Skip to main content

dynamo_runtime/pipeline/network/egress/
push_router.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::{AsyncEngineContextProvider, ResponseStream};
5use crate::error::{BackendError, DynamoError, ErrorType, match_error_chain};
6use crate::{
7    component::{
8        Client, DeviceType, Endpoint, Instance, RoutingInstances, RoutingOccupancyState,
9        get_or_create_routing_occupancy_state,
10    },
11    discovery::EndpointInstanceId,
12    dynamo_nvtx_range,
13    engine::{AsyncEngine, AsyncEngineContext, Data},
14    metrics::frontend_perf::{STAGE_DURATION_SECONDS, STAGE_ROUTE},
15    pipeline::{
16        AddressedPushRouter, AddressedRequest, Error, ManyIn, ManyOut, SingleIn,
17        error::{PipelineError, PipelineErrorExt},
18    },
19    protocols::{EndpointId, maybe_error::MaybeError},
20    traits::DistributedRuntimeProvider,
21};
22use async_trait::async_trait;
23use futures::Stream;
24use rand::Rng;
25use serde::{Deserialize, Serialize};
26use std::{
27    collections::{HashMap, HashSet},
28    marker::PhantomData,
29    pin::Pin,
30    sync::{
31        Arc,
32        atomic::{AtomicU64, Ordering},
33    },
34    task::Poll,
35    time::Instant,
36};
37use tokio_stream::StreamExt;
38use tracing::Instrument;
39
40/// Check if an error chain indicates the worker should be reported as down.
41fn is_inhibited(err: &(dyn std::error::Error + 'static)) -> bool {
42    const INHIBITED: &[ErrorType] = &[
43        ErrorType::CannotConnect,
44        ErrorType::Disconnected,
45        ErrorType::ConnectionTimeout,
46        ErrorType::ResponseTimeout,
47        ErrorType::Backend(BackendError::EngineShutdown),
48    ];
49    match_error_chain(err, INHIBITED, &[])
50}
51
52/// Read the backend response inactivity timeout from the environment.
53/// Reuses `DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS` — the same env var
54/// as the HTTP-layer safety net in `disconnect.rs`.
55fn response_inactivity_timeout() -> Option<std::time::Duration> {
56    use crate::config::environment_names::llm::DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS;
57    std::env::var(DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS)
58        .ok()
59        .and_then(|s| s.parse::<u64>().ok())
60        .filter(|&secs| secs > 0)
61        .map(std::time::Duration::from_secs)
62}
63
64/// RAII handle for one in-flight unit of work charged against
65/// [`RoutingOccupancyState`]. The counter is incremented at construction; the
66/// matching decrement is emitted on drop (or by [`Self::into_tracked_stream`]).
67struct OccupancyPermit {
68    state: Arc<RoutingOccupancyState>,
69    instance_id: u64,
70    armed: bool,
71}
72
73impl OccupancyPermit {
74    fn new(state: Arc<RoutingOccupancyState>, instance_id: u64) -> Self {
75        Self {
76            state,
77            instance_id,
78            armed: true,
79        }
80    }
81
82    fn retarget(&mut self, instance_id: u64) {
83        if self.instance_id == instance_id {
84            return;
85        }
86        self.state.increment(instance_id);
87        self.state.decrement(self.instance_id);
88        self.instance_id = instance_id;
89    }
90
91    fn into_tracked_stream<U: Data>(mut self, stream: ManyOut<U>) -> ManyOut<U> {
92        self.armed = false;
93        let engine_ctx = stream.context();
94        ResponseStream::new(
95            Box::pin(OccupancyTrackedStream {
96                inner: stream,
97                state: self.state.clone(),
98                instance_id: self.instance_id,
99                released: false,
100            }),
101            engine_ctx,
102        )
103    }
104}
105
106impl Drop for OccupancyPermit {
107    fn drop(&mut self) {
108        if self.armed {
109            self.state.decrement(self.instance_id);
110        }
111    }
112}
113
114/// Trait for monitoring worker load and determining overload state.
115/// Implementations can define custom load metrics and overload thresholds.
116#[async_trait]
117pub trait WorkerLoadMonitor: Send + Sync {
118    /// Start background monitoring of worker load.
119    /// This should spawn background tasks that update the client's overloaded instances.
120    async fn start_monitoring(&self) -> anyhow::Result<()>;
121}
122
123/// Query interface for routing against multimodal embedding cache state.
124pub trait MultimodalCacheIndex: Send + Sync {
125    fn workers_with_cache_key_hits(&self, cache_keys: &[String]) -> Vec<(u64, usize)>;
126    fn remove_worker(&self, worker_id: u64);
127}
128
129pub type MultimodalCacheKeyExtractor<T> = Arc<dyn Fn(&T) -> Vec<String> + Send + Sync>;
130
131#[derive(Clone)]
132pub struct PushRouter<T, U>
133where
134    T: Data + Serialize,
135    U: Data + for<'de> Deserialize<'de>,
136{
137    // TODO: This shouldn't be pub, but lib/bindings/python/rust/lib.rs exposes it.
138    /// The Client is how we gather remote endpoint information from etcd.
139    pub client: Client,
140
141    /// How we choose which instance to send traffic to.
142    ///
143    /// Setting this to KV means we never intend to call `generate` on this PushRouter. We are
144    /// not using it as an AsyncEngine.
145    /// Instead we will decide whether to call random/round_robin/direct ourselves and call them directly.
146    /// dynamo-llm's KV Routing does this.
147    router_mode: RouterMode,
148
149    /// Number of round robin requests handled. Used to decide which server is next.
150    round_robin_counter: Arc<AtomicU64>,
151
152    /// The next step in the chain. PushRouter (this object) picks an instances,
153    /// addresses it, then passes it to AddressedPushRouter which does the network traffic.
154    addressed: Arc<AddressedPushRouter>,
155
156    /// When false, `generate_with_fault_detection` skips fault detection logic:
157    /// it won't call `report_instance_down` on errors, and it uses the raw discovery
158    /// instance list instead of the filtered avail list. Use for recovery/query paths
159    /// where transient failures are expected.
160    fault_detection_enabled: bool,
161
162    /// Cached response inactivity timeout. Read once at construction from
163    /// [`environment_names::llm::DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS`](crate::config::environment_names::llm::DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS) to avoid a syscall per request.
164    response_timeout: Option<std::time::Duration>,
165
166    /// Shared request occupancy state for tracked routing modes.
167    occupancy_state: Option<Arc<RoutingOccupancyState>>,
168
169    /// Optional cache index for direct multimodal embedding cache lookups.
170    /// Currently consumed by `RouterMode::DeviceAwareWeighted`.
171    multimodal_cache_indexer: Option<Arc<dyn MultimodalCacheIndex>>,
172
173    /// Optional typed request extractor for multimodal embedding cache keys.
174    multimodal_cache_key_extractor: Option<MultimodalCacheKeyExtractor<T>>,
175
176    /// An internal Rust type. This says that PushRouter is generic over the T and U types,
177    /// which are the input and output types of it's `generate` function. It allows the
178    /// compiler to specialize us at compile time.
179    _phantom: PhantomData<(T, U)>,
180}
181
182#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum RouterMode {
185    #[default]
186    RoundRobin,
187    Random,
188    PowerOfTwoChoices,
189    KV,
190    Direct,
191    LeastLoaded,
192    /// Device-aware weighted routing for heterogeneous workers.
193    DeviceAwareWeighted,
194}
195
196#[derive(Clone, Copy)]
197enum TransportFallback<'a> {
198    Allow,
199    Deny,
200    Within(&'a HashSet<u64>),
201}
202
203struct DeviceAwareCandidates {
204    candidates: Vec<u64>,
205    device_type_map: HashMap<u64, Option<DeviceType>>,
206    embedding_cache_hit: bool,
207    full_embedding_cache_hit: bool,
208    request_cache_keys: usize,
209}
210
211impl RouterMode {
212    pub fn is_kv_routing(&self) -> bool {
213        *self == RouterMode::KV
214    }
215
216    pub fn is_direct_routing(&self) -> bool {
217        *self == RouterMode::Direct
218    }
219}
220
221/// Pick the instance with lower in-flight count from two random candidates.
222/// Returns the single instance if only one is available.
223fn p2c_select_from(occupancy_state: &RoutingOccupancyState, instance_ids: &[u64]) -> u64 {
224    let count = instance_ids.len();
225    if count == 1 {
226        let worker_id = instance_ids[0];
227        tracing::info!(
228            router_mode = "power-of-two-choices",
229            worker_id,
230            candidate_count = count,
231            load = occupancy_state.load(worker_id),
232            "Selected worker"
233        );
234        return worker_id;
235    }
236    let mut rng = rand::rng();
237    let idx1 = rng.random_range(0..count);
238    let idx2 = (idx1 + 1 + rng.random_range(0..count - 1)) % count;
239    let id1 = instance_ids[idx1];
240    let id2 = instance_ids[idx2];
241    let load1 = occupancy_state.load(id1);
242    let load2 = occupancy_state.load(id2);
243    let selected = if load1 <= load2 { id1 } else { id2 };
244    tracing::info!(
245        router_mode = "power-of-two-choices",
246        worker_id = selected,
247        candidate_count = count,
248        load = std::cmp::min(load1, load2),
249        candidate_a = id1,
250        candidate_a_load = load1,
251        candidate_b = id2,
252        candidate_b_load = load2,
253        "Selected worker"
254    );
255    selected
256}
257
258/// Select the target device group for the next request in `DeviceAwareWeighted` mode.
259///
260/// If only one class exists (all CPU or all non-CPU), returns that class directly.
261/// If both classes exist, compares capability-normalized load and returns the less-loaded group.
262///
263/// Budget check (integer form):
264/// `allowed_cpu_inflight = total_non_cpu_inflight * cpu_count / (ratio * non_cpu_count)`
265/// and choose CPU when `total_cpu_inflight < allowed_cpu_inflight`.
266///
267/// `ratio` is `non_cpu_to_cpu_ratio` (from `DYN_ENCODER_CUDA_TO_CPU_RATIO`,
268/// default `8` in `device_aware_weighted`).
269fn device_aware_candidate_group(
270    state: &RoutingOccupancyState,
271    instance_ids: &[u64],
272    device_type_map: &HashMap<u64, Option<DeviceType>>,
273    non_cpu_to_cpu_ratio: usize,
274) -> Vec<u64> {
275    let cpu_ids: Vec<u64> = instance_ids
276        .iter()
277        .copied()
278        .filter(|id| matches!(device_type_map.get(id), Some(Some(DeviceType::Cpu))))
279        .collect();
280    let non_cpu_ids: Vec<u64> = instance_ids
281        .iter()
282        .copied()
283        .filter(|id| !matches!(device_type_map.get(id), Some(Some(DeviceType::Cpu))))
284        .collect();
285
286    if cpu_ids.is_empty() {
287        return non_cpu_ids;
288    }
289    if non_cpu_ids.is_empty() {
290        return cpu_ids;
291    }
292
293    // Both classes exist: compute a budget for CPU in-flight requests.
294    let total_non_cpu_inflight: u64 = non_cpu_ids.iter().map(|id| state.load(*id)).sum();
295    let total_cpu_inflight: u64 = cpu_ids.iter().map(|id| state.load(*id)).sum();
296    let cpu_count = cpu_ids.len() as u64;
297    let non_cpu_count = non_cpu_ids.len() as u64;
298    let allowed_cpu_inflight = total_non_cpu_inflight.saturating_mul(cpu_count)
299        / ((non_cpu_to_cpu_ratio as u64).saturating_mul(non_cpu_count));
300
301    if total_cpu_inflight < allowed_cpu_inflight {
302        cpu_ids
303    } else {
304        non_cpu_ids
305    }
306}
307
308/// At most one `list_and_watch` per endpoint, across all `PushRouter`
309/// instances. Entry removed on watcher exit so a later router can re-arm.
310static ENDPOINT_WATCHER_ACTIVE: std::sync::OnceLock<dashmap::DashMap<EndpointId, ()>> =
311    std::sync::OnceLock::new();
312
313/// At most one multimodal cache cleanup watcher per endpoint.
314static ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE: std::sync::OnceLock<
315    dashmap::DashMap<EndpointId, ()>,
316> = std::sync::OnceLock::new();
317
318/// Watch discovery for instance removals and cancel pending response-stream
319/// registrations on the removed instance, unblocking queued requests with
320/// a migratable `Disconnected` error. Uses raw `list_and_watch` events
321/// (not a coalesced snapshot diff) so a rapid remove→re-add of the same
322/// identity is not silently swallowed. Keyed by full `EndpointInstanceId`.
323fn spawn_instance_removal_watcher(
324    endpoint: Endpoint,
325    addressed: Arc<AddressedPushRouter>,
326    cancel_token: tokio_util::sync::CancellationToken,
327) {
328    use crate::discovery::{
329        DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
330    };
331    use tokio_stream::StreamExt as _;
332
333    // One watcher per endpoint: if one is already running, skip.
334    let guard = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
335    let endpoint_id = endpoint.id();
336    if guard.insert(endpoint_id.clone(), ()).is_some() {
337        tracing::debug!(
338            ?endpoint_id,
339            "Instance removal watcher already running for this endpoint, skipping"
340        );
341        return;
342    }
343
344    let endpoint_name = endpoint.name().to_string();
345
346    tokio::spawn(async move {
347        // Release on every exit path (including panic); a leaked entry
348        // silently disables removal cancellation until process restart.
349        struct GuardRelease(EndpointId);
350        impl Drop for GuardRelease {
351            fn drop(&mut self) {
352                if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
353                    map.remove(&self.0);
354                }
355            }
356        }
357        let _release = GuardRelease(endpoint_id);
358
359        let namespace = endpoint.component().namespace().name();
360        let component = endpoint.component().name().to_string();
361
362        // Reconnect on transient discovery failure; cancel-aware backoff.
363        const RECONNECT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
364        'reconnect: loop {
365            let query = DiscoveryQuery::Endpoint {
366                namespace: namespace.clone(),
367                component: component.clone(),
368                endpoint: endpoint_name.clone(),
369            };
370
371            let mut stream = match endpoint.drt().discovery().list_and_watch(query, None).await {
372                Ok(s) => s,
373                Err(e) => {
374                    tracing::warn!(
375                        endpoint = %endpoint_name,
376                        "Failed to start instance removal watcher (will retry): {e}"
377                    );
378                    tokio::select! {
379                        _ = tokio::time::sleep(RECONNECT_BACKOFF) => continue 'reconnect,
380                        _ = cancel_token.cancelled() => break 'reconnect,
381                    }
382                }
383            };
384
385            loop {
386                tokio::select! {
387                    event = stream.next() => {
388                        match event {
389                            Some(Ok(DiscoveryEvent::Removed(id))) => {
390                                if let DiscoveryInstanceId::Endpoint(eid) = &id {
391                                    let n = addressed.cancel_instance_streams(eid).await;
392                                    if n > 0 {
393                                        tracing::warn!(
394                                            namespace = %eid.namespace,
395                                            component = %eid.component,
396                                            endpoint = %eid.endpoint,
397                                            instance_id = eid.instance_id,
398                                            cancelled = n,
399                                            "Cancelled pending response streams for removed \
400                                             instance (discovery-driven cleanup)"
401                                        );
402                                    }
403                                }
404                            }
405                            Some(Ok(DiscoveryEvent::Added(DiscoveryInstance::Endpoint(inst)))) => {
406                                let eid: EndpointInstanceId = inst.endpoint_instance_id();
407                                addressed.clear_instance_tombstone(&eid).await;
408                            }
409                            Some(Ok(_)) => {}
410                            Some(Err(e)) => {
411                                tracing::warn!(
412                                    endpoint = %endpoint_name,
413                                    "Instance removal watcher stream error: {e}"
414                                );
415                            }
416                            None => {
417                                tracing::warn!(
418                                    endpoint = %endpoint_name,
419                                    "Instance removal watcher stream ended; reconnecting"
420                                );
421                                continue 'reconnect;
422                            }
423                        }
424                    }
425                    _ = cancel_token.cancelled() => {
426                        break 'reconnect;
427                    }
428                }
429            }
430        }
431
432        tracing::debug!(endpoint = %endpoint_name, "Instance removal watcher exiting");
433    });
434}
435
436/// Watch discovery removals for cache-aware routers and drop stale worker cache entries.
437fn spawn_multimodal_cache_cleanup_watcher(
438    endpoint: Endpoint,
439    indexer: Arc<dyn MultimodalCacheIndex>,
440    cancel_token: tokio_util::sync::CancellationToken,
441) {
442    use crate::discovery::{DiscoveryEvent, DiscoveryInstanceId, DiscoveryQuery};
443    use tokio_stream::StreamExt as _;
444
445    let guard = ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
446    let endpoint_id = endpoint.id();
447    if guard.insert(endpoint_id.clone(), ()).is_some() {
448        tracing::debug!(
449            ?endpoint_id,
450            "Multimodal cache cleanup watcher already running for this endpoint, skipping"
451        );
452        return;
453    }
454
455    let endpoint_name = endpoint.name().to_string();
456    let namespace = endpoint.component().namespace().name();
457    let component = endpoint.component().name().to_string();
458
459    tokio::spawn(async move {
460        struct GuardRelease(EndpointId);
461        impl Drop for GuardRelease {
462            fn drop(&mut self) {
463                if let Some(map) = ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE.get() {
464                    map.remove(&self.0);
465                }
466            }
467        }
468        let _release = GuardRelease(endpoint_id);
469
470        const RECONNECT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
471        'reconnect: loop {
472            let query = DiscoveryQuery::Endpoint {
473                namespace: namespace.clone(),
474                component: component.clone(),
475                endpoint: endpoint_name.clone(),
476            };
477
478            let mut stream = match endpoint.drt().discovery().list_and_watch(query, None).await {
479                Ok(stream) => stream,
480                Err(error) => {
481                    tracing::warn!(
482                        endpoint = %endpoint_name,
483                        "Failed to start multimodal cache cleanup watcher (will retry): {error}"
484                    );
485                    tokio::select! {
486                        _ = tokio::time::sleep(RECONNECT_BACKOFF) => continue 'reconnect,
487                        _ = cancel_token.cancelled() => break 'reconnect,
488                    }
489                }
490            };
491
492            loop {
493                tokio::select! {
494                    event = stream.next() => {
495                        match event {
496                            Some(Ok(DiscoveryEvent::Removed(DiscoveryInstanceId::Endpoint(eid)))) => {
497                                indexer.remove_worker(eid.instance_id);
498                            }
499                            Some(Ok(_)) => {}
500                            Some(Err(error)) => {
501                                tracing::warn!(
502                                    endpoint = %endpoint_name,
503                                    "Multimodal cache cleanup watcher stream error: {error}"
504                                );
505                                continue 'reconnect;
506                            }
507                            None => {
508                                tracing::warn!(
509                                    endpoint = %endpoint_name,
510                                    "Multimodal cache cleanup watcher stream ended; reconnecting"
511                                );
512                                continue 'reconnect;
513                            }
514                        }
515                    }
516                    _ = cancel_token.cancelled() => break 'reconnect,
517                }
518            }
519        }
520
521        tracing::debug!(endpoint = %endpoint_name, "Multimodal cache cleanup watcher exiting");
522    });
523}
524
525async fn addressed_router(endpoint: &Endpoint) -> anyhow::Result<Arc<AddressedPushRouter>> {
526    AddressedPushRouter::from_runtime_provider(endpoint).await
527}
528
529impl<T, U> PushRouter<T, U>
530where
531    T: Data + Serialize,
532    U: Data + for<'de> Deserialize<'de> + MaybeError,
533{
534    /// Create a new PushRouter without a worker load monitor (no overload detection)
535    pub async fn from_client(client: Client, router_mode: RouterMode) -> anyhow::Result<Self> {
536        Self::from_client_with_monitor(client, router_mode, None).await
537    }
538
539    /// Create a new PushRouter with fault detection disabled.
540    ///
541    /// Unlike `from_client`, this router will not call `report_instance_down` on
542    /// transient errors, and `direct()` uses the raw discovery instance list instead
543    /// of the filtered avail list. Use for recovery/query paths.
544    pub async fn from_client_no_fault_detection(
545        client: Client,
546        router_mode: RouterMode,
547    ) -> anyhow::Result<Self> {
548        let addressed = addressed_router(&client.endpoint).await?;
549
550        let occupancy_state = if matches!(
551            router_mode,
552            RouterMode::PowerOfTwoChoices
553                | RouterMode::LeastLoaded
554                | RouterMode::DeviceAwareWeighted
555        ) {
556            Some(get_or_create_routing_occupancy_state(&client.endpoint).await)
557        } else {
558            None
559        };
560
561        // Cancel orphaned pending response streams when workers die.
562        spawn_instance_removal_watcher(
563            client.endpoint.clone(),
564            addressed.clone(),
565            client.endpoint.drt().primary_token(),
566        );
567
568        Ok(PushRouter {
569            client,
570            addressed,
571            router_mode,
572            round_robin_counter: Arc::new(AtomicU64::new(0)),
573            fault_detection_enabled: false,
574            response_timeout: response_inactivity_timeout(),
575            occupancy_state,
576            multimodal_cache_indexer: None,
577            multimodal_cache_key_extractor: None,
578            _phantom: PhantomData,
579        })
580    }
581
582    /// Create a new PushRouter with an optional worker load monitor.
583    ///
584    /// The rejection path is gated by `fault_detection_enabled` (true here);
585    /// overload detection itself is driven by the monitor via `client.set_overloaded_instances(...)`.
586    /// If no thresholds are configured on the monitor (or no monitor is provided),
587    /// the routing snapshot reports at least one free instance and the gate never rejects.
588    pub async fn from_client_with_monitor(
589        client: Client,
590        router_mode: RouterMode,
591        worker_monitor: Option<Arc<dyn WorkerLoadMonitor>>,
592    ) -> anyhow::Result<Self> {
593        Self::from_client_with_state(client, router_mode, worker_monitor, None, None).await
594    }
595
596    /// Create a new PushRouter with optional load monitoring and multimodal cache indexing.
597    pub async fn from_client_with_state(
598        client: Client,
599        router_mode: RouterMode,
600        worker_monitor: Option<Arc<dyn WorkerLoadMonitor>>,
601        multimodal_cache_indexer: Option<Arc<dyn MultimodalCacheIndex>>,
602        multimodal_cache_key_extractor: Option<MultimodalCacheKeyExtractor<T>>,
603    ) -> anyhow::Result<Self> {
604        let addressed = addressed_router(&client.endpoint).await?;
605
606        // Start worker monitor if provided and in dynamic mode
607        if let Some(monitor) = worker_monitor.as_ref() {
608            monitor.start_monitoring().await?;
609        }
610
611        let occupancy_state = if matches!(
612            router_mode,
613            RouterMode::PowerOfTwoChoices
614                | RouterMode::LeastLoaded
615                | RouterMode::DeviceAwareWeighted
616        ) {
617            Some(get_or_create_routing_occupancy_state(&client.endpoint).await)
618        } else {
619            None
620        };
621
622        // Cancel orphaned pending response streams when workers die.
623        spawn_instance_removal_watcher(
624            client.endpoint.clone(),
625            addressed.clone(),
626            client.endpoint.drt().primary_token(),
627        );
628
629        // Drop stale cache-index entries when workers leave discovery.
630        if let Some(indexer) = multimodal_cache_indexer.clone() {
631            spawn_multimodal_cache_cleanup_watcher(
632                client.endpoint.clone(),
633                indexer,
634                client.endpoint.drt().primary_token(),
635            );
636        }
637
638        let router = PushRouter {
639            client,
640            addressed,
641            router_mode,
642            round_robin_counter: Arc::new(AtomicU64::new(0)),
643            fault_detection_enabled: true,
644            response_timeout: response_inactivity_timeout(),
645            occupancy_state,
646            multimodal_cache_indexer,
647            multimodal_cache_key_extractor,
648            _phantom: PhantomData,
649        };
650
651        Ok(router)
652    }
653
654    /// `ResourceExhausted` when workers are routable but all overloaded;
655    /// `Unavailable` when no routable workers exist.
656    fn empty_free_pool_error(&self, routing_instances: &RoutingInstances) -> anyhow::Error {
657        if !routing_instances.routable_ids().is_empty() {
658            let cause = PipelineError::ServiceOverloaded(
659                "All workers are busy, please retry later".to_string(),
660            );
661            return DynamoError::builder()
662                .error_type(ErrorType::ResourceExhausted)
663                .message("All workers are busy, please retry later")
664                .cause(cause)
665                .build()
666                .into();
667        }
668        DynamoError::builder()
669            .error_type(ErrorType::Unavailable)
670            .message(format!(
671                "No workers available for endpoint {}",
672                self.client.endpoint.id()
673            ))
674            .build()
675            .into()
676    }
677
678    /// Issue a request to the next available instance in a round-robin fashion
679    pub async fn round_robin(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
680        self.round_robin_prepared(request, |_, _| Ok(()))
681            .await
682            .map(|(_, stream)| stream)
683    }
684
685    async fn round_robin_prepared<M, F>(
686        &self,
687        request: SingleIn<T>,
688        prepare: F,
689    ) -> anyhow::Result<(M, ManyOut<U>)>
690    where
691        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
692    {
693        let counter = self.round_robin_counter.fetch_add(1, Ordering::Relaxed) as usize;
694
695        let (instance_id, candidate_count) = {
696            let routing_instances = self.client.routing_instances();
697            let count = routing_instances.free_ids().len();
698            if count == 0 {
699                return Err(self.empty_free_pool_error(&routing_instances));
700            }
701            (routing_instances.free_ids()[counter % count], count)
702        };
703        tracing::info!(
704            router_mode = "round-robin",
705            worker_id = instance_id,
706            candidate_count,
707            "Selected worker"
708        );
709
710        self.dispatch_selected(instance_id, request, None, prepare)
711            .await
712    }
713
714    /// Issue a request to a random endpoint
715    pub async fn random(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
716        self.random_prepared(request, |_, _| Ok(()))
717            .await
718            .map(|(_, stream)| stream)
719    }
720
721    async fn random_prepared<M, F>(
722        &self,
723        request: SingleIn<T>,
724        prepare: F,
725    ) -> anyhow::Result<(M, ManyOut<U>)>
726    where
727        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
728    {
729        let (instance_id, candidate_count) = {
730            let routing_instances = self.client.routing_instances();
731            let count = routing_instances.free_ids().len();
732            if count == 0 {
733                return Err(self.empty_free_pool_error(&routing_instances));
734            }
735            let counter = rand::rng().random::<u64>() as usize;
736            (routing_instances.free_ids()[counter % count], count)
737        };
738        tracing::info!(
739            router_mode = "random",
740            worker_id = instance_id,
741            candidate_count,
742            "Selected worker"
743        );
744
745        self.dispatch_selected(instance_id, request, None, prepare)
746            .await
747    }
748
749    /// Issue a request using power-of-two-choices: pick 2 random healthy workers,
750    /// route to the one with fewer in-flight requests.
751    pub async fn power_of_two_choices(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
752        self.power_of_two_choices_prepared(request, |_, _| Ok(()))
753            .await
754            .map(|(_, stream)| stream)
755    }
756
757    async fn power_of_two_choices_prepared<M, F>(
758        &self,
759        request: SingleIn<T>,
760        prepare: F,
761    ) -> anyhow::Result<(M, ManyOut<U>)>
762    where
763        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
764    {
765        let state = self.occupancy_state()?;
766        let instance_id = {
767            let routing_instances = self.client.routing_instances();
768            if routing_instances.free_ids().is_empty() {
769                return Err(self.empty_free_pool_error(&routing_instances));
770            }
771            p2c_select_from(state.as_ref(), routing_instances.free_ids())
772        };
773        state.increment(instance_id);
774        let permit = OccupancyPermit::new(state, instance_id);
775        self.dispatch_selected(instance_id, request, Some(permit), prepare)
776            .await
777    }
778
779    /// Issue a request to exactly one endpoint without transport fallback.
780    pub async fn direct(
781        &self,
782        request: SingleIn<T>,
783        instance_id: u64,
784    ) -> anyhow::Result<ManyOut<U>> {
785        tracing::info!(
786            router_mode = "direct",
787            worker_id = instance_id,
788            "Selected worker"
789        );
790        self.generate_with_fault_detection(instance_id, request, TransportFallback::Deny)
791            .await
792    }
793
794    /// Dispatch to a selected endpoint with transport fallback.
795    ///
796    /// Unlike [`Self::direct`], if the selected instance disappears between selection and
797    /// dispatch, this method may reselect another worker. When `allowed_fallback` is `Some`,
798    /// reselection is constrained to that set; callers that pre-narrowed the candidates (e.g.
799    /// LoRA replica-set filtering) use it to prevent fallback to an arbitrary worker.
800    pub async fn direct_within(
801        &self,
802        request: SingleIn<T>,
803        instance_id: u64,
804        allowed_fallback: Option<&HashSet<u64>>,
805    ) -> anyhow::Result<ManyOut<U>> {
806        self.direct_within_prepared(request, instance_id, allowed_fallback, |_, _| Ok(()))
807            .await
808            .map(|(_, stream)| stream)
809    }
810
811    /// Like [`Self::direct_within`], but prepares the request after transport resolution and
812    /// returns the preparation metadata alongside the response stream.
813    pub async fn direct_within_prepared<M, F>(
814        &self,
815        request: SingleIn<T>,
816        instance_id: u64,
817        allowed_fallback: Option<&HashSet<u64>>,
818        prepare: F,
819    ) -> anyhow::Result<(M, ManyOut<U>)>
820    where
821        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
822    {
823        // Fallback-enabled dispatch still honors a selected worker while it remains in
824        // discovery. Local inhibition only filters worker selection owned by this router;
825        // fallback is considered only if the selected worker disappears after this check.
826        if !self.client.instance_ids().contains(&instance_id) {
827            return Err(DynamoError::builder()
828                .error_type(ErrorType::CannotConnect)
829                .message(format!(
830                    "instance_id={instance_id} not found for endpoint {}",
831                    self.client.endpoint.id()
832                ))
833                .build()
834                .into());
835        }
836
837        tracing::info!(
838            router_mode = "direct",
839            worker_id = instance_id,
840            "Selected worker"
841        );
842
843        let fallback = allowed_fallback
844            .map(TransportFallback::Within)
845            .unwrap_or(TransportFallback::Allow);
846        self.generate_with_fault_detection_prepared(instance_id, request, fallback, prepare)
847            .await
848    }
849
850    /// Dispatch to exactly one worker without transport fallback.
851    ///
852    /// The worker is revalidated against the latest discovery and overload
853    /// state immediately before dispatch.
854    pub async fn dispatch_exact(
855        &self,
856        request: SingleIn<T>,
857        instance_id: u64,
858    ) -> anyhow::Result<ManyOut<U>> {
859        self.generate_with_fault_detection(instance_id, request, TransportFallback::Deny)
860            .await
861    }
862
863    /// Select and book one worker, prepare the request for that exact worker,
864    /// then dispatch without reselection or transport fallback.
865    pub async fn select_and_dispatch_exact<M, F>(
866        &self,
867        mut request: SingleIn<T>,
868        pinned_worker: Option<u64>,
869        prepare: F,
870    ) -> anyhow::Result<(M, ManyOut<U>)>
871    where
872        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
873    {
874        let (instance_id, permit) = self
875            .select_exact_target(request.content(), pinned_worker)
876            .await?;
877        let metadata = prepare(&mut request, instance_id)?;
878        let stream = self.dispatch_exact(request, instance_id).await?;
879        let stream = match permit {
880            Some(permit) => permit.into_tracked_stream(stream),
881            None => stream,
882        };
883        Ok((metadata, stream))
884    }
885
886    /// Select a worker using the configured routing mode, prepare the request with the worker
887    /// that survives transport resolution, then dispatch with normal fallback behavior.
888    pub async fn select_and_dispatch<M, F>(
889        &self,
890        request: SingleIn<T>,
891        prepare: F,
892    ) -> anyhow::Result<(M, ManyOut<U>)>
893    where
894        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
895    {
896        match self.router_mode {
897            RouterMode::Random => self.random_prepared(request, prepare).await,
898            RouterMode::RoundRobin => self.round_robin_prepared(request, prepare).await,
899            RouterMode::PowerOfTwoChoices => {
900                self.power_of_two_choices_prepared(request, prepare).await
901            }
902            RouterMode::LeastLoaded => self.least_loaded_prepared(request, prepare).await,
903            RouterMode::DeviceAwareWeighted => {
904                self.device_aware_weighted_prepared(request, prepare).await
905            }
906            RouterMode::KV => anyhow::bail!("KV routing should not call select_and_dispatch"),
907            RouterMode::Direct => anyhow::bail!(
908                "Direct routing should use direct_within_prepared instead of select_and_dispatch"
909            ),
910        }
911    }
912
913    async fn dispatch_selected<M, F>(
914        &self,
915        instance_id: u64,
916        request: SingleIn<T>,
917        mut permit: Option<OccupancyPermit>,
918        prepare: F,
919    ) -> anyhow::Result<(M, ManyOut<U>)>
920    where
921        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
922    {
923        let (metadata, stream) = self
924            .generate_with_fault_detection_prepared(
925                instance_id,
926                request,
927                TransportFallback::Allow,
928                |request, resolved_instance_id| {
929                    if let Some(permit) = permit.as_mut() {
930                        permit.retarget(resolved_instance_id);
931                    }
932                    prepare(request, resolved_instance_id)
933                },
934            )
935            .await?;
936        let stream = match permit {
937            Some(permit) => permit.into_tracked_stream(stream),
938            None => stream,
939        };
940        Ok((metadata, stream))
941    }
942
943    /// Issue a request using device-aware weighted routing.
944    ///
945    /// Instances are partitioned by device type (CPU vs non-CPU), then the router
946    /// applies a budget policy and selects the least-loaded instance within the
947    /// chosen group.
948    ///
949    /// If only one device class exists (all CPU or all non-CPU), this naturally
950    /// degenerates to least-loaded routing over the available instances.
951    pub async fn device_aware_weighted(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
952        self.device_aware_weighted_prepared(request, |_, _| Ok(()))
953            .await
954            .map(|(_, stream)| stream)
955    }
956
957    async fn device_aware_weighted_prepared<M, F>(
958        &self,
959        request: SingleIn<T>,
960        prepare: F,
961    ) -> anyhow::Result<(M, ManyOut<U>)>
962    where
963        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
964    {
965        let state = self.occupancy_state()?;
966        let routing_instances = self.client.routing_instances();
967        let instance_ids = routing_instances.free_ids().to_vec();
968
969        if instance_ids.is_empty() {
970            return Err(self.empty_free_pool_error(&routing_instances));
971        }
972
973        // Apply a unified policy for all endpoints.
974        let endpoint_id = self.client.endpoint.id();
975
976        let selection =
977            self.device_aware_candidates(request.content(), state.as_ref(), &instance_ids);
978
979        // Only full cache hits bypass weighted accounting; partial hits still follow the
980        // device-aware ratio because some image encoding remains for this request.
981        let instance_id = if selection.full_embedding_cache_hit {
982            state.select_exact_min(&selection.candidates).await
983        } else {
984            state
985                .select_exact_min_and_increment(&selection.candidates)
986                .await
987        }
988        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
989        let permit = if selection.full_embedding_cache_hit {
990            None
991        } else {
992            Some(OccupancyPermit::new(state.clone(), instance_id))
993        };
994        let is_cpu = matches!(
995            selection.device_type_map.get(&instance_id),
996            Some(Some(DeviceType::Cpu))
997        );
998        tracing::info!(
999            router_mode = "device-aware-weighted",
1000            worker_id = instance_id,
1001            candidate_count = selection.candidates.len(),
1002            load = state.load(instance_id),
1003            endpoint = %endpoint_id,
1004            is_cpu,
1005            embedding_cache_hit = selection.embedding_cache_hit,
1006            request_cache_keys = selection.request_cache_keys,
1007            "Selected worker"
1008        );
1009
1010        self.dispatch_selected(instance_id, request, permit, prepare)
1011            .await
1012    }
1013
1014    fn device_aware_candidates(
1015        &self,
1016        request: &T,
1017        state: &RoutingOccupancyState,
1018        instance_ids: &[u64],
1019    ) -> DeviceAwareCandidates {
1020        let device_type_map = self
1021            .client
1022            .instances()
1023            .iter()
1024            .map(|instance| (instance.instance_id, instance.device_type.clone()))
1025            .collect();
1026        let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1027            .ok()
1028            .and_then(|value| value.parse::<usize>().ok())
1029            .filter(|value| *value >= 1)
1030            .unwrap_or(8);
1031
1032        let (request_cache_keys, cache_matched_candidates) =
1033            if let (Some(indexer), Some(extractor)) = (
1034                self.multimodal_cache_indexer.as_ref(),
1035                self.multimodal_cache_key_extractor.as_ref(),
1036            ) {
1037                let request_cache_keys = extractor(request);
1038                let matched = if request_cache_keys.is_empty() {
1039                    Vec::new()
1040                } else {
1041                    let mut matched = indexer.workers_with_cache_key_hits(&request_cache_keys);
1042                    matched.retain(|(id, _)| instance_ids.contains(id));
1043                    matched
1044                };
1045                (request_cache_keys, matched)
1046            } else {
1047                (Vec::new(), Vec::new())
1048            };
1049
1050        let embedding_cache_hit = !cache_matched_candidates.is_empty();
1051        let request_cache_key_count = request_cache_keys
1052            .iter()
1053            .collect::<std::collections::HashSet<_>>()
1054            .len();
1055        let full_cache_candidates = cache_matched_candidates
1056            .iter()
1057            .filter_map(|(worker_id, hits)| {
1058                (*hits >= request_cache_key_count).then_some(*worker_id)
1059            })
1060            .collect::<Vec<_>>();
1061        let full_embedding_cache_hit = !full_cache_candidates.is_empty();
1062        let candidates = if full_embedding_cache_hit {
1063            full_cache_candidates
1064        } else {
1065            device_aware_candidate_group(state, instance_ids, &device_type_map, cuda_to_cpu_ratio)
1066        };
1067
1068        DeviceAwareCandidates {
1069            candidates,
1070            device_type_map,
1071            embedding_cache_hit,
1072            full_embedding_cache_hit,
1073            request_cache_keys: request_cache_keys.len(),
1074        }
1075    }
1076
1077    /// Issue a request to the instance with the fewest active connections.
1078    pub async fn least_loaded(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1079        self.least_loaded_prepared(request, |_, _| Ok(()))
1080            .await
1081            .map(|(_, stream)| stream)
1082    }
1083
1084    async fn least_loaded_prepared<M, F>(
1085        &self,
1086        request: SingleIn<T>,
1087        prepare: F,
1088    ) -> anyhow::Result<(M, ManyOut<U>)>
1089    where
1090        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1091    {
1092        let state = self.occupancy_state()?;
1093        let routing_instances = self.client.routing_instances();
1094        let instance_ids = routing_instances.free_ids().to_vec();
1095        let instance_id = state
1096            .select_exact_min_and_increment(&instance_ids)
1097            .await
1098            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1099        let permit = OccupancyPermit::new(state.clone(), instance_id);
1100        tracing::info!(
1101            router_mode = "least-loaded",
1102            worker_id = instance_id,
1103            candidate_count = instance_ids.len(),
1104            load = state.load(instance_id),
1105            "Selected worker"
1106        );
1107
1108        self.dispatch_selected(instance_id, request, Some(permit), prepare)
1109            .await
1110    }
1111
1112    /// Select the next worker according to the routing mode.
1113    /// Increments round-robin counter if applicable.
1114    /// Returns None for modes that require request lifecycle tracking or explicit routing hints.
1115    pub fn select_next_worker(&self) -> Option<u64> {
1116        let routing_instances = self.client.routing_instances();
1117        let count = routing_instances.free_ids().len();
1118        if count == 0 {
1119            return None;
1120        }
1121
1122        match self.router_mode {
1123            RouterMode::RoundRobin => {
1124                let counter = self.round_robin_counter.fetch_add(1, Ordering::Relaxed) as usize;
1125                Some(routing_instances.free_ids()[counter % count])
1126            }
1127            RouterMode::Random => {
1128                let counter = rand::rng().random::<u64>() as usize;
1129                Some(routing_instances.free_ids()[counter % count])
1130            }
1131            RouterMode::PowerOfTwoChoices
1132            | RouterMode::Direct
1133            | RouterMode::LeastLoaded
1134            | RouterMode::DeviceAwareWeighted => None,
1135            RouterMode::KV => {
1136                panic!(
1137                    "select_next_worker should not be called for {:?} routing mode",
1138                    self.router_mode
1139                )
1140            }
1141        }
1142    }
1143
1144    /// Peek the next worker according to the routing mode without incrementing the counter.
1145    /// Useful for checking if a worker is suitable before committing to it.
1146    ///
1147    /// `None` for [`RouterMode::Direct`] (caller-supplied routing); panics for
1148    /// [`RouterMode::KV`], which selects via `kv_chooser::find_best_match`.
1149    pub fn peek_next_worker(&self) -> Option<u64> {
1150        // Select among free (admission-eligible) workers — see select_next_worker
1151        // for the per-mode selection rationale.
1152        let instance_ids = self.client.routing_instances().free_ids().to_vec();
1153        let count = instance_ids.len();
1154        if count == 0 {
1155            return None;
1156        }
1157
1158        match self.router_mode {
1159            RouterMode::RoundRobin => {
1160                // Just peek at the current counter value without incrementing
1161                let counter = self.round_robin_counter.load(Ordering::Relaxed) as usize;
1162                Some(instance_ids[counter % count])
1163            }
1164            RouterMode::Random => {
1165                // For random, peeking implies a fresh random selection since it's stateless.
1166                // Note: The caller must realize that select_next_worker() will pick a DIFFERENT random worker.
1167                let counter = rand::rng().random::<u64>() as usize;
1168                Some(instance_ids[counter % count])
1169            }
1170            RouterMode::LeastLoaded => self.occupancy_state.as_deref()?.peek_min(&instance_ids),
1171            RouterMode::PowerOfTwoChoices => Some(p2c_select_from(
1172                self.occupancy_state.as_deref()?,
1173                &instance_ids,
1174            )),
1175            RouterMode::DeviceAwareWeighted => {
1176                let state = self.occupancy_state.as_deref()?;
1177                let device_type_map: HashMap<u64, Option<DeviceType>> = self
1178                    .client
1179                    .instances()
1180                    .iter()
1181                    .map(|instance| (instance.instance_id, instance.device_type.clone()))
1182                    .collect();
1183                let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1184                    .ok()
1185                    .and_then(|value| value.parse::<usize>().ok())
1186                    .filter(|value| *value >= 1)
1187                    .unwrap_or(8);
1188                let candidates = device_aware_candidate_group(
1189                    state,
1190                    &instance_ids,
1191                    &device_type_map,
1192                    cuda_to_cpu_ratio,
1193                );
1194                state.peek_min(&candidates)
1195            }
1196            RouterMode::Direct => None,
1197            RouterMode::KV => {
1198                panic!(
1199                    "peek_next_worker should not be called for {:?} routing mode",
1200                    self.router_mode
1201                )
1202            }
1203        }
1204    }
1205
1206    async fn select_exact_target(
1207        &self,
1208        request: &T,
1209        pinned_worker: Option<u64>,
1210    ) -> anyhow::Result<(u64, Option<OccupancyPermit>)> {
1211        if let Some(instance_id) = pinned_worker {
1212            let routing_instances = self.client.routing_instances();
1213            if !routing_instances.routable_ids().contains(&instance_id) {
1214                return Err(anyhow::anyhow!(
1215                    "instance_id={instance_id} not found for endpoint {}",
1216                    self.client.endpoint.id()
1217                ));
1218            }
1219            let permit = match self.router_mode {
1220                RouterMode::LeastLoaded
1221                | RouterMode::PowerOfTwoChoices
1222                | RouterMode::DeviceAwareWeighted => {
1223                    let state = self.occupancy_state()?;
1224                    state.increment(instance_id);
1225                    Some(OccupancyPermit::new(state, instance_id))
1226                }
1227                RouterMode::RoundRobin
1228                | RouterMode::Random
1229                | RouterMode::Direct
1230                | RouterMode::KV => None,
1231            };
1232            return Ok((instance_id, permit));
1233        }
1234
1235        match self.router_mode {
1236            RouterMode::LeastLoaded
1237            | RouterMode::PowerOfTwoChoices
1238            | RouterMode::DeviceAwareWeighted => {
1239                let state = self.occupancy_state()?;
1240                let routing_instances = self.client.routing_instances();
1241                let instance_ids = routing_instances.free_ids().to_vec();
1242                if instance_ids.is_empty() {
1243                    return Err(self.empty_free_pool_error(&routing_instances));
1244                }
1245
1246                let instance_id = match self.router_mode {
1247                    RouterMode::LeastLoaded => state
1248                        .select_exact_min_and_increment(&instance_ids)
1249                        .await
1250                        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?,
1251                    RouterMode::PowerOfTwoChoices => {
1252                        let instance_id = p2c_select_from(state.as_ref(), &instance_ids);
1253                        state.increment(instance_id);
1254                        instance_id
1255                    }
1256                    RouterMode::DeviceAwareWeighted => {
1257                        let selection =
1258                            self.device_aware_candidates(request, state.as_ref(), &instance_ids);
1259                        let instance_id = if selection.full_embedding_cache_hit {
1260                            state.select_exact_min(&selection.candidates).await
1261                        } else {
1262                            state
1263                                .select_exact_min_and_increment(&selection.candidates)
1264                                .await
1265                        }
1266                        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1267                        let permit = (!selection.full_embedding_cache_hit)
1268                            .then(|| OccupancyPermit::new(state, instance_id));
1269                        return Ok((instance_id, permit));
1270                    }
1271                    _ => unreachable!(),
1272                };
1273                Ok((instance_id, Some(OccupancyPermit::new(state, instance_id))))
1274            }
1275            RouterMode::RoundRobin | RouterMode::Random => self
1276                .select_next_worker()
1277                .map(|instance_id| (instance_id, None))
1278                .ok_or_else(|| {
1279                    let routing_instances = self.client.routing_instances();
1280                    self.empty_free_pool_error(&routing_instances)
1281                }),
1282            RouterMode::Direct => Err(anyhow::anyhow!(
1283                "Worker ID required for exact dispatch in Direct routing mode"
1284            )),
1285            RouterMode::KV => Err(anyhow::anyhow!(
1286                "select_and_dispatch_exact cannot select workers in KV routing mode"
1287            )),
1288        }
1289    }
1290
1291    fn occupancy_state(&self) -> anyhow::Result<Arc<RoutingOccupancyState>> {
1292        self.occupancy_state.clone().ok_or_else(|| {
1293            anyhow::anyhow!(
1294                "routing occupancy state not initialized for endpoint {}",
1295                self.client.endpoint.id()
1296            )
1297        })
1298    }
1299
1300    /*
1301    pub async fn r#static(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1302        let subject = self.client.endpoint.subject();
1303        tracing::debug!("static got subject: {subject}");
1304        let request = request.map(|req| AddressedRequest::new(req, subject));
1305        tracing::debug!("router generate");
1306        self.addressed.generate(request).await
1307    }
1308    */
1309
1310    async fn generate_with_fault_detection(
1311        &self,
1312        instance_id: u64,
1313        request: SingleIn<T>,
1314        fallback: TransportFallback<'_>,
1315    ) -> anyhow::Result<ManyOut<U>> {
1316        self.generate_with_fault_detection_prepared(instance_id, request, fallback, |_, _| Ok(()))
1317            .await
1318            .map(|(_, stream)| stream)
1319    }
1320
1321    async fn generate_with_fault_detection_prepared<M, F>(
1322        &self,
1323        instance_id: u64,
1324        mut request: SingleIn<T>,
1325        fallback: TransportFallback<'_>,
1326        prepare: F,
1327    ) -> anyhow::Result<(M, ManyOut<U>)>
1328    where
1329        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1330    {
1331        let route_start = Instant::now();
1332        let request_id = request.id().to_string();
1333        let route_span = if matches!(self.router_mode, RouterMode::KV) {
1334            tracing::Span::none()
1335        } else {
1336            tracing::info_span!(
1337                "router.route_request",
1338                request_id = %request_id,
1339                worker_id = instance_id,
1340                router_mode = ?self.router_mode,
1341            )
1342        };
1343
1344        let (instance_id, address, transport_kind, instance) =
1345            self.resolve_transport(instance_id, fallback)?;
1346        self.check_workers_available(instance_id, &request_id)?;
1347
1348        let metadata = prepare(&mut request, instance_id)?;
1349        let request = request.map(|req| AddressedRequest::with_instance(req, address, instance));
1350
1351        STAGE_DURATION_SECONDS
1352            .with_label_values(&[STAGE_ROUTE])
1353            .observe(route_start.elapsed().as_secs_f64());
1354
1355        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
1356        let stream = self
1357            .addressed
1358            .generate(request)
1359            .instrument(route_span)
1360            .await;
1361        let stream = self.wrap_with_fault_detection(stream, instance_id)?;
1362        Ok((metadata, stream))
1363    }
1364
1365    /// Reject early if the selected worker is overloaded and fault detection
1366    /// is enabled. The request_id is only used for the debug-level "checked
1367    /// worker overload state" trace; pass an empty string from callers that
1368    /// don't have one handy.
1369    fn check_workers_available(&self, instance_id: u64, request_id: &str) -> anyhow::Result<()> {
1370        if !self.fault_detection_enabled {
1371            return Ok(());
1372        }
1373        let routing_instances = self.client.routing_instances();
1374        let selected_worker_overloaded = routing_instances.is_overloaded(instance_id);
1375        let counts = routing_instances.counts();
1376        if tracing::enabled!(tracing::Level::DEBUG) {
1377            tracing::debug!(
1378                request_id,
1379                instance_id,
1380                router_mode = ?self.router_mode,
1381                free_workers = counts.free,
1382                overloaded_workers = counts.overloaded,
1383                total_workers = counts.discovered,
1384                selected_worker_overloaded,
1385                "checked worker overload state"
1386            );
1387        }
1388        if !selected_worker_overloaded {
1389            return Ok(());
1390        }
1391        tracing::warn!(
1392            instance_id,
1393            overloaded_workers = counts.overloaded,
1394            total_workers = counts.discovered,
1395            "Rejecting request: selected worker is overloaded"
1396        );
1397        let cause = PipelineError::ServiceOverloaded(
1398            "Selected worker is overloaded, please retry later".into(),
1399        );
1400        Err(DynamoError::builder()
1401            .error_type(ErrorType::ResourceExhausted)
1402            .message("Selected worker is overloaded, please retry later")
1403            .cause(cause)
1404            .build()
1405            .into())
1406    }
1407
1408    /// Resolve `(instance_id, address, transport_kind_label, Instance)` for
1409    /// the selected worker. If that worker has disappeared, apply the caller's
1410    /// fallback policy. `CannotConnect` is returned when fallback is forbidden
1411    /// or when a selected fallback disappears before its transport can be
1412    /// resolved.
1413    fn resolve_transport(
1414        &self,
1415        instance_id: u64,
1416        fallback: TransportFallback<'_>,
1417    ) -> anyhow::Result<(u64, String, &'static str, Instance)> {
1418        use crate::component::TransportType;
1419
1420        let lookup = |id: u64| {
1421            self.client
1422                .instances()
1423                .iter()
1424                .find(|i| i.instance_id == id)
1425                .map(|instance| {
1426                    let (addr, kind) = match &instance.transport {
1427                        TransportType::Tcp(tcp_endpoint) => {
1428                            (tcp_endpoint.clone(), "transport.tcp.request")
1429                        }
1430                        TransportType::Nats(subject) => (subject.clone(), "transport.nats.request"),
1431                    };
1432                    (addr, kind, instance.clone())
1433                })
1434        };
1435
1436        if let Some((addr, kind, inst)) = lookup(instance_id) {
1437            return Ok((instance_id, addr, kind, inst));
1438        }
1439        let allowed_fallback = match fallback {
1440            TransportFallback::Allow => None,
1441            TransportFallback::Deny => {
1442                return Err(DynamoError::builder()
1443                    .error_type(ErrorType::CannotConnect)
1444                    .message(format!(
1445                        "instance_id={instance_id} not found for endpoint {}",
1446                        self.client.endpoint.id()
1447                    ))
1448                    .build()
1449                    .into());
1450            }
1451            TransportFallback::Within(allowed) => Some(allowed),
1452        };
1453
1454        let routing_instances = self.client.routing_instances();
1455        let fallback_id = routing_instances.free_ids().iter().copied().find(|&id| {
1456            id != instance_id && allowed_fallback.is_none_or(|allowed| allowed.contains(&id))
1457        });
1458        match fallback_id {
1459            Some(id) => {
1460                tracing::warn!(
1461                    original_instance = instance_id,
1462                    fallback_instance = id,
1463                    "Instance disappeared during routing, reselecting"
1464                );
1465                let (addr, kind, inst) = lookup(id).ok_or_else(|| {
1466                    DynamoError::builder()
1467                        .error_type(ErrorType::CannotConnect)
1468                        .message(format!(
1469                            "Fallback instance {} also not found for endpoint {}",
1470                            id,
1471                            self.client.endpoint.id()
1472                        ))
1473                        .build()
1474                })?;
1475                Ok((id, addr, kind, inst))
1476            }
1477            // TODO(https://github.com/ai-dynamo/dynamo/issues/12383): Distinguish
1478            // no discoverable fallback from pool-wide overload and return the
1479            // appropriate typed error for each case.
1480            None => Err(anyhow::anyhow!(
1481                "Instance {} not found and no other instances available for endpoint {}",
1482                instance_id,
1483                self.client.endpoint.id()
1484            )),
1485        }
1486    }
1487
1488    /// Wrap a dispatched stream with fault detection + inactivity timeout.
1489    /// `is_inhibited` errors trigger `report_instance_down`; the timeout
1490    /// (driven by `DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS`) yields a synthetic
1491    /// `ResponseTimeout` and quarantines the worker.
1492    fn wrap_with_fault_detection(
1493        &self,
1494        stream: anyhow::Result<ManyOut<U>>,
1495        instance_id: u64,
1496    ) -> anyhow::Result<ManyOut<U>> {
1497        let stream = match stream {
1498            Ok(stream) => stream,
1499            Err(err) => {
1500                if self.fault_detection_enabled {
1501                    if is_inhibited(err.as_ref()) {
1502                        tracing::debug!(
1503                            "Reporting instance {instance_id} down due to error: {err}"
1504                        );
1505                        self.client.report_instance_down(instance_id);
1506                    } else if match_error_chain(err.as_ref(), &[ErrorType::ResourceExhausted], &[])
1507                    {
1508                        // Backpressure: worker said "my queue is full,
1509                        // retry later". Mark overloaded so this FE skips it on
1510                        // the next selection; the next ActiveLoad event from the
1511                        // worker monitor overwrites the overloaded set from fresh
1512                        // metrics. This is NOT report_instance_down (fault path).
1513                        tracing::debug!(
1514                            "Marking instance {instance_id} overloaded due to backpressure: {err}"
1515                        );
1516                        self.client.mark_overloaded_immediate(instance_id);
1517                    }
1518                }
1519                return Err(err);
1520            }
1521        };
1522
1523        if !self.fault_detection_enabled {
1524            return Ok(stream);
1525        }
1526
1527        let engine_ctx = stream.context();
1528        let client = self.client.clone();
1529        let client_for_timeout = self.client.clone();
1530        let stream = stream.map(move |res| {
1531            if let Some(err) = res.err()
1532                && is_inhibited(&err)
1533            {
1534                tracing::debug!(
1535                    "Reporting instance {instance_id} down due to migratable error: {err}"
1536                );
1537                client.report_instance_down(instance_id);
1538            }
1539            res
1540        });
1541
1542        let stream: Pin<Box<dyn Stream<Item = U> + Send>> =
1543            if let Some(timeout) = self.response_timeout {
1544                Box::pin(async_stream::stream! {
1545                    let mut inner = Box::pin(stream);
1546                    loop {
1547                        tokio::select! {
1548                            biased;
1549                            item = inner.next() => {
1550                                match item {
1551                                    Some(item) => yield item,
1552                                    None => break,
1553                                }
1554                            }
1555                            _ = tokio::time::sleep(timeout) => {
1556                                tracing::warn!(
1557                                    instance_id,
1558                                    timeout_secs = timeout.as_secs(),
1559                                    "backend response inactivity timeout — quarantining worker"
1560                                );
1561                                client_for_timeout.report_instance_down(instance_id);
1562                                yield U::from_err(
1563                                    crate::error::DynamoError::builder()
1564                                        .error_type(crate::error::ErrorType::ResponseTimeout)
1565                                        .message("backend response inactivity timeout")
1566                                        .build()
1567                                );
1568                                break;
1569                            }
1570                        }
1571                    }
1572                })
1573            } else {
1574                Box::pin(stream)
1575            };
1576
1577        Ok(ResponseStream::new(stream, engine_ctx))
1578    }
1579}
1580
1581#[async_trait]
1582impl<T, U> AsyncEngine<SingleIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
1583where
1584    T: Data + Serialize,
1585    U: Data + for<'de> Deserialize<'de> + MaybeError,
1586{
1587    async fn generate(&self, request: SingleIn<T>) -> Result<ManyOut<U>, Error> {
1588        match self.router_mode {
1589            RouterMode::Random => self.random(request).await,
1590            RouterMode::RoundRobin => self.round_robin(request).await,
1591            RouterMode::PowerOfTwoChoices => self.power_of_two_choices(request).await,
1592            RouterMode::KV => {
1593                anyhow::bail!("KV routing should not call generate on PushRouter");
1594            }
1595            RouterMode::Direct => {
1596                anyhow::bail!(
1597                    "Direct routing should not call generate on PushRouter directly; use DirectRoutingRouter wrapper"
1598                );
1599            }
1600            RouterMode::LeastLoaded => self.least_loaded(request).await,
1601            RouterMode::DeviceAwareWeighted => self.device_aware_weighted(request).await,
1602        }
1603    }
1604}
1605
1606impl<T, U> PushRouter<T, U>
1607where
1608    T: Data + Serialize,
1609    U: Data + for<'de> Deserialize<'de> + MaybeError,
1610{
1611    /// Bidirectional sibling of [`Self::generate_with_fault_detection`].
1612    async fn bidirectional_dispatch(
1613        &self,
1614        instance_id: u64,
1615        input: ManyIn<T>,
1616    ) -> anyhow::Result<ManyOut<U>> {
1617        let route_start = Instant::now();
1618        let request_id = input.context().id().to_string();
1619        let route_span = tracing::info_span!(
1620            "router.route_request_bidirectional",
1621            request_id = %request_id,
1622            worker_id = instance_id,
1623            router_mode = ?self.router_mode,
1624        );
1625
1626        let (instance_id, address, transport_kind, instance) =
1627            self.resolve_transport(instance_id, TransportFallback::Allow)?;
1628        self.check_workers_available(instance_id, &request_id)?;
1629
1630        STAGE_DURATION_SECONDS
1631            .with_label_values(&[STAGE_ROUTE])
1632            .observe(route_start.elapsed().as_secs_f64());
1633
1634        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
1635        let stream: anyhow::Result<ManyOut<U>> = self
1636            .addressed
1637            .generate_bidirectional(instance, address, input)
1638            .instrument(route_span)
1639            .await;
1640        self.wrap_with_fault_detection(stream, instance_id)
1641    }
1642}
1643
1644/// Bidirectional `AsyncEngine` impl for streaming-input workloads (e.g. the
1645/// OpenAI Realtime API). Reserves a sticky worker up front — before any
1646/// inbound frame is observed — and binds the whole input stream to that
1647/// worker. KV and Direct modes inherit the same `bail!` invariants as the
1648/// unary impl.
1649///
1650/// **Reserve-before-observe rationale.** The router-mode strategies
1651/// (`RoundRobin`, `Random`, `PowerOfTwoChoices`, `LeastLoaded`,
1652/// `DeviceAwareWeighted`) don't depend on frame contents, so selection
1653/// runs immediately and connection setup proceeds in parallel with the
1654/// client producing its first frame. A client that connects but never
1655/// sends one still releases the slot via the response-stream-drop path;
1656/// the dispatch-side `cancel_both` cleanup covers the early-bail case.
1657#[async_trait]
1658impl<T, U> AsyncEngine<ManyIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
1659where
1660    T: Data + Serialize,
1661    U: Data + for<'de> Deserialize<'de> + MaybeError,
1662{
1663    async fn generate(&self, input: ManyIn<T>) -> Result<ManyOut<U>, Error> {
1664        match self.router_mode {
1665            RouterMode::KV => {
1666                anyhow::bail!("KV routing should not call generate on PushRouter");
1667            }
1668            RouterMode::Direct => {
1669                anyhow::bail!(
1670                    "Direct routing should not call generate on PushRouter directly; use DirectRoutingRouter wrapper"
1671                );
1672            }
1673            // These modes drive `select_next_worker()` to `None` — they rely on
1674            // the occupancy/load-aware selection the bidirectional path does not
1675            // wire yet, which would otherwise surface as a misleading "no
1676            // instances available" error below. Reject them explicitly until
1677            // bidirectional support lands; tracked in
1678            // https://github.com/ai-dynamo/dynamo/issues/10320.
1679            RouterMode::PowerOfTwoChoices
1680            | RouterMode::LeastLoaded
1681            | RouterMode::DeviceAwareWeighted => {
1682                anyhow::bail!(
1683                    "{:?} routing is not yet supported for bidirectional dispatch",
1684                    self.router_mode
1685                );
1686            }
1687            RouterMode::RoundRobin | RouterMode::Random => {}
1688        }
1689
1690        let instance_id = self
1691            .select_next_worker()
1692            .ok_or_else(|| anyhow::anyhow!("no instances available for bidirectional routing"))?;
1693
1694        self.bidirectional_dispatch(instance_id, input).await
1695    }
1696}
1697
1698struct OccupancyTrackedStream<U: Data> {
1699    inner: ManyOut<U>,
1700    state: Arc<RoutingOccupancyState>,
1701    instance_id: u64,
1702    released: bool,
1703}
1704
1705impl<U: Data> Drop for OccupancyTrackedStream<U> {
1706    fn drop(&mut self) {
1707        if !self.released {
1708            self.state.decrement(self.instance_id);
1709        }
1710    }
1711}
1712
1713impl<U: Data> std::fmt::Debug for OccupancyTrackedStream<U> {
1714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1715        f.debug_struct("OccupancyTrackedStream")
1716            .field("instance_id", &self.instance_id)
1717            .finish()
1718    }
1719}
1720
1721impl<U: Data> Stream for OccupancyTrackedStream<U> {
1722    type Item = U;
1723
1724    fn poll_next(
1725        mut self: Pin<&mut Self>,
1726        cx: &mut std::task::Context<'_>,
1727    ) -> Poll<Option<Self::Item>> {
1728        let poll = self.inner.as_mut().poll_next(cx);
1729        if matches!(poll, Poll::Ready(None)) && !self.released {
1730            self.state.decrement(self.instance_id);
1731            self.released = true;
1732        }
1733        poll
1734    }
1735}
1736
1737impl<U: Data> AsyncEngineContextProvider for OccupancyTrackedStream<U> {
1738    fn context(&self) -> Arc<dyn AsyncEngineContext> {
1739        self.inner.context()
1740    }
1741}
1742
1743impl<U: Data> crate::engine::AsyncEngineStream<U> for OccupancyTrackedStream<U> {}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748    use crate::{
1749        DistributedRuntime, Runtime,
1750        distributed::DistributedConfig,
1751        error::DynamoError,
1752        pipeline::{
1753            RequestStream, ResponseStream,
1754            context::{Context, Controller},
1755        },
1756    };
1757    use serde::{Deserialize, Serialize};
1758
1759    #[derive(Clone, Debug, Deserialize, Serialize)]
1760    struct TestResponse {
1761        error: Option<DynamoError>,
1762    }
1763
1764    impl MaybeError for TestResponse {
1765        fn from_err(err: impl std::error::Error + 'static) -> Self {
1766            Self {
1767                error: Some(DynamoError::from(
1768                    Box::new(err) as Box<dyn std::error::Error + 'static>
1769                )),
1770            }
1771        }
1772
1773        fn err(&self) -> Option<DynamoError> {
1774            self.error.clone()
1775        }
1776    }
1777
1778    fn assert_cannot_connect(error: &anyhow::Error) {
1779        assert!(
1780            match_error_chain(error.as_ref(), &[ErrorType::CannotConnect], &[]),
1781            "expected CannotConnect error, got: {error}"
1782        );
1783        assert!(
1784            !match_error_chain(error.as_ref(), &[ErrorType::ResourceExhausted], &[]),
1785            "CannotConnect failure must not be masked as ResourceExhausted: {error}"
1786        );
1787    }
1788
1789    fn assert_not_cannot_connect(error: &anyhow::Error) {
1790        assert!(
1791            !match_error_chain(error.as_ref(), &[ErrorType::CannotConnect], &[]),
1792            "fallback-enabled failure must preserve its existing error semantics: {error}"
1793        );
1794    }
1795
1796    struct StaticMultimodalCacheIndex {
1797        worker_id: u64,
1798    }
1799
1800    impl MultimodalCacheIndex for StaticMultimodalCacheIndex {
1801        fn workers_with_cache_key_hits(&self, cache_keys: &[String]) -> Vec<(u64, usize)> {
1802            vec![(self.worker_id, cache_keys.len())]
1803        }
1804
1805        fn remove_worker(&self, _worker_id: u64) {}
1806    }
1807
1808    #[test]
1809    fn p2c_selects_lower_load_worker() {
1810        let state = RoutingOccupancyState::default();
1811        for _ in 0..10 {
1812            state.increment(1);
1813        }
1814        state.increment(2);
1815
1816        // With only two workers, p2c_select_from must pick both and choose id=2 (lower load).
1817        let result = p2c_select_from(&state, &[1, 2]);
1818        assert_eq!(result, 2);
1819    }
1820
1821    #[test]
1822    fn p2c_selects_single_worker() {
1823        let state = RoutingOccupancyState::default();
1824        assert_eq!(p2c_select_from(&state, &[42]), 42);
1825    }
1826
1827    #[test]
1828    fn p2c_treats_missing_counts_as_zero() {
1829        let state = RoutingOccupancyState::default();
1830        for _ in 0..5 {
1831            state.increment(1);
1832        }
1833        // Worker 2 has no entry — should be treated as 0, so it wins.
1834        let result = p2c_select_from(&state, &[1, 2]);
1835        assert_eq!(result, 2);
1836    }
1837
1838    #[test]
1839    fn p2c_returns_valid_worker_on_tie() {
1840        let state = RoutingOccupancyState::default();
1841        for _ in 0..3 {
1842            state.increment(1);
1843            state.increment(2);
1844        }
1845
1846        for _ in 0..100 {
1847            let result = p2c_select_from(&state, &[1, 2]);
1848            assert!(result == 1 || result == 2);
1849        }
1850    }
1851
1852    #[test]
1853    fn occupancy_permit_decrements_before_stream_creation() {
1854        let state = Arc::new(RoutingOccupancyState::default());
1855        state.increment(42);
1856        let permit = OccupancyPermit::new(state.clone(), 42);
1857        assert_eq!(state.load(42), 1);
1858        drop(permit);
1859        assert_eq!(state.load(42), 0);
1860    }
1861
1862    #[test]
1863    fn occupancy_tracked_stream_decrements_on_drop() {
1864        let state = Arc::new(RoutingOccupancyState::default());
1865        state.increment(7);
1866        let permit = OccupancyPermit::new(state.clone(), 7);
1867        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
1868        let stream = permit.into_tracked_stream(ResponseStream::new(
1869            Box::pin(tokio_stream::iter(vec![1u64])),
1870            ctx,
1871        ));
1872        assert_eq!(state.load(7), 1);
1873        drop(stream);
1874        assert_eq!(state.load(7), 0);
1875    }
1876
1877    #[tokio::test]
1878    async fn occupancy_tracked_stream_decrements_on_completion() {
1879        let state = Arc::new(RoutingOccupancyState::default());
1880        state.increment(7);
1881        let permit = OccupancyPermit::new(state.clone(), 7);
1882        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
1883        let mut stream = permit.into_tracked_stream(ResponseStream::new(
1884            Box::pin(tokio_stream::iter(vec![1u64])),
1885            ctx,
1886        ));
1887
1888        assert_eq!(stream.next().await, Some(1));
1889        assert_eq!(state.load(7), 1);
1890        assert_eq!(stream.next().await, None);
1891        assert_eq!(state.load(7), 0);
1892        drop(stream);
1893        assert_eq!(state.load(7), 0, "drop must not release twice after EOF");
1894    }
1895
1896    #[test]
1897    fn p2c_lifecycle_tracks_inflight_counts_with_shared_tracker() {
1898        let state = Arc::new(RoutingOccupancyState::default());
1899        let mut permits = Vec::new();
1900        for _ in 0..5 {
1901            let selected = p2c_select_from(&state, &[1, 2]);
1902            state.increment(selected);
1903            permits.push(OccupancyPermit::new(state.clone(), selected));
1904        }
1905
1906        let total = state.load(1) + state.load(2);
1907        assert_eq!(total, 5, "5 in-flight requests should be tracked");
1908
1909        drop(permits);
1910        let total = state.load(1) + state.load(2);
1911        assert_eq!(total, 0, "All guards dropped, counts should be 0");
1912    }
1913
1914    #[test]
1915    fn p2c_never_selects_dominated_worker() {
1916        let state = RoutingOccupancyState::default();
1917        for _ in 0..100 {
1918            state.increment(3);
1919        }
1920
1921        let mut selected = [0u32; 3];
1922        for _ in 0..1000 {
1923            let result = p2c_select_from(&state, &[1, 2, 3]);
1924            match result {
1925                1 => selected[0] += 1,
1926                2 => selected[1] += 1,
1927                3 => selected[2] += 1,
1928                _ => panic!("unexpected worker id"),
1929            }
1930        }
1931        assert_eq!(
1932            selected[2], 0,
1933            "Worker 3 (load=100) should never be selected against load=0 workers, but got {} times",
1934            selected[2]
1935        );
1936    }
1937
1938    #[tokio::test]
1939    async fn least_loaded_selects_exact_min_and_tracks_counts() {
1940        let state = Arc::new(RoutingOccupancyState::default());
1941        state.increment(1);
1942        state.increment(1);
1943        state.increment(2);
1944
1945        let selected = state
1946            .select_exact_min_and_increment(&[1, 2, 3])
1947            .await
1948            .unwrap();
1949        assert_eq!(selected, 3);
1950
1951        let permit = OccupancyPermit::new(state.clone(), selected);
1952        assert_eq!(state.load(selected), 1);
1953        drop(permit);
1954        assert_eq!(state.load(selected), 0);
1955    }
1956
1957    #[tokio::test]
1958    async fn bidirectional_generate_bails_with_no_instances() {
1959        let rt = Runtime::from_current().unwrap();
1960        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1961            .await
1962            .unwrap();
1963        let ns = drt.namespace("test_bidi_no_instances".to_string()).unwrap();
1964        let component = ns.component("test_component".to_string()).unwrap();
1965        let endpoint = component.endpoint("test_endpoint".to_string());
1966        let client = endpoint.client().await.unwrap();
1967
1968        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
1969            .await
1970            .unwrap();
1971
1972        let input: ManyIn<u64> =
1973            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![
1974                1u64, 2u64,
1975            ]))));
1976        let result = router.generate(input).await;
1977        assert!(
1978            result.is_err(),
1979            "bidirectional generate must bail when no instances are registered"
1980        );
1981
1982        rt.shutdown();
1983    }
1984
1985    #[tokio::test]
1986    async fn bidirectional_generate_bails_for_kv_router_mode() {
1987        let rt = Runtime::from_current().unwrap();
1988        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1989            .await
1990            .unwrap();
1991        let ns = drt.namespace("test_bidi_kv_mode".to_string()).unwrap();
1992        let component = ns.component("test_component".to_string()).unwrap();
1993        let endpoint = component.endpoint("test_endpoint".to_string());
1994        let client = endpoint.client().await.unwrap();
1995
1996        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::KV)
1997            .await
1998            .unwrap();
1999
2000        let input: ManyIn<u64> =
2001            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2002        let result = router.generate(input).await;
2003        assert!(
2004            result.is_err(),
2005            "bidirectional generate must bail for RouterMode::KV"
2006        );
2007        let err_msg = format!("{:?}", result.unwrap_err());
2008        assert!(
2009            err_msg.contains("KV") || err_msg.contains("kv"),
2010            "error should mention KV: got {err_msg}"
2011        );
2012
2013        rt.shutdown();
2014    }
2015
2016    #[tokio::test]
2017    async fn bidirectional_generate_bails_for_direct_router_mode() {
2018        let rt = Runtime::from_current().unwrap();
2019        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2020            .await
2021            .unwrap();
2022        let ns = drt.namespace("test_bidi_direct_mode".to_string()).unwrap();
2023        let component = ns.component("test_component".to_string()).unwrap();
2024        let endpoint = component.endpoint("test_endpoint".to_string());
2025        let client = endpoint.client().await.unwrap();
2026
2027        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::Direct)
2028            .await
2029            .unwrap();
2030
2031        let input: ManyIn<u64> =
2032            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2033        let result = router.generate(input).await;
2034        assert!(
2035            result.is_err(),
2036            "bidirectional generate must bail for RouterMode::Direct"
2037        );
2038        let err_msg = format!("{:?}", result.unwrap_err());
2039        assert!(
2040            err_msg.contains("Direct") || err_msg.contains("direct"),
2041            "error should mention Direct: got {err_msg}"
2042        );
2043
2044        rt.shutdown();
2045    }
2046
2047    #[tokio::test]
2048    async fn bidirectional_generate_rejects_unsupported_load_aware_modes() {
2049        let rt = Runtime::from_current().unwrap();
2050        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2051            .await
2052            .unwrap();
2053        let ns = drt.namespace("test_bidi_load_aware".to_string()).unwrap();
2054        let component = ns.component("test_component".to_string()).unwrap();
2055
2056        for mode in [
2057            RouterMode::PowerOfTwoChoices,
2058            RouterMode::LeastLoaded,
2059            RouterMode::DeviceAwareWeighted,
2060        ] {
2061            let endpoint = component.endpoint("test_endpoint".to_string());
2062            let client = endpoint.client().await.unwrap();
2063            let router = PushRouter::<u64, TestResponse>::from_client(client, mode)
2064                .await
2065                .unwrap();
2066
2067            let input: ManyIn<u64> =
2068                Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2069            let result = router.generate(input).await;
2070            assert!(
2071                result.is_err(),
2072                "bidirectional generate must reject {mode:?} (not yet supported)"
2073            );
2074            let err_msg = format!("{:?}", result.unwrap_err());
2075            assert!(
2076                err_msg.contains("not yet supported for bidirectional dispatch"),
2077                "error should explain the mode is unsupported, not 'no instances': got {err_msg}"
2078            );
2079        }
2080
2081        rt.shutdown();
2082    }
2083
2084    #[tokio::test]
2085    async fn least_loaded_peek_returns_available_worker_select_stays_none() {
2086        let rt = Runtime::from_current().unwrap();
2087        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2088            .await
2089            .unwrap();
2090        let ns = drt
2091            .namespace("test_least_loaded_router".to_string())
2092            .unwrap();
2093        let component = ns.component("test_component".to_string()).unwrap();
2094        let endpoint = component.endpoint("test_endpoint".to_string());
2095        let client = endpoint.client().await.unwrap();
2096
2097        endpoint.register_endpoint_instance().await.unwrap();
2098        client.wait_for_instances().await.unwrap();
2099
2100        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2101            .await
2102            .unwrap();
2103
2104        // LeastLoaded selection tracks request occupancy, so the advisory API is
2105        // separate from select_next_worker().
2106        assert_eq!(router.select_next_worker(), None);
2107        assert!(
2108            router.peek_next_worker().is_some(),
2109            "LeastLoaded peek must return the available worker for disagg bootstrap"
2110        );
2111
2112        rt.shutdown();
2113    }
2114
2115    #[tokio::test]
2116    async fn exact_selection_releases_occupancy_when_preparation_fails() {
2117        let rt = Runtime::from_current().unwrap();
2118        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2119            .await
2120            .unwrap();
2121        let ns = drt
2122            .namespace("test_exact_prepare_failure".to_string())
2123            .unwrap();
2124        let component = ns.component("test_component".to_string()).unwrap();
2125        let endpoint = component.endpoint("test_endpoint".to_string());
2126        let client = endpoint.client().await.unwrap();
2127        endpoint.register_endpoint_instance().await.unwrap();
2128        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2129
2130        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2131            .await
2132            .unwrap();
2133        let state = router.occupancy_state.clone().unwrap();
2134        let result = router
2135            .select_and_dispatch_exact(SingleIn::new(42), None, |_, _| {
2136                Err::<(), _>(anyhow::anyhow!("metadata preparation failed"))
2137            })
2138            .await;
2139
2140        assert!(result.is_err());
2141        assert_eq!(
2142            state.load(worker_id),
2143            0,
2144            "preparation failure must release the selected worker"
2145        );
2146        rt.shutdown();
2147    }
2148
2149    #[tokio::test]
2150    async fn exact_dispatch_revalidates_overload_after_preparation() {
2151        let rt = Runtime::from_current().unwrap();
2152        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2153            .await
2154            .unwrap();
2155        let ns = drt
2156            .namespace("test_exact_overload_revalidation".to_string())
2157            .unwrap();
2158        let component = ns.component("test_component".to_string()).unwrap();
2159        let endpoint = component.endpoint("test_endpoint".to_string());
2160        let client = endpoint.client().await.unwrap();
2161        endpoint.register_endpoint_instance().await.unwrap();
2162        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2163
2164        let router =
2165            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::LeastLoaded)
2166                .await
2167                .unwrap();
2168        let state = router.occupancy_state.clone().unwrap();
2169        let result = router
2170            .select_and_dispatch_exact(SingleIn::new(42), Some(worker_id), |_, worker_id| {
2171                client.set_overloaded_instances(&[worker_id]);
2172                Ok(())
2173            })
2174            .await;
2175
2176        assert!(result.is_err());
2177        assert_eq!(
2178            state.load(worker_id),
2179            0,
2180            "validation failure must release the selected worker"
2181        );
2182        rt.shutdown();
2183    }
2184
2185    #[tokio::test]
2186    async fn transport_resolution_precedes_stale_overload_check() {
2187        let rt = Runtime::from_current().unwrap();
2188        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2189            .await
2190            .unwrap();
2191        let endpoint = drt
2192            .namespace("test_transport_precedes_stale_overload".to_string())
2193            .unwrap()
2194            .component("test_component".to_string())
2195            .unwrap()
2196            .endpoint("test_endpoint".to_string());
2197        let client = endpoint.client().await.unwrap();
2198        let stale_id = 99999;
2199        client.override_instance_avail(vec![stale_id]);
2200        client.set_overloaded_instances(&[stale_id]);
2201        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2202            .await
2203            .unwrap();
2204
2205        let unary_error = router
2206            .direct(SingleIn::new(42), stale_id)
2207            .await
2208            .unwrap_err();
2209        assert_cannot_connect(&unary_error);
2210
2211        let input: ManyIn<u64> =
2212            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2213        let bidirectional_error = router
2214            .bidirectional_dispatch(stale_id, input)
2215            .await
2216            .unwrap_err();
2217        assert_not_cannot_connect(&bidirectional_error);
2218        assert!(
2219            !match_error_chain(
2220                bidirectional_error.as_ref(),
2221                &[ErrorType::ResourceExhausted],
2222                &[]
2223            ),
2224            "transport resolution must precede the stale overload check: {bidirectional_error}"
2225        );
2226
2227        rt.shutdown();
2228    }
2229
2230    #[tokio::test]
2231    async fn selected_overloaded_worker_is_rejected_before_dispatch() {
2232        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
2233
2234        let rt = Runtime::from_current().unwrap();
2235        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2236            .await
2237            .unwrap();
2238        let ns = drt
2239            .namespace("test_selected_overloaded_worker_rejected".to_string())
2240            .unwrap();
2241        let component = ns.component("test_component".to_string()).unwrap();
2242        let endpoint = component.endpoint("test_endpoint".to_string());
2243        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2244            .await
2245            .unwrap();
2246
2247        endpoint.register_endpoint_instance().await.unwrap();
2248        let instances = client.wait_for_instances().await.unwrap();
2249        let worker_id = instances[0].id();
2250
2251        for _ in 0..10 {
2252            if client.instance_ids_avail().contains(&worker_id) {
2253                break;
2254            }
2255            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
2256        }
2257        assert!(
2258            client.instance_ids_avail().contains(&worker_id),
2259            "worker should be routable before marking it overloaded"
2260        );
2261
2262        client.set_overloaded_instances(&[worker_id]);
2263        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2264            .await
2265            .unwrap();
2266
2267        let result = router.generate(SingleIn::new(42u64)).await;
2268        assert!(result.is_err());
2269        let msg = format!("{}", result.unwrap_err());
2270        // With pre-selection filtering on free_ids, the single-overloaded-worker
2271        // case is now caught before selection rather than after — the chosen
2272        // worker is never overloaded because the candidate pool excludes it.
2273        // The post-selection check in route() remains as a race-condition
2274        // backstop.
2275        assert!(
2276            msg.contains("All workers are busy"),
2277            "expected empty-free-pool rejection, got: {msg}"
2278        );
2279
2280        rt.shutdown();
2281    }
2282
2283    #[tokio::test]
2284    async fn direct_within_rejects_overloaded_constrained_target() {
2285        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2286
2287        let rt = Runtime::from_current().unwrap();
2288        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2289            .await
2290            .unwrap();
2291        let ns = drt
2292            .namespace("test_direct_within_overload_rejection".to_string())
2293            .unwrap();
2294        let component = ns.component("test_component".to_string()).unwrap();
2295        let endpoint = component.endpoint("test_endpoint".to_string());
2296        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2297            .await
2298            .unwrap();
2299
2300        endpoint.register_endpoint_instance().await.unwrap();
2301        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2302        client.set_overloaded_instances(&[worker_id]);
2303
2304        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2305            .await
2306            .unwrap();
2307        let allowed = HashSet::from([worker_id]);
2308        let error = router
2309            .direct_within(SingleIn::new(42), worker_id, Some(&allowed))
2310            .await
2311            .unwrap_err();
2312
2313        assert!(match_error_chain(
2314            error.as_ref(),
2315            &[ErrorType::ResourceExhausted],
2316            &[]
2317        ));
2318        assert!(
2319            error.to_string().contains("Selected worker is overloaded"),
2320            "expected overload rejection, got: {error}"
2321        );
2322
2323        rt.shutdown();
2324    }
2325
2326    #[tokio::test]
2327    async fn no_workers_is_reported_as_unavailable() {
2328        let rt = Runtime::from_current().unwrap();
2329        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2330            .await
2331            .unwrap();
2332        let ns = drt
2333            .namespace("test_no_workers_unavailable".to_string())
2334            .unwrap();
2335        let component = ns.component("test_component".to_string()).unwrap();
2336        let endpoint = component.endpoint("test_endpoint".to_string());
2337        let client = endpoint.client().await.unwrap();
2338        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2339            .await
2340            .unwrap();
2341
2342        let error = router.generate(SingleIn::new(42)).await.unwrap_err();
2343        assert!(match_error_chain(
2344            error.as_ref(),
2345            &[ErrorType::Unavailable],
2346            &[]
2347        ));
2348
2349        rt.shutdown();
2350    }
2351
2352    #[tokio::test]
2353    async fn round_robin_excludes_overloaded_workers_from_candidates() {
2354        // Long reconcile interval so the synthetic override below survives
2355        // the test. We still register a real endpoint instance up front so
2356        // the initial reconcile (which fires immediately when the monitor
2357        // task spawns) settles on a non-empty source — without that, the
2358        // first reconcile would clobber the override before it takes effect.
2359        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2360
2361        let rt = Runtime::from_current().unwrap();
2362        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2363            .await
2364            .unwrap();
2365        let ns = drt
2366            .namespace("test_round_robin_excludes_overloaded".to_string())
2367            .unwrap();
2368        let component = ns.component("test_component".to_string()).unwrap();
2369        let endpoint = component.endpoint("test_endpoint".to_string());
2370        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2371            .await
2372            .unwrap();
2373
2374        endpoint.register_endpoint_instance().await.unwrap();
2375        let instances = client.wait_for_instances().await.unwrap();
2376        let real_id = instances[0].id();
2377        for _ in 0..50 {
2378            if client.instance_ids_avail().contains(&real_id) {
2379                break;
2380            }
2381            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2382        }
2383
2384        // Now override with two synthetic IDs and mark one overloaded.
2385        // round_robin must never select the overloaded one — that's the
2386        // whole point of selecting from free_ids instead of routable_ids.
2387        // The post-selection overload check in route() would otherwise return 529
2388        // one of N requests on each pass, which is the bug this PR closes
2389        // for non-KV selectors.
2390        client.override_instance_avail(vec![1, 2]);
2391        client.set_overloaded_instances(&[1]);
2392
2393        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2394            .await
2395            .unwrap();
2396
2397        // Round-robin over N requests should land on worker 2 every time.
2398        // We use peek_next_worker for a side-effect-free probe.
2399        for _ in 0..6 {
2400            let selected = router
2401                .peek_next_worker()
2402                .expect("peek should succeed with a free worker");
2403            assert_eq!(
2404                selected, 2,
2405                "overloaded worker 1 must not appear in the candidate set"
2406            );
2407        }
2408
2409        rt.shutdown();
2410    }
2411
2412    #[tokio::test]
2413    async fn device_aware_cpu_only_selects_least_loaded_instance() {
2414        let state = RoutingOccupancyState::default();
2415        // All candidates are CPU. Make worker 2 the least-loaded one.
2416        for _ in 0..3 {
2417            state.increment(1);
2418        }
2419        state.increment(3);
2420
2421        let instance_ids = vec![1, 2, 3];
2422        let device_type_map = HashMap::from([
2423            (1, Some(DeviceType::Cpu)),
2424            (2, Some(DeviceType::Cpu)),
2425            (3, Some(DeviceType::Cpu)),
2426        ]);
2427
2428        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 8);
2429        assert_eq!(candidates, vec![1, 2, 3]);
2430
2431        let selected = state
2432            .select_exact_min_and_increment(&candidates)
2433            .await
2434            .unwrap();
2435        assert_eq!(selected, 2);
2436    }
2437
2438    #[tokio::test]
2439    async fn device_aware_non_cpu_only_selects_least_loaded_instance() {
2440        let state = RoutingOccupancyState::default();
2441        // All candidates are non-CPU. Make worker 2 the least-loaded one.
2442        for _ in 0..3 {
2443            state.increment(1);
2444        }
2445        state.increment(3);
2446
2447        let instance_ids = vec![1, 2, 3];
2448        let device_type_map = HashMap::from([
2449            (1, Some(DeviceType::Cuda)),
2450            (2, Some(DeviceType::Cuda)),
2451            (3, Some(DeviceType::Cuda)),
2452        ]);
2453
2454        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 8);
2455        assert_eq!(candidates, vec![1, 2, 3]);
2456
2457        let selected = state
2458            .select_exact_min_and_increment(&candidates)
2459            .await
2460            .unwrap();
2461        assert_eq!(selected, 2);
2462    }
2463
2464    #[test]
2465    fn device_aware_group_uses_ratio_budget() {
2466        let state = RoutingOccupancyState::default();
2467        // CPU ids: 1,2 ; non-CPU ids: 3,4
2468        for _ in 0..4 {
2469            state.increment(3);
2470            state.increment(4);
2471        }
2472        // CPU inflight can differ across instances; budgeting uses total CPU inflight.
2473        for _ in 0..3 {
2474            state.increment(1);
2475        }
2476        // total_non_cpu_inflight=8, cpu_count=2, non_cpu_count=2, ratio=2
2477        // allowed_cpu_inflight = 8*2/(2*2)=4
2478        // total_cpu_inflight=3 < 4 => choose CPU group.
2479        let instance_ids = vec![1, 2, 3, 4];
2480        let device_type_map = HashMap::from([
2481            (1, Some(DeviceType::Cpu)),
2482            (2, Some(DeviceType::Cpu)),
2483            (3, Some(DeviceType::Cuda)),
2484            (4, Some(DeviceType::Cuda)),
2485        ]);
2486
2487        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 2);
2488        assert_eq!(candidates, vec![1, 2]);
2489
2490        // Within selected CPU group, final choice should be the least-loaded instance (id=2).
2491        let selected =
2492            futures::executor::block_on(state.select_exact_min_and_increment(&candidates)).unwrap();
2493        assert_eq!(selected, 2);
2494    }
2495
2496    #[tokio::test]
2497    async fn device_aware_weighted_peek_returns_available_worker_select_stays_none() {
2498        let rt = Runtime::from_current().unwrap();
2499        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2500            .await
2501            .unwrap();
2502        let ns = drt
2503            .namespace("test_device_aware_router".to_string())
2504            .unwrap();
2505        let component = ns.component("test_component".to_string()).unwrap();
2506        let endpoint = component.endpoint("test_endpoint".to_string());
2507        let client = endpoint.client().await.unwrap();
2508
2509        endpoint.register_endpoint_instance().await.unwrap();
2510        client.wait_for_instances().await.unwrap();
2511
2512        let router =
2513            PushRouter::<u64, TestResponse>::from_client(client, RouterMode::DeviceAwareWeighted)
2514                .await
2515                .unwrap();
2516
2517        // DeviceAwareWeighted degenerates to least-loaded for peek (device-class
2518        // partitioning happens at dispatch); select_next_worker stays None.
2519        assert_eq!(router.select_next_worker(), None);
2520        assert!(
2521            router.peek_next_worker().is_some(),
2522            "DeviceAwareWeighted peek must return the available worker for disagg bootstrap"
2523        );
2524
2525        rt.shutdown();
2526    }
2527
2528    #[tokio::test]
2529    async fn device_aware_exact_selection_preserves_full_multimodal_cache_hit() {
2530        let rt = Runtime::from_current().unwrap();
2531        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2532            .await
2533            .unwrap();
2534        let ns = drt
2535            .namespace("test_device_aware_affinity_cache".to_string())
2536            .unwrap();
2537        let component = ns.component("test_component".to_string()).unwrap();
2538        let endpoint = component.endpoint("test_endpoint".to_string());
2539        let client = endpoint.client().await.unwrap();
2540        endpoint.register_endpoint_instance().await.unwrap();
2541        let cache_worker = client.wait_for_instances().await.unwrap()[0].id();
2542
2543        let router = PushRouter::<u64, TestResponse>::from_client_with_state(
2544            client,
2545            RouterMode::DeviceAwareWeighted,
2546            None,
2547            Some(Arc::new(StaticMultimodalCacheIndex {
2548                worker_id: cache_worker,
2549            })),
2550            Some(Arc::new(|_| vec!["image-key".to_string()])),
2551        )
2552        .await
2553        .unwrap();
2554
2555        let (worker_id, permit) = router.select_exact_target(&42, None).await.unwrap();
2556        assert_eq!(worker_id, cache_worker);
2557        assert!(
2558            permit.is_none(),
2559            "full cache hits bypass occupancy charging"
2560        );
2561
2562        rt.shutdown();
2563    }
2564
2565    /// Direct dispatch honors an upstream-selected worker even after local inhibition.
2566    #[tokio::test]
2567    async fn direct_dispatch_ignores_local_inhibition() {
2568        let rt = Runtime::from_current().unwrap();
2569        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2570            .await
2571            .unwrap();
2572        let ns = drt
2573            .namespace("test_direct_bypasses_inhibition".to_string())
2574            .unwrap();
2575        let component = ns.component("test_component".to_string()).unwrap();
2576        let endpoint = component.endpoint("test_endpoint".to_string());
2577        let client = endpoint.client().await.unwrap();
2578        endpoint.register_endpoint_instance().await.unwrap();
2579        let instance_id = client.wait_for_instances().await.unwrap()[0].id();
2580
2581        // KV routing selects upstream and dispatches through PushRouter::direct.
2582        let router = PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::KV)
2583            .await
2584            .unwrap();
2585
2586        client.report_instance_down(instance_id);
2587        assert!(
2588            !client.instance_ids_avail().contains(&instance_id),
2589            "precondition: worker should be locally inhibited"
2590        );
2591
2592        let result = router
2593            .direct_within_prepared(
2594                SingleIn::new(42),
2595                instance_id,
2596                None,
2597                |_, selected_instance_id| {
2598                    assert_eq!(selected_instance_id, instance_id);
2599                    Err::<(), _>(anyhow::anyhow!("direct prepare sentinel"))
2600                },
2601            )
2602            .await;
2603        let error = match result {
2604            Ok(_) => panic!("direct dispatch should reach request preparation"),
2605            Err(error) => error,
2606        };
2607        assert_eq!(error.to_string(), "direct prepare sentinel");
2608
2609        let missing_instance_id = instance_id.wrapping_add(1);
2610        let result = router
2611            .direct_within_prepared(SingleIn::new(42), missing_instance_id, None, |_, _| {
2612                Ok::<(), anyhow::Error>(())
2613            })
2614            .await;
2615        let error = match result {
2616            Ok(_) => panic!("direct dispatch should reject a worker absent from discovery"),
2617            Err(error) => error,
2618        };
2619        assert!(
2620            error
2621                .to_string()
2622                .contains(&format!("instance_id={missing_instance_id} not found")),
2623            "unexpected missing-worker error: {error}"
2624        );
2625
2626        rt.shutdown();
2627    }
2628
2629    /// When the router selects an instance that has deregistered between selection
2630    /// and transport resolution, it should fall back to another available instance
2631    /// rather than returning a 500 error.
2632    #[tokio::test]
2633    async fn transport_resolution_falls_back_when_selected_instance_disappears() {
2634        let rt = Runtime::from_current().unwrap();
2635        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2636            .await
2637            .unwrap();
2638        let ns = drt
2639            .namespace("test_transport_fallback".to_string())
2640            .unwrap();
2641        let component = ns.component("test_component".to_string()).unwrap();
2642        let endpoint = component.endpoint("test_endpoint".to_string());
2643        let client = endpoint.client().await.unwrap();
2644
2645        // Register one real instance so it appears in instance_source.
2646        endpoint.register_endpoint_instance().await.unwrap();
2647        client.wait_for_instances().await.unwrap();
2648
2649        let real_id = client.instance_ids()[0];
2650
2651        // Inject a stale ID into instance_avail that does NOT exist in
2652        // instance_source. This simulates the race window where an instance
2653        // deregistered after selection but before transport resolution.
2654        let stale_id = real_id + 1000;
2655        client.override_instance_avail(vec![stale_id, real_id]);
2656
2657        let router =
2658            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2659                .await
2660                .unwrap();
2661
2662        // Exercise transport resolution directly. Sending a request to this
2663        // registration would wait forever because the test intentionally has
2664        // no worker handler.
2665        let (resolved_id, _, _, _) = router
2666            .resolve_transport(stale_id, TransportFallback::Allow)
2667            .expect("normal routing should fall back from a stale worker");
2668        assert_eq!(resolved_id, real_id);
2669
2670        rt.shutdown();
2671    }
2672
2673    #[tokio::test]
2674    async fn prepared_dispatch_observes_worker_after_transport_fallback() {
2675        let rt = Runtime::from_current().unwrap();
2676        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2677            .await
2678            .unwrap();
2679        let endpoint = drt
2680            .namespace("test_prepared_transport_fallback".to_string())
2681            .unwrap()
2682            .component("test_component".to_string())
2683            .unwrap()
2684            .endpoint("test_endpoint".to_string());
2685        let client = endpoint.client().await.unwrap();
2686        endpoint.register_endpoint_instance().await.unwrap();
2687        let real_id = client.wait_for_instances().await.unwrap()[0].id();
2688        let stale_id = real_id.wrapping_add(1);
2689        client.override_instance_avail(vec![stale_id, real_id]);
2690        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2691            .await
2692            .unwrap();
2693        let state = router.occupancy_state.clone().unwrap();
2694        state.increment(real_id);
2695        let state_for_prepare = state.clone();
2696        let observed = Arc::new(AtomicU64::new(0));
2697        let observed_for_prepare = observed.clone();
2698
2699        let _ = tokio::time::timeout(
2700            std::time::Duration::from_millis(100),
2701            router.select_and_dispatch(SingleIn::new(42), move |_, worker_id| {
2702                assert_eq!(state_for_prepare.load(stale_id), 0);
2703                assert_eq!(state_for_prepare.load(worker_id), 2);
2704                observed_for_prepare.store(worker_id, Ordering::Relaxed);
2705                Ok(())
2706            }),
2707        )
2708        .await;
2709
2710        assert_eq!(observed.load(Ordering::Relaxed), real_id);
2711        assert_eq!(state.load(real_id), 1);
2712        state.decrement(real_id);
2713        rt.shutdown();
2714    }
2715
2716    /// When no instances are available at all (both primary and fallback),
2717    /// the router should return a clear error.
2718    #[tokio::test]
2719    async fn transport_resolution_errors_when_no_instances_available() {
2720        let rt = Runtime::from_current().unwrap();
2721        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2722            .await
2723            .unwrap();
2724        let ns = drt
2725            .namespace("test_transport_no_fallback".to_string())
2726            .unwrap();
2727        let component = ns.component("test_component".to_string()).unwrap();
2728        let endpoint = component.endpoint("test_endpoint".to_string());
2729        let client = endpoint.client().await.unwrap();
2730
2731        // Register an instance so we can create the router (needs transport setup).
2732        endpoint.register_endpoint_instance().await.unwrap();
2733        client.wait_for_instances().await.unwrap();
2734
2735        let router =
2736            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2737                .await
2738                .unwrap();
2739
2740        // Override avail to contain only a stale ID with no real backing
2741        // instance AND no other available fallback.
2742        let stale_id = 99999;
2743        client.override_instance_avail(vec![stale_id]);
2744
2745        let request = SingleIn::new(42u64);
2746        let result = router.generate(request).await;
2747
2748        assert!(result.is_err());
2749        let error = result.unwrap_err();
2750        assert_not_cannot_connect(&error);
2751        let msg = error.to_string();
2752        assert!(
2753            msg.contains("not found") && msg.contains("no other instances available"),
2754            "Expected clear error about missing instance with no fallback, got: {msg}"
2755        );
2756
2757        rt.shutdown();
2758    }
2759
2760    #[tokio::test]
2761    async fn transport_resolution_honors_fallback_policy() {
2762        let rt = Runtime::from_current().unwrap();
2763        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2764            .await
2765            .unwrap();
2766        let ns = drt
2767            .namespace("test_exact_transport_no_fallback".to_string())
2768            .unwrap();
2769        let component = ns.component("test_component".to_string()).unwrap();
2770        let endpoint = component.endpoint("test_endpoint".to_string());
2771        let client = endpoint.client().await.unwrap();
2772        endpoint.register_endpoint_instance().await.unwrap();
2773        let instances = client.wait_for_instances().await.unwrap();
2774        let real_id = instances[0].id();
2775
2776        let router =
2777            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2778                .await
2779                .unwrap();
2780        let stale_id = real_id.wrapping_add(1);
2781        client.override_instance_avail(vec![stale_id, real_id]);
2782
2783        assert!(
2784            router
2785                .resolve_transport(stale_id, TransportFallback::Allow)
2786                .is_ok(),
2787            "normal dispatch should preserve transport fallback"
2788        );
2789        let allowed = HashSet::from([real_id]);
2790        assert!(
2791            router
2792                .resolve_transport(stale_id, TransportFallback::Within(&allowed))
2793                .is_ok(),
2794            "constrained dispatch should fall back within the allowed worker set"
2795        );
2796        let disallowed = HashSet::new();
2797        let disallowed_error = router
2798            .resolve_transport(stale_id, TransportFallback::Within(&disallowed))
2799            .unwrap_err();
2800        assert_not_cannot_connect(&disallowed_error);
2801
2802        let exact_error = router
2803            .resolve_transport(stale_id, TransportFallback::Deny)
2804            .unwrap_err();
2805        assert_cannot_connect(&exact_error);
2806
2807        let second_stale_id = stale_id.wrapping_add(1);
2808        client.override_instance_avail(vec![stale_id, second_stale_id]);
2809        let stale_fallback_error = router
2810            .resolve_transport(stale_id, TransportFallback::Allow)
2811            .unwrap_err();
2812        assert_cannot_connect(&stale_fallback_error);
2813        assert!(
2814            stale_fallback_error
2815                .to_string()
2816                .contains("Fallback instance"),
2817            "expected fallback lookup failure, got: {stale_fallback_error}"
2818        );
2819
2820        rt.shutdown();
2821    }
2822
2823    /// The watcher dedup guard must be released even if the spawned task panics.
2824    /// Without this, a panic anywhere in the watcher body would leave a stale
2825    /// `ENDPOINT_WATCHER_ACTIVE` entry, silently disabling orphaned-pending-
2826    /// request cancellation for that endpoint until process restart.
2827    ///
2828    /// We exercise the Drop-guard pattern directly against the same static
2829    /// rather than driving `spawn_instance_removal_watcher` end-to-end (which
2830    /// would require staging a panicking discovery stream). The test mirrors
2831    /// the production code's GuardRelease shape; if the production code stops
2832    /// using a Drop guard, the integration would regress and the existing
2833    /// orphan-cancellation tests would fail.
2834    #[tokio::test]
2835    async fn watcher_dedup_guard_released_on_panic() {
2836        let endpoint_id = EndpointId {
2837            namespace: "panic-test-ns".to_string(),
2838            component: "panic-test-comp".to_string(),
2839            name: "panic-test-endpoint".to_string(),
2840        };
2841
2842        // Mimic the production code's pre-spawn dedup insert.
2843        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
2844        map.insert(endpoint_id.clone(), ());
2845
2846        let endpoint_id_clone = endpoint_id.clone();
2847        let join = tokio::spawn(async move {
2848            // Same shape as in spawn_instance_removal_watcher.
2849            struct GuardRelease(EndpointId);
2850            impl Drop for GuardRelease {
2851                fn drop(&mut self) {
2852                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
2853                        map.remove(&self.0);
2854                    }
2855                }
2856            }
2857            let _release = GuardRelease(endpoint_id_clone);
2858            panic!("simulated watcher-task panic");
2859        });
2860
2861        let result = join.await;
2862        assert!(result.is_err() && result.unwrap_err().is_panic());
2863        assert!(
2864            !map.contains_key(&endpoint_id),
2865            "Drop guard must release the dedup entry even on panic"
2866        );
2867    }
2868
2869    /// Normal-exit path: the Drop guard releases the entry when the task
2870    /// finishes without panicking. This is the everyday case (cancel_token
2871    /// fires or discovery stream closes).
2872    #[tokio::test]
2873    async fn watcher_dedup_guard_released_on_normal_exit() {
2874        let endpoint_id = EndpointId {
2875            namespace: "normal-test-ns".to_string(),
2876            component: "normal-test-comp".to_string(),
2877            name: "normal-test-endpoint".to_string(),
2878        };
2879
2880        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
2881        map.insert(endpoint_id.clone(), ());
2882
2883        let endpoint_id_clone = endpoint_id.clone();
2884        tokio::spawn(async move {
2885            struct GuardRelease(EndpointId);
2886            impl Drop for GuardRelease {
2887                fn drop(&mut self) {
2888                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
2889                        map.remove(&self.0);
2890                    }
2891                }
2892            }
2893            let _release = GuardRelease(endpoint_id_clone);
2894            // task body returns normally
2895        })
2896        .await
2897        .unwrap();
2898
2899        assert!(!map.contains_key(&endpoint_id));
2900    }
2901}