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 a specific endpoint
780    pub async fn direct(
781        &self,
782        request: SingleIn<T>,
783        instance_id: u64,
784    ) -> anyhow::Result<ManyOut<U>> {
785        self.direct_within(request, instance_id, None).await
786    }
787
788    /// Like [`direct`], but if the selected instance disappears between selection and dispatch,
789    /// the internal reselection is constrained to `allowed_fallback` (when `Some`). Callers that
790    /// pre-narrowed the candidate set (e.g. LoRA replica-set filtering) pass that set so the
791    /// vanished-instance fallback cannot escape it and route to an arbitrary worker.
792    pub async fn direct_within(
793        &self,
794        request: SingleIn<T>,
795        instance_id: u64,
796        allowed_fallback: Option<&HashSet<u64>>,
797    ) -> anyhow::Result<ManyOut<U>> {
798        self.direct_within_prepared(request, instance_id, allowed_fallback, |_, _| Ok(()))
799            .await
800            .map(|(_, stream)| stream)
801    }
802
803    /// Like [`Self::direct_within`], but prepares the request after transport resolution and
804    /// returns the preparation metadata alongside the response stream.
805    pub async fn direct_within_prepared<M, F>(
806        &self,
807        request: SingleIn<T>,
808        instance_id: u64,
809        allowed_fallback: Option<&HashSet<u64>>,
810        prepare: F,
811    ) -> anyhow::Result<(M, ManyOut<U>)>
812    where
813        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
814    {
815        // When fault detection is disabled, check the raw discovery list
816        // (not filtered by report_instance_down) so transient failures
817        // don't poison the instance for subsequent retries.
818        let found = {
819            if self.fault_detection_enabled {
820                let routing_instances = self.client.routing_instances();
821                routing_instances.routable_ids().contains(&instance_id)
822            } else {
823                self.client.instance_ids().contains(&instance_id)
824            }
825        };
826
827        if !found {
828            return Err(DynamoError::builder()
829                .error_type(ErrorType::CannotConnect)
830                .message(format!(
831                    "instance_id={instance_id} not found for endpoint {}",
832                    self.client.endpoint.id()
833                ))
834                .build()
835                .into());
836        }
837
838        tracing::info!(
839            router_mode = "direct",
840            worker_id = instance_id,
841            "Selected worker"
842        );
843
844        let fallback = allowed_fallback
845            .map(TransportFallback::Within)
846            .unwrap_or(TransportFallback::Allow);
847        self.generate_with_fault_detection_prepared(instance_id, request, fallback, prepare)
848            .await
849    }
850
851    /// Dispatch to exactly one worker without transport fallback.
852    ///
853    /// The worker is revalidated against the latest discovery and overload
854    /// state immediately before dispatch.
855    pub async fn dispatch_exact(
856        &self,
857        request: SingleIn<T>,
858        instance_id: u64,
859    ) -> anyhow::Result<ManyOut<U>> {
860        self.generate_with_fault_detection(instance_id, request, TransportFallback::Deny)
861            .await
862    }
863
864    /// Select and book one worker, prepare the request for that exact worker,
865    /// then dispatch without reselection or transport fallback.
866    pub async fn select_and_dispatch_exact<M, F>(
867        &self,
868        mut request: SingleIn<T>,
869        pinned_worker: Option<u64>,
870        prepare: F,
871    ) -> anyhow::Result<(M, ManyOut<U>)>
872    where
873        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
874    {
875        let (instance_id, permit) = self
876            .select_exact_target(request.content(), pinned_worker)
877            .await?;
878        let metadata = prepare(&mut request, instance_id)?;
879        let stream = self.dispatch_exact(request, instance_id).await?;
880        let stream = match permit {
881            Some(permit) => permit.into_tracked_stream(stream),
882            None => stream,
883        };
884        Ok((metadata, stream))
885    }
886
887    /// Select a worker using the configured routing mode, prepare the request with the worker
888    /// that survives transport resolution, then dispatch with normal fallback behavior.
889    pub async fn select_and_dispatch<M, F>(
890        &self,
891        request: SingleIn<T>,
892        prepare: F,
893    ) -> anyhow::Result<(M, ManyOut<U>)>
894    where
895        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
896    {
897        match self.router_mode {
898            RouterMode::Random => self.random_prepared(request, prepare).await,
899            RouterMode::RoundRobin => self.round_robin_prepared(request, prepare).await,
900            RouterMode::PowerOfTwoChoices => {
901                self.power_of_two_choices_prepared(request, prepare).await
902            }
903            RouterMode::LeastLoaded => self.least_loaded_prepared(request, prepare).await,
904            RouterMode::DeviceAwareWeighted => {
905                self.device_aware_weighted_prepared(request, prepare).await
906            }
907            RouterMode::KV => anyhow::bail!("KV routing should not call select_and_dispatch"),
908            RouterMode::Direct => anyhow::bail!(
909                "Direct routing should use direct_within_prepared instead of select_and_dispatch"
910            ),
911        }
912    }
913
914    async fn dispatch_selected<M, F>(
915        &self,
916        instance_id: u64,
917        request: SingleIn<T>,
918        mut permit: Option<OccupancyPermit>,
919        prepare: F,
920    ) -> anyhow::Result<(M, ManyOut<U>)>
921    where
922        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
923    {
924        let (metadata, stream) = self
925            .generate_with_fault_detection_prepared(
926                instance_id,
927                request,
928                TransportFallback::Allow,
929                |request, resolved_instance_id| {
930                    if let Some(permit) = permit.as_mut() {
931                        permit.retarget(resolved_instance_id);
932                    }
933                    prepare(request, resolved_instance_id)
934                },
935            )
936            .await?;
937        let stream = match permit {
938            Some(permit) => permit.into_tracked_stream(stream),
939            None => stream,
940        };
941        Ok((metadata, stream))
942    }
943
944    /// Issue a request using device-aware weighted routing.
945    ///
946    /// Instances are partitioned by device type (CPU vs non-CPU), then the router
947    /// applies a budget policy and selects the least-loaded instance within the
948    /// chosen group.
949    ///
950    /// If only one device class exists (all CPU or all non-CPU), this naturally
951    /// degenerates to least-loaded routing over the available instances.
952    pub async fn device_aware_weighted(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
953        self.device_aware_weighted_prepared(request, |_, _| Ok(()))
954            .await
955            .map(|(_, stream)| stream)
956    }
957
958    async fn device_aware_weighted_prepared<M, F>(
959        &self,
960        request: SingleIn<T>,
961        prepare: F,
962    ) -> anyhow::Result<(M, ManyOut<U>)>
963    where
964        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
965    {
966        let state = self.occupancy_state()?;
967        let routing_instances = self.client.routing_instances();
968        let instance_ids = routing_instances.free_ids().to_vec();
969
970        if instance_ids.is_empty() {
971            return Err(self.empty_free_pool_error(&routing_instances));
972        }
973
974        // Apply a unified policy for all endpoints.
975        let endpoint_id = self.client.endpoint.id();
976
977        let selection =
978            self.device_aware_candidates(request.content(), state.as_ref(), &instance_ids);
979
980        // Only full cache hits bypass weighted accounting; partial hits still follow the
981        // device-aware ratio because some image encoding remains for this request.
982        let instance_id = if selection.full_embedding_cache_hit {
983            state.select_exact_min(&selection.candidates).await
984        } else {
985            state
986                .select_exact_min_and_increment(&selection.candidates)
987                .await
988        }
989        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
990        let permit = if selection.full_embedding_cache_hit {
991            None
992        } else {
993            Some(OccupancyPermit::new(state.clone(), instance_id))
994        };
995        let is_cpu = matches!(
996            selection.device_type_map.get(&instance_id),
997            Some(Some(DeviceType::Cpu))
998        );
999        tracing::info!(
1000            router_mode = "device-aware-weighted",
1001            worker_id = instance_id,
1002            candidate_count = selection.candidates.len(),
1003            load = state.load(instance_id),
1004            endpoint = %endpoint_id,
1005            is_cpu,
1006            embedding_cache_hit = selection.embedding_cache_hit,
1007            request_cache_keys = selection.request_cache_keys,
1008            "Selected worker"
1009        );
1010
1011        self.dispatch_selected(instance_id, request, permit, prepare)
1012            .await
1013    }
1014
1015    fn device_aware_candidates(
1016        &self,
1017        request: &T,
1018        state: &RoutingOccupancyState,
1019        instance_ids: &[u64],
1020    ) -> DeviceAwareCandidates {
1021        let device_type_map = self
1022            .client
1023            .instances()
1024            .iter()
1025            .map(|instance| (instance.instance_id, instance.device_type.clone()))
1026            .collect();
1027        let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1028            .ok()
1029            .and_then(|value| value.parse::<usize>().ok())
1030            .filter(|value| *value >= 1)
1031            .unwrap_or(8);
1032
1033        let (request_cache_keys, cache_matched_candidates) =
1034            if let (Some(indexer), Some(extractor)) = (
1035                self.multimodal_cache_indexer.as_ref(),
1036                self.multimodal_cache_key_extractor.as_ref(),
1037            ) {
1038                let request_cache_keys = extractor(request);
1039                let matched = if request_cache_keys.is_empty() {
1040                    Vec::new()
1041                } else {
1042                    let mut matched = indexer.workers_with_cache_key_hits(&request_cache_keys);
1043                    matched.retain(|(id, _)| instance_ids.contains(id));
1044                    matched
1045                };
1046                (request_cache_keys, matched)
1047            } else {
1048                (Vec::new(), Vec::new())
1049            };
1050
1051        let embedding_cache_hit = !cache_matched_candidates.is_empty();
1052        let request_cache_key_count = request_cache_keys
1053            .iter()
1054            .collect::<std::collections::HashSet<_>>()
1055            .len();
1056        let full_cache_candidates = cache_matched_candidates
1057            .iter()
1058            .filter_map(|(worker_id, hits)| {
1059                (*hits >= request_cache_key_count).then_some(*worker_id)
1060            })
1061            .collect::<Vec<_>>();
1062        let full_embedding_cache_hit = !full_cache_candidates.is_empty();
1063        let candidates = if full_embedding_cache_hit {
1064            full_cache_candidates
1065        } else {
1066            device_aware_candidate_group(state, instance_ids, &device_type_map, cuda_to_cpu_ratio)
1067        };
1068
1069        DeviceAwareCandidates {
1070            candidates,
1071            device_type_map,
1072            embedding_cache_hit,
1073            full_embedding_cache_hit,
1074            request_cache_keys: request_cache_keys.len(),
1075        }
1076    }
1077
1078    /// Issue a request to the instance with the fewest active connections.
1079    pub async fn least_loaded(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1080        self.least_loaded_prepared(request, |_, _| Ok(()))
1081            .await
1082            .map(|(_, stream)| stream)
1083    }
1084
1085    async fn least_loaded_prepared<M, F>(
1086        &self,
1087        request: SingleIn<T>,
1088        prepare: F,
1089    ) -> anyhow::Result<(M, ManyOut<U>)>
1090    where
1091        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1092    {
1093        let state = self.occupancy_state()?;
1094        let routing_instances = self.client.routing_instances();
1095        let instance_ids = routing_instances.free_ids().to_vec();
1096        let instance_id = state
1097            .select_exact_min_and_increment(&instance_ids)
1098            .await
1099            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1100        let permit = OccupancyPermit::new(state.clone(), instance_id);
1101        tracing::info!(
1102            router_mode = "least-loaded",
1103            worker_id = instance_id,
1104            candidate_count = instance_ids.len(),
1105            load = state.load(instance_id),
1106            "Selected worker"
1107        );
1108
1109        self.dispatch_selected(instance_id, request, Some(permit), prepare)
1110            .await
1111    }
1112
1113    /// Select the next worker according to the routing mode.
1114    /// Increments round-robin counter if applicable.
1115    /// Returns None for modes that require request lifecycle tracking or explicit routing hints.
1116    pub fn select_next_worker(&self) -> Option<u64> {
1117        let routing_instances = self.client.routing_instances();
1118        let count = routing_instances.free_ids().len();
1119        if count == 0 {
1120            return None;
1121        }
1122
1123        match self.router_mode {
1124            RouterMode::RoundRobin => {
1125                let counter = self.round_robin_counter.fetch_add(1, Ordering::Relaxed) as usize;
1126                Some(routing_instances.free_ids()[counter % count])
1127            }
1128            RouterMode::Random => {
1129                let counter = rand::rng().random::<u64>() as usize;
1130                Some(routing_instances.free_ids()[counter % count])
1131            }
1132            RouterMode::PowerOfTwoChoices
1133            | RouterMode::Direct
1134            | RouterMode::LeastLoaded
1135            | RouterMode::DeviceAwareWeighted => None,
1136            RouterMode::KV => {
1137                panic!(
1138                    "select_next_worker should not be called for {:?} routing mode",
1139                    self.router_mode
1140                )
1141            }
1142        }
1143    }
1144
1145    /// Peek the next worker according to the routing mode without incrementing the counter.
1146    /// Useful for checking if a worker is suitable before committing to it.
1147    ///
1148    /// `None` for [`RouterMode::Direct`] (caller-supplied routing); panics for
1149    /// [`RouterMode::KV`], which selects via `kv_chooser::find_best_match`.
1150    pub fn peek_next_worker(&self) -> Option<u64> {
1151        // Select among free (admission-eligible) workers — see select_next_worker
1152        // for the per-mode selection rationale.
1153        let instance_ids = self.client.routing_instances().free_ids().to_vec();
1154        let count = instance_ids.len();
1155        if count == 0 {
1156            return None;
1157        }
1158
1159        match self.router_mode {
1160            RouterMode::RoundRobin => {
1161                // Just peek at the current counter value without incrementing
1162                let counter = self.round_robin_counter.load(Ordering::Relaxed) as usize;
1163                Some(instance_ids[counter % count])
1164            }
1165            RouterMode::Random => {
1166                // For random, peeking implies a fresh random selection since it's stateless.
1167                // Note: The caller must realize that select_next_worker() will pick a DIFFERENT random worker.
1168                let counter = rand::rng().random::<u64>() as usize;
1169                Some(instance_ids[counter % count])
1170            }
1171            RouterMode::LeastLoaded => self.occupancy_state.as_deref()?.peek_min(&instance_ids),
1172            RouterMode::PowerOfTwoChoices => Some(p2c_select_from(
1173                self.occupancy_state.as_deref()?,
1174                &instance_ids,
1175            )),
1176            RouterMode::DeviceAwareWeighted => {
1177                let state = self.occupancy_state.as_deref()?;
1178                let device_type_map: HashMap<u64, Option<DeviceType>> = self
1179                    .client
1180                    .instances()
1181                    .iter()
1182                    .map(|instance| (instance.instance_id, instance.device_type.clone()))
1183                    .collect();
1184                let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1185                    .ok()
1186                    .and_then(|value| value.parse::<usize>().ok())
1187                    .filter(|value| *value >= 1)
1188                    .unwrap_or(8);
1189                let candidates = device_aware_candidate_group(
1190                    state,
1191                    &instance_ids,
1192                    &device_type_map,
1193                    cuda_to_cpu_ratio,
1194                );
1195                state.peek_min(&candidates)
1196            }
1197            RouterMode::Direct => None,
1198            RouterMode::KV => {
1199                panic!(
1200                    "peek_next_worker should not be called for {:?} routing mode",
1201                    self.router_mode
1202                )
1203            }
1204        }
1205    }
1206
1207    async fn select_exact_target(
1208        &self,
1209        request: &T,
1210        pinned_worker: Option<u64>,
1211    ) -> anyhow::Result<(u64, Option<OccupancyPermit>)> {
1212        if let Some(instance_id) = pinned_worker {
1213            let routing_instances = self.client.routing_instances();
1214            if !routing_instances.routable_ids().contains(&instance_id) {
1215                return Err(anyhow::anyhow!(
1216                    "instance_id={instance_id} not found for endpoint {}",
1217                    self.client.endpoint.id()
1218                ));
1219            }
1220            let permit = match self.router_mode {
1221                RouterMode::LeastLoaded
1222                | RouterMode::PowerOfTwoChoices
1223                | RouterMode::DeviceAwareWeighted => {
1224                    let state = self.occupancy_state()?;
1225                    state.increment(instance_id);
1226                    Some(OccupancyPermit::new(state, instance_id))
1227                }
1228                RouterMode::RoundRobin
1229                | RouterMode::Random
1230                | RouterMode::Direct
1231                | RouterMode::KV => None,
1232            };
1233            return Ok((instance_id, permit));
1234        }
1235
1236        match self.router_mode {
1237            RouterMode::LeastLoaded
1238            | RouterMode::PowerOfTwoChoices
1239            | RouterMode::DeviceAwareWeighted => {
1240                let state = self.occupancy_state()?;
1241                let routing_instances = self.client.routing_instances();
1242                let instance_ids = routing_instances.free_ids().to_vec();
1243                if instance_ids.is_empty() {
1244                    return Err(self.empty_free_pool_error(&routing_instances));
1245                }
1246
1247                let instance_id = match self.router_mode {
1248                    RouterMode::LeastLoaded => state
1249                        .select_exact_min_and_increment(&instance_ids)
1250                        .await
1251                        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?,
1252                    RouterMode::PowerOfTwoChoices => {
1253                        let instance_id = p2c_select_from(state.as_ref(), &instance_ids);
1254                        state.increment(instance_id);
1255                        instance_id
1256                    }
1257                    RouterMode::DeviceAwareWeighted => {
1258                        let selection =
1259                            self.device_aware_candidates(request, state.as_ref(), &instance_ids);
1260                        let instance_id = if selection.full_embedding_cache_hit {
1261                            state.select_exact_min(&selection.candidates).await
1262                        } else {
1263                            state
1264                                .select_exact_min_and_increment(&selection.candidates)
1265                                .await
1266                        }
1267                        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1268                        let permit = (!selection.full_embedding_cache_hit)
1269                            .then(|| OccupancyPermit::new(state, instance_id));
1270                        return Ok((instance_id, permit));
1271                    }
1272                    _ => unreachable!(),
1273                };
1274                Ok((instance_id, Some(OccupancyPermit::new(state, instance_id))))
1275            }
1276            RouterMode::RoundRobin | RouterMode::Random => self
1277                .select_next_worker()
1278                .map(|instance_id| (instance_id, None))
1279                .ok_or_else(|| {
1280                    let routing_instances = self.client.routing_instances();
1281                    self.empty_free_pool_error(&routing_instances)
1282                }),
1283            RouterMode::Direct => Err(anyhow::anyhow!(
1284                "Worker ID required for exact dispatch in Direct routing mode"
1285            )),
1286            RouterMode::KV => Err(anyhow::anyhow!(
1287                "select_and_dispatch_exact cannot select workers in KV routing mode"
1288            )),
1289        }
1290    }
1291
1292    fn occupancy_state(&self) -> anyhow::Result<Arc<RoutingOccupancyState>> {
1293        self.occupancy_state.clone().ok_or_else(|| {
1294            anyhow::anyhow!(
1295                "routing occupancy state not initialized for endpoint {}",
1296                self.client.endpoint.id()
1297            )
1298        })
1299    }
1300
1301    /*
1302    pub async fn r#static(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1303        let subject = self.client.endpoint.subject();
1304        tracing::debug!("static got subject: {subject}");
1305        let request = request.map(|req| AddressedRequest::new(req, subject));
1306        tracing::debug!("router generate");
1307        self.addressed.generate(request).await
1308    }
1309    */
1310
1311    async fn generate_with_fault_detection(
1312        &self,
1313        instance_id: u64,
1314        request: SingleIn<T>,
1315        fallback: TransportFallback<'_>,
1316    ) -> anyhow::Result<ManyOut<U>> {
1317        self.generate_with_fault_detection_prepared(instance_id, request, fallback, |_, _| Ok(()))
1318            .await
1319            .map(|(_, stream)| stream)
1320    }
1321
1322    async fn generate_with_fault_detection_prepared<M, F>(
1323        &self,
1324        instance_id: u64,
1325        mut request: SingleIn<T>,
1326        fallback: TransportFallback<'_>,
1327        prepare: F,
1328    ) -> anyhow::Result<(M, ManyOut<U>)>
1329    where
1330        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1331    {
1332        let route_start = Instant::now();
1333        let request_id = request.id().to_string();
1334        let route_span = if matches!(self.router_mode, RouterMode::KV) {
1335            tracing::Span::none()
1336        } else {
1337            tracing::info_span!(
1338                "router.route_request",
1339                request_id = %request_id,
1340                worker_id = instance_id,
1341                router_mode = ?self.router_mode,
1342            )
1343        };
1344
1345        self.check_workers_available(instance_id, &request_id)?;
1346
1347        let (instance_id, address, transport_kind, instance) =
1348            self.resolve_transport(instance_id, fallback)?;
1349        let metadata = prepare(&mut request, instance_id)?;
1350        let request = request.map(|req| AddressedRequest::with_instance(req, address, instance));
1351
1352        STAGE_DURATION_SECONDS
1353            .with_label_values(&[STAGE_ROUTE])
1354            .observe(route_start.elapsed().as_secs_f64());
1355
1356        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
1357        let stream = self
1358            .addressed
1359            .generate(request)
1360            .instrument(route_span)
1361            .await;
1362        let stream = self.wrap_with_fault_detection(stream, instance_id)?;
1363        Ok((metadata, stream))
1364    }
1365
1366    /// Reject early if the selected worker is overloaded and fault detection
1367    /// is enabled. The request_id is only used for the debug-level "checked
1368    /// worker overload state" trace; pass an empty string from callers that
1369    /// don't have one handy.
1370    fn check_workers_available(&self, instance_id: u64, request_id: &str) -> anyhow::Result<()> {
1371        if !self.fault_detection_enabled {
1372            return Ok(());
1373        }
1374        let routing_instances = self.client.routing_instances();
1375        let selected_worker_overloaded = routing_instances.is_overloaded(instance_id);
1376        let counts = routing_instances.counts();
1377        if tracing::enabled!(tracing::Level::DEBUG) {
1378            tracing::debug!(
1379                request_id,
1380                instance_id,
1381                router_mode = ?self.router_mode,
1382                free_workers = counts.free,
1383                overloaded_workers = counts.overloaded,
1384                total_workers = counts.discovered,
1385                selected_worker_overloaded,
1386                "checked worker overload state"
1387            );
1388        }
1389        if !selected_worker_overloaded {
1390            return Ok(());
1391        }
1392        tracing::warn!(
1393            instance_id,
1394            overloaded_workers = counts.overloaded,
1395            total_workers = counts.discovered,
1396            "Rejecting request: selected worker is overloaded"
1397        );
1398        let cause = PipelineError::ServiceOverloaded(
1399            "Selected worker is overloaded, please retry later".into(),
1400        );
1401        Err(DynamoError::builder()
1402            .error_type(ErrorType::ResourceExhausted)
1403            .message("Selected worker is overloaded, please retry later")
1404            .cause(cause)
1405            .build()
1406            .into())
1407    }
1408
1409    /// Resolve `(instance_id, address, transport_kind_label, Instance)` for
1410    /// the selected worker. If the instance has disappeared between selection
1411    /// and dispatch, fall back to one other instance from `free_ids` (same
1412    /// filter as pre-selection) and return the updated id so the caller can
1413    /// `report_instance_down` the right worker on later failures.
1414    fn resolve_transport(
1415        &self,
1416        instance_id: u64,
1417        fallback: TransportFallback<'_>,
1418    ) -> anyhow::Result<(u64, String, &'static str, Instance)> {
1419        use crate::component::TransportType;
1420
1421        let lookup = |id: u64| {
1422            self.client
1423                .instances()
1424                .iter()
1425                .find(|i| i.instance_id == id)
1426                .map(|instance| {
1427                    let (addr, kind) = match &instance.transport {
1428                        TransportType::Tcp(tcp_endpoint) => {
1429                            (tcp_endpoint.clone(), "transport.tcp.request")
1430                        }
1431                        TransportType::Nats(subject) => (subject.clone(), "transport.nats.request"),
1432                    };
1433                    (addr, kind, instance.clone())
1434                })
1435        };
1436
1437        if let Some((addr, kind, inst)) = lookup(instance_id) {
1438            return Ok((instance_id, addr, kind, inst));
1439        }
1440        let allowed_fallback = match fallback {
1441            TransportFallback::Allow => None,
1442            TransportFallback::Deny => {
1443                return Err(anyhow::anyhow!(
1444                    "Instance {} not found for endpoint {}",
1445                    instance_id,
1446                    self.client.endpoint.id()
1447                ));
1448            }
1449            TransportFallback::Within(allowed) => Some(allowed),
1450        };
1451
1452        let routing_instances = self.client.routing_instances();
1453        let fallback_id = routing_instances.free_ids().iter().copied().find(|&id| {
1454            id != instance_id && allowed_fallback.is_none_or(|allowed| allowed.contains(&id))
1455        });
1456        match fallback_id {
1457            Some(id) => {
1458                tracing::warn!(
1459                    original_instance = instance_id,
1460                    fallback_instance = id,
1461                    "Instance disappeared during routing, reselecting"
1462                );
1463                let (addr, kind, inst) = lookup(id).ok_or_else(|| {
1464                    anyhow::anyhow!(
1465                        "Fallback instance {} also not found for endpoint {}",
1466                        id,
1467                        self.client.endpoint.id()
1468                    )
1469                })?;
1470                Ok((id, addr, kind, inst))
1471            }
1472            None => Err(anyhow::anyhow!(
1473                "Instance {} not found and no other instances available for endpoint {}",
1474                instance_id,
1475                self.client.endpoint.id()
1476            )),
1477        }
1478    }
1479
1480    /// Wrap a dispatched stream with fault detection + inactivity timeout.
1481    /// `is_inhibited` errors trigger `report_instance_down`; the timeout
1482    /// (driven by `DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS`) yields a synthetic
1483    /// `ResponseTimeout` and quarantines the worker.
1484    fn wrap_with_fault_detection(
1485        &self,
1486        stream: anyhow::Result<ManyOut<U>>,
1487        instance_id: u64,
1488    ) -> anyhow::Result<ManyOut<U>> {
1489        let stream = match stream {
1490            Ok(stream) => stream,
1491            Err(err) => {
1492                if self.fault_detection_enabled {
1493                    if is_inhibited(err.as_ref()) {
1494                        tracing::debug!(
1495                            "Reporting instance {instance_id} down due to error: {err}"
1496                        );
1497                        self.client.report_instance_down(instance_id);
1498                    } else if match_error_chain(err.as_ref(), &[ErrorType::ResourceExhausted], &[])
1499                    {
1500                        // Backpressure: worker said "my queue is full,
1501                        // retry later". Mark overloaded so this FE skips it on
1502                        // the next selection; the next ActiveLoad event from the
1503                        // worker monitor overwrites the overloaded set from fresh
1504                        // metrics. This is NOT report_instance_down (fault path).
1505                        tracing::debug!(
1506                            "Marking instance {instance_id} overloaded due to backpressure: {err}"
1507                        );
1508                        self.client.mark_overloaded_immediate(instance_id);
1509                    }
1510                }
1511                return Err(err);
1512            }
1513        };
1514
1515        if !self.fault_detection_enabled {
1516            return Ok(stream);
1517        }
1518
1519        let engine_ctx = stream.context();
1520        let client = self.client.clone();
1521        let client_for_timeout = self.client.clone();
1522        let stream = stream.map(move |res| {
1523            if let Some(err) = res.err()
1524                && is_inhibited(&err)
1525            {
1526                tracing::debug!(
1527                    "Reporting instance {instance_id} down due to migratable error: {err}"
1528                );
1529                client.report_instance_down(instance_id);
1530            }
1531            res
1532        });
1533
1534        let stream: Pin<Box<dyn Stream<Item = U> + Send>> =
1535            if let Some(timeout) = self.response_timeout {
1536                Box::pin(async_stream::stream! {
1537                    let mut inner = Box::pin(stream);
1538                    loop {
1539                        tokio::select! {
1540                            biased;
1541                            item = inner.next() => {
1542                                match item {
1543                                    Some(item) => yield item,
1544                                    None => break,
1545                                }
1546                            }
1547                            _ = tokio::time::sleep(timeout) => {
1548                                tracing::warn!(
1549                                    instance_id,
1550                                    timeout_secs = timeout.as_secs(),
1551                                    "backend response inactivity timeout — quarantining worker"
1552                                );
1553                                client_for_timeout.report_instance_down(instance_id);
1554                                yield U::from_err(
1555                                    crate::error::DynamoError::builder()
1556                                        .error_type(crate::error::ErrorType::ResponseTimeout)
1557                                        .message("backend response inactivity timeout")
1558                                        .build()
1559                                );
1560                                break;
1561                            }
1562                        }
1563                    }
1564                })
1565            } else {
1566                Box::pin(stream)
1567            };
1568
1569        Ok(ResponseStream::new(stream, engine_ctx))
1570    }
1571}
1572
1573#[async_trait]
1574impl<T, U> AsyncEngine<SingleIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
1575where
1576    T: Data + Serialize,
1577    U: Data + for<'de> Deserialize<'de> + MaybeError,
1578{
1579    async fn generate(&self, request: SingleIn<T>) -> Result<ManyOut<U>, Error> {
1580        match self.router_mode {
1581            RouterMode::Random => self.random(request).await,
1582            RouterMode::RoundRobin => self.round_robin(request).await,
1583            RouterMode::PowerOfTwoChoices => self.power_of_two_choices(request).await,
1584            RouterMode::KV => {
1585                anyhow::bail!("KV routing should not call generate on PushRouter");
1586            }
1587            RouterMode::Direct => {
1588                anyhow::bail!(
1589                    "Direct routing should not call generate on PushRouter directly; use DirectRoutingRouter wrapper"
1590                );
1591            }
1592            RouterMode::LeastLoaded => self.least_loaded(request).await,
1593            RouterMode::DeviceAwareWeighted => self.device_aware_weighted(request).await,
1594        }
1595    }
1596}
1597
1598impl<T, U> PushRouter<T, U>
1599where
1600    T: Data + Serialize,
1601    U: Data + for<'de> Deserialize<'de> + MaybeError,
1602{
1603    /// Bidirectional sibling of [`Self::generate_with_fault_detection`].
1604    async fn bidirectional_dispatch(
1605        &self,
1606        instance_id: u64,
1607        input: ManyIn<T>,
1608    ) -> anyhow::Result<ManyOut<U>> {
1609        let route_start = Instant::now();
1610        let request_id = input.context().id().to_string();
1611        let route_span = tracing::info_span!(
1612            "router.route_request_bidirectional",
1613            request_id = %request_id,
1614            worker_id = instance_id,
1615            router_mode = ?self.router_mode,
1616        );
1617
1618        self.check_workers_available(instance_id, &request_id)?;
1619        let (instance_id, address, transport_kind, instance) =
1620            self.resolve_transport(instance_id, TransportFallback::Allow)?;
1621
1622        STAGE_DURATION_SECONDS
1623            .with_label_values(&[STAGE_ROUTE])
1624            .observe(route_start.elapsed().as_secs_f64());
1625
1626        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
1627        let stream: anyhow::Result<ManyOut<U>> = self
1628            .addressed
1629            .generate_bidirectional(instance, address, input)
1630            .instrument(route_span)
1631            .await;
1632        self.wrap_with_fault_detection(stream, instance_id)
1633    }
1634}
1635
1636/// Bidirectional `AsyncEngine` impl for streaming-input workloads (e.g. the
1637/// OpenAI Realtime API). Reserves a sticky worker up front — before any
1638/// inbound frame is observed — and binds the whole input stream to that
1639/// worker. KV and Direct modes inherit the same `bail!` invariants as the
1640/// unary impl.
1641///
1642/// **Reserve-before-observe rationale.** The router-mode strategies
1643/// (`RoundRobin`, `Random`, `PowerOfTwoChoices`, `LeastLoaded`,
1644/// `DeviceAwareWeighted`) don't depend on frame contents, so selection
1645/// runs immediately and connection setup proceeds in parallel with the
1646/// client producing its first frame. A client that connects but never
1647/// sends one still releases the slot via the response-stream-drop path;
1648/// the dispatch-side `cancel_both` cleanup covers the early-bail case.
1649#[async_trait]
1650impl<T, U> AsyncEngine<ManyIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
1651where
1652    T: Data + Serialize,
1653    U: Data + for<'de> Deserialize<'de> + MaybeError,
1654{
1655    async fn generate(&self, input: ManyIn<T>) -> Result<ManyOut<U>, Error> {
1656        match self.router_mode {
1657            RouterMode::KV => {
1658                anyhow::bail!("KV routing should not call generate on PushRouter");
1659            }
1660            RouterMode::Direct => {
1661                anyhow::bail!(
1662                    "Direct routing should not call generate on PushRouter directly; use DirectRoutingRouter wrapper"
1663                );
1664            }
1665            // These modes drive `select_next_worker()` to `None` — they rely on
1666            // the occupancy/load-aware selection the bidirectional path does not
1667            // wire yet, which would otherwise surface as a misleading "no
1668            // instances available" error below. Reject them explicitly until
1669            // bidirectional support lands; tracked in
1670            // https://github.com/ai-dynamo/dynamo/issues/10320.
1671            RouterMode::PowerOfTwoChoices
1672            | RouterMode::LeastLoaded
1673            | RouterMode::DeviceAwareWeighted => {
1674                anyhow::bail!(
1675                    "{:?} routing is not yet supported for bidirectional dispatch",
1676                    self.router_mode
1677                );
1678            }
1679            RouterMode::RoundRobin | RouterMode::Random => {}
1680        }
1681
1682        let instance_id = self
1683            .select_next_worker()
1684            .ok_or_else(|| anyhow::anyhow!("no instances available for bidirectional routing"))?;
1685
1686        self.bidirectional_dispatch(instance_id, input).await
1687    }
1688}
1689
1690struct OccupancyTrackedStream<U: Data> {
1691    inner: ManyOut<U>,
1692    state: Arc<RoutingOccupancyState>,
1693    instance_id: u64,
1694    released: bool,
1695}
1696
1697impl<U: Data> Drop for OccupancyTrackedStream<U> {
1698    fn drop(&mut self) {
1699        if !self.released {
1700            self.state.decrement(self.instance_id);
1701        }
1702    }
1703}
1704
1705impl<U: Data> std::fmt::Debug for OccupancyTrackedStream<U> {
1706    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1707        f.debug_struct("OccupancyTrackedStream")
1708            .field("instance_id", &self.instance_id)
1709            .finish()
1710    }
1711}
1712
1713impl<U: Data> Stream for OccupancyTrackedStream<U> {
1714    type Item = U;
1715
1716    fn poll_next(
1717        mut self: Pin<&mut Self>,
1718        cx: &mut std::task::Context<'_>,
1719    ) -> Poll<Option<Self::Item>> {
1720        let poll = self.inner.as_mut().poll_next(cx);
1721        if matches!(poll, Poll::Ready(None)) && !self.released {
1722            self.state.decrement(self.instance_id);
1723            self.released = true;
1724        }
1725        poll
1726    }
1727}
1728
1729impl<U: Data> AsyncEngineContextProvider for OccupancyTrackedStream<U> {
1730    fn context(&self) -> Arc<dyn AsyncEngineContext> {
1731        self.inner.context()
1732    }
1733}
1734
1735impl<U: Data> crate::engine::AsyncEngineStream<U> for OccupancyTrackedStream<U> {}
1736
1737#[cfg(test)]
1738mod tests {
1739    use super::*;
1740    use crate::{
1741        DistributedRuntime, Runtime,
1742        distributed::DistributedConfig,
1743        error::DynamoError,
1744        pipeline::{
1745            RequestStream, ResponseStream,
1746            context::{Context, Controller},
1747        },
1748    };
1749    use serde::{Deserialize, Serialize};
1750
1751    #[derive(Clone, Debug, Deserialize, Serialize)]
1752    struct TestResponse {
1753        error: Option<DynamoError>,
1754    }
1755
1756    impl MaybeError for TestResponse {
1757        fn from_err(err: impl std::error::Error + 'static) -> Self {
1758            Self {
1759                error: Some(DynamoError::from(
1760                    Box::new(err) as Box<dyn std::error::Error + 'static>
1761                )),
1762            }
1763        }
1764
1765        fn err(&self) -> Option<DynamoError> {
1766            self.error.clone()
1767        }
1768    }
1769
1770    struct StaticMultimodalCacheIndex {
1771        worker_id: u64,
1772    }
1773
1774    impl MultimodalCacheIndex for StaticMultimodalCacheIndex {
1775        fn workers_with_cache_key_hits(&self, cache_keys: &[String]) -> Vec<(u64, usize)> {
1776            vec![(self.worker_id, cache_keys.len())]
1777        }
1778
1779        fn remove_worker(&self, _worker_id: u64) {}
1780    }
1781
1782    #[test]
1783    fn p2c_selects_lower_load_worker() {
1784        let state = RoutingOccupancyState::default();
1785        for _ in 0..10 {
1786            state.increment(1);
1787        }
1788        state.increment(2);
1789
1790        // With only two workers, p2c_select_from must pick both and choose id=2 (lower load).
1791        let result = p2c_select_from(&state, &[1, 2]);
1792        assert_eq!(result, 2);
1793    }
1794
1795    #[test]
1796    fn p2c_selects_single_worker() {
1797        let state = RoutingOccupancyState::default();
1798        assert_eq!(p2c_select_from(&state, &[42]), 42);
1799    }
1800
1801    #[test]
1802    fn p2c_treats_missing_counts_as_zero() {
1803        let state = RoutingOccupancyState::default();
1804        for _ in 0..5 {
1805            state.increment(1);
1806        }
1807        // Worker 2 has no entry — should be treated as 0, so it wins.
1808        let result = p2c_select_from(&state, &[1, 2]);
1809        assert_eq!(result, 2);
1810    }
1811
1812    #[test]
1813    fn p2c_returns_valid_worker_on_tie() {
1814        let state = RoutingOccupancyState::default();
1815        for _ in 0..3 {
1816            state.increment(1);
1817            state.increment(2);
1818        }
1819
1820        for _ in 0..100 {
1821            let result = p2c_select_from(&state, &[1, 2]);
1822            assert!(result == 1 || result == 2);
1823        }
1824    }
1825
1826    #[test]
1827    fn occupancy_permit_decrements_before_stream_creation() {
1828        let state = Arc::new(RoutingOccupancyState::default());
1829        state.increment(42);
1830        let permit = OccupancyPermit::new(state.clone(), 42);
1831        assert_eq!(state.load(42), 1);
1832        drop(permit);
1833        assert_eq!(state.load(42), 0);
1834    }
1835
1836    #[test]
1837    fn occupancy_tracked_stream_decrements_on_drop() {
1838        let state = Arc::new(RoutingOccupancyState::default());
1839        state.increment(7);
1840        let permit = OccupancyPermit::new(state.clone(), 7);
1841        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
1842        let stream = permit.into_tracked_stream(ResponseStream::new(
1843            Box::pin(tokio_stream::iter(vec![1u64])),
1844            ctx,
1845        ));
1846        assert_eq!(state.load(7), 1);
1847        drop(stream);
1848        assert_eq!(state.load(7), 0);
1849    }
1850
1851    #[tokio::test]
1852    async fn occupancy_tracked_stream_decrements_on_completion() {
1853        let state = Arc::new(RoutingOccupancyState::default());
1854        state.increment(7);
1855        let permit = OccupancyPermit::new(state.clone(), 7);
1856        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
1857        let mut stream = permit.into_tracked_stream(ResponseStream::new(
1858            Box::pin(tokio_stream::iter(vec![1u64])),
1859            ctx,
1860        ));
1861
1862        assert_eq!(stream.next().await, Some(1));
1863        assert_eq!(state.load(7), 1);
1864        assert_eq!(stream.next().await, None);
1865        assert_eq!(state.load(7), 0);
1866        drop(stream);
1867        assert_eq!(state.load(7), 0, "drop must not release twice after EOF");
1868    }
1869
1870    #[test]
1871    fn p2c_lifecycle_tracks_inflight_counts_with_shared_tracker() {
1872        let state = Arc::new(RoutingOccupancyState::default());
1873        let mut permits = Vec::new();
1874        for _ in 0..5 {
1875            let selected = p2c_select_from(&state, &[1, 2]);
1876            state.increment(selected);
1877            permits.push(OccupancyPermit::new(state.clone(), selected));
1878        }
1879
1880        let total = state.load(1) + state.load(2);
1881        assert_eq!(total, 5, "5 in-flight requests should be tracked");
1882
1883        drop(permits);
1884        let total = state.load(1) + state.load(2);
1885        assert_eq!(total, 0, "All guards dropped, counts should be 0");
1886    }
1887
1888    #[test]
1889    fn p2c_never_selects_dominated_worker() {
1890        let state = RoutingOccupancyState::default();
1891        for _ in 0..100 {
1892            state.increment(3);
1893        }
1894
1895        let mut selected = [0u32; 3];
1896        for _ in 0..1000 {
1897            let result = p2c_select_from(&state, &[1, 2, 3]);
1898            match result {
1899                1 => selected[0] += 1,
1900                2 => selected[1] += 1,
1901                3 => selected[2] += 1,
1902                _ => panic!("unexpected worker id"),
1903            }
1904        }
1905        assert_eq!(
1906            selected[2], 0,
1907            "Worker 3 (load=100) should never be selected against load=0 workers, but got {} times",
1908            selected[2]
1909        );
1910    }
1911
1912    #[tokio::test]
1913    async fn least_loaded_selects_exact_min_and_tracks_counts() {
1914        let state = Arc::new(RoutingOccupancyState::default());
1915        state.increment(1);
1916        state.increment(1);
1917        state.increment(2);
1918
1919        let selected = state
1920            .select_exact_min_and_increment(&[1, 2, 3])
1921            .await
1922            .unwrap();
1923        assert_eq!(selected, 3);
1924
1925        let permit = OccupancyPermit::new(state.clone(), selected);
1926        assert_eq!(state.load(selected), 1);
1927        drop(permit);
1928        assert_eq!(state.load(selected), 0);
1929    }
1930
1931    #[tokio::test]
1932    async fn bidirectional_generate_bails_with_no_instances() {
1933        let rt = Runtime::from_current().unwrap();
1934        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1935            .await
1936            .unwrap();
1937        let ns = drt.namespace("test_bidi_no_instances".to_string()).unwrap();
1938        let component = ns.component("test_component".to_string()).unwrap();
1939        let endpoint = component.endpoint("test_endpoint".to_string());
1940        let client = endpoint.client().await.unwrap();
1941
1942        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
1943            .await
1944            .unwrap();
1945
1946        let input: ManyIn<u64> =
1947            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![
1948                1u64, 2u64,
1949            ]))));
1950        let result = router.generate(input).await;
1951        assert!(
1952            result.is_err(),
1953            "bidirectional generate must bail when no instances are registered"
1954        );
1955
1956        rt.shutdown();
1957    }
1958
1959    #[tokio::test]
1960    async fn bidirectional_generate_bails_for_kv_router_mode() {
1961        let rt = Runtime::from_current().unwrap();
1962        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1963            .await
1964            .unwrap();
1965        let ns = drt.namespace("test_bidi_kv_mode".to_string()).unwrap();
1966        let component = ns.component("test_component".to_string()).unwrap();
1967        let endpoint = component.endpoint("test_endpoint".to_string());
1968        let client = endpoint.client().await.unwrap();
1969
1970        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::KV)
1971            .await
1972            .unwrap();
1973
1974        let input: ManyIn<u64> =
1975            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
1976        let result = router.generate(input).await;
1977        assert!(
1978            result.is_err(),
1979            "bidirectional generate must bail for RouterMode::KV"
1980        );
1981        let err_msg = format!("{:?}", result.unwrap_err());
1982        assert!(
1983            err_msg.contains("KV") || err_msg.contains("kv"),
1984            "error should mention KV: got {err_msg}"
1985        );
1986
1987        rt.shutdown();
1988    }
1989
1990    #[tokio::test]
1991    async fn bidirectional_generate_bails_for_direct_router_mode() {
1992        let rt = Runtime::from_current().unwrap();
1993        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
1994            .await
1995            .unwrap();
1996        let ns = drt.namespace("test_bidi_direct_mode".to_string()).unwrap();
1997        let component = ns.component("test_component".to_string()).unwrap();
1998        let endpoint = component.endpoint("test_endpoint".to_string());
1999        let client = endpoint.client().await.unwrap();
2000
2001        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::Direct)
2002            .await
2003            .unwrap();
2004
2005        let input: ManyIn<u64> =
2006            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2007        let result = router.generate(input).await;
2008        assert!(
2009            result.is_err(),
2010            "bidirectional generate must bail for RouterMode::Direct"
2011        );
2012        let err_msg = format!("{:?}", result.unwrap_err());
2013        assert!(
2014            err_msg.contains("Direct") || err_msg.contains("direct"),
2015            "error should mention Direct: got {err_msg}"
2016        );
2017
2018        rt.shutdown();
2019    }
2020
2021    #[tokio::test]
2022    async fn bidirectional_generate_rejects_unsupported_load_aware_modes() {
2023        let rt = Runtime::from_current().unwrap();
2024        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2025            .await
2026            .unwrap();
2027        let ns = drt.namespace("test_bidi_load_aware".to_string()).unwrap();
2028        let component = ns.component("test_component".to_string()).unwrap();
2029
2030        for mode in [
2031            RouterMode::PowerOfTwoChoices,
2032            RouterMode::LeastLoaded,
2033            RouterMode::DeviceAwareWeighted,
2034        ] {
2035            let endpoint = component.endpoint("test_endpoint".to_string());
2036            let client = endpoint.client().await.unwrap();
2037            let router = PushRouter::<u64, TestResponse>::from_client(client, mode)
2038                .await
2039                .unwrap();
2040
2041            let input: ManyIn<u64> =
2042                Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2043            let result = router.generate(input).await;
2044            assert!(
2045                result.is_err(),
2046                "bidirectional generate must reject {mode:?} (not yet supported)"
2047            );
2048            let err_msg = format!("{:?}", result.unwrap_err());
2049            assert!(
2050                err_msg.contains("not yet supported for bidirectional dispatch"),
2051                "error should explain the mode is unsupported, not 'no instances': got {err_msg}"
2052            );
2053        }
2054
2055        rt.shutdown();
2056    }
2057
2058    #[tokio::test]
2059    async fn least_loaded_peek_returns_available_worker_select_stays_none() {
2060        let rt = Runtime::from_current().unwrap();
2061        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2062            .await
2063            .unwrap();
2064        let ns = drt
2065            .namespace("test_least_loaded_router".to_string())
2066            .unwrap();
2067        let component = ns.component("test_component".to_string()).unwrap();
2068        let endpoint = component.endpoint("test_endpoint".to_string());
2069        let client = endpoint.client().await.unwrap();
2070
2071        endpoint.register_endpoint_instance().await.unwrap();
2072        client.wait_for_instances().await.unwrap();
2073
2074        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2075            .await
2076            .unwrap();
2077
2078        // LeastLoaded selection tracks request occupancy, so the advisory API is
2079        // separate from select_next_worker().
2080        assert_eq!(router.select_next_worker(), None);
2081        assert!(
2082            router.peek_next_worker().is_some(),
2083            "LeastLoaded peek must return the available worker for disagg bootstrap"
2084        );
2085
2086        rt.shutdown();
2087    }
2088
2089    #[tokio::test]
2090    async fn exact_selection_releases_occupancy_when_preparation_fails() {
2091        let rt = Runtime::from_current().unwrap();
2092        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2093            .await
2094            .unwrap();
2095        let ns = drt
2096            .namespace("test_exact_prepare_failure".to_string())
2097            .unwrap();
2098        let component = ns.component("test_component".to_string()).unwrap();
2099        let endpoint = component.endpoint("test_endpoint".to_string());
2100        let client = endpoint.client().await.unwrap();
2101        endpoint.register_endpoint_instance().await.unwrap();
2102        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2103
2104        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2105            .await
2106            .unwrap();
2107        let state = router.occupancy_state.clone().unwrap();
2108        let result = router
2109            .select_and_dispatch_exact(SingleIn::new(42), None, |_, _| {
2110                Err::<(), _>(anyhow::anyhow!("metadata preparation failed"))
2111            })
2112            .await;
2113
2114        assert!(result.is_err());
2115        assert_eq!(
2116            state.load(worker_id),
2117            0,
2118            "preparation failure must release the selected worker"
2119        );
2120        rt.shutdown();
2121    }
2122
2123    #[tokio::test]
2124    async fn exact_dispatch_revalidates_overload_after_preparation() {
2125        let rt = Runtime::from_current().unwrap();
2126        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2127            .await
2128            .unwrap();
2129        let ns = drt
2130            .namespace("test_exact_overload_revalidation".to_string())
2131            .unwrap();
2132        let component = ns.component("test_component".to_string()).unwrap();
2133        let endpoint = component.endpoint("test_endpoint".to_string());
2134        let client = endpoint.client().await.unwrap();
2135        endpoint.register_endpoint_instance().await.unwrap();
2136        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2137
2138        let router =
2139            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::LeastLoaded)
2140                .await
2141                .unwrap();
2142        let state = router.occupancy_state.clone().unwrap();
2143        let result = router
2144            .select_and_dispatch_exact(SingleIn::new(42), Some(worker_id), |_, worker_id| {
2145                client.set_overloaded_instances(&[worker_id]);
2146                Ok(())
2147            })
2148            .await;
2149
2150        assert!(result.is_err());
2151        assert_eq!(
2152            state.load(worker_id),
2153            0,
2154            "validation failure must release the selected worker"
2155        );
2156        rt.shutdown();
2157    }
2158
2159    #[tokio::test]
2160    async fn selected_overloaded_worker_is_rejected_before_dispatch() {
2161        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
2162
2163        let rt = Runtime::from_current().unwrap();
2164        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2165            .await
2166            .unwrap();
2167        let ns = drt
2168            .namespace("test_selected_overloaded_worker_rejected".to_string())
2169            .unwrap();
2170        let component = ns.component("test_component".to_string()).unwrap();
2171        let endpoint = component.endpoint("test_endpoint".to_string());
2172        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2173            .await
2174            .unwrap();
2175
2176        endpoint.register_endpoint_instance().await.unwrap();
2177        let instances = client.wait_for_instances().await.unwrap();
2178        let worker_id = instances[0].id();
2179
2180        for _ in 0..10 {
2181            if client.instance_ids_avail().contains(&worker_id) {
2182                break;
2183            }
2184            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
2185        }
2186        assert!(
2187            client.instance_ids_avail().contains(&worker_id),
2188            "worker should be routable before marking it overloaded"
2189        );
2190
2191        client.set_overloaded_instances(&[worker_id]);
2192        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2193            .await
2194            .unwrap();
2195
2196        let result = router.generate(SingleIn::new(42u64)).await;
2197        assert!(result.is_err());
2198        let msg = format!("{}", result.unwrap_err());
2199        // With pre-selection filtering on free_ids, the single-overloaded-worker
2200        // case is now caught before selection rather than after — the chosen
2201        // worker is never overloaded because the candidate pool excludes it.
2202        // The post-selection check in route() remains as a race-condition
2203        // backstop.
2204        assert!(
2205            msg.contains("All workers are busy"),
2206            "expected empty-free-pool rejection, got: {msg}"
2207        );
2208
2209        rt.shutdown();
2210    }
2211
2212    #[tokio::test]
2213    async fn direct_within_rejects_overloaded_constrained_target() {
2214        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2215
2216        let rt = Runtime::from_current().unwrap();
2217        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2218            .await
2219            .unwrap();
2220        let ns = drt
2221            .namespace("test_direct_within_overload_rejection".to_string())
2222            .unwrap();
2223        let component = ns.component("test_component".to_string()).unwrap();
2224        let endpoint = component.endpoint("test_endpoint".to_string());
2225        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2226            .await
2227            .unwrap();
2228
2229        endpoint.register_endpoint_instance().await.unwrap();
2230        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2231        client.set_overloaded_instances(&[worker_id]);
2232
2233        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2234            .await
2235            .unwrap();
2236        let allowed = HashSet::from([worker_id]);
2237        let error = router
2238            .direct_within(SingleIn::new(42), worker_id, Some(&allowed))
2239            .await
2240            .unwrap_err();
2241
2242        assert!(match_error_chain(
2243            error.as_ref(),
2244            &[ErrorType::ResourceExhausted],
2245            &[]
2246        ));
2247        assert!(
2248            error.to_string().contains("Selected worker is overloaded"),
2249            "expected overload rejection, got: {error}"
2250        );
2251
2252        rt.shutdown();
2253    }
2254
2255    #[tokio::test]
2256    async fn no_workers_is_reported_as_unavailable() {
2257        let rt = Runtime::from_current().unwrap();
2258        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2259            .await
2260            .unwrap();
2261        let ns = drt
2262            .namespace("test_no_workers_unavailable".to_string())
2263            .unwrap();
2264        let component = ns.component("test_component".to_string()).unwrap();
2265        let endpoint = component.endpoint("test_endpoint".to_string());
2266        let client = endpoint.client().await.unwrap();
2267        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2268            .await
2269            .unwrap();
2270
2271        let error = router.generate(SingleIn::new(42)).await.unwrap_err();
2272        assert!(match_error_chain(
2273            error.as_ref(),
2274            &[ErrorType::Unavailable],
2275            &[]
2276        ));
2277
2278        rt.shutdown();
2279    }
2280
2281    #[tokio::test]
2282    async fn round_robin_excludes_overloaded_workers_from_candidates() {
2283        // Long reconcile interval so the synthetic override below survives
2284        // the test. We still register a real endpoint instance up front so
2285        // the initial reconcile (which fires immediately when the monitor
2286        // task spawns) settles on a non-empty source — without that, the
2287        // first reconcile would clobber the override before it takes effect.
2288        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2289
2290        let rt = Runtime::from_current().unwrap();
2291        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2292            .await
2293            .unwrap();
2294        let ns = drt
2295            .namespace("test_round_robin_excludes_overloaded".to_string())
2296            .unwrap();
2297        let component = ns.component("test_component".to_string()).unwrap();
2298        let endpoint = component.endpoint("test_endpoint".to_string());
2299        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2300            .await
2301            .unwrap();
2302
2303        endpoint.register_endpoint_instance().await.unwrap();
2304        let instances = client.wait_for_instances().await.unwrap();
2305        let real_id = instances[0].id();
2306        for _ in 0..50 {
2307            if client.instance_ids_avail().contains(&real_id) {
2308                break;
2309            }
2310            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2311        }
2312
2313        // Now override with two synthetic IDs and mark one overloaded.
2314        // round_robin must never select the overloaded one — that's the
2315        // whole point of selecting from free_ids instead of routable_ids.
2316        // The post-selection overload check in route() would otherwise return 529
2317        // one of N requests on each pass, which is the bug this PR closes
2318        // for non-KV selectors.
2319        client.override_instance_avail(vec![1, 2]);
2320        client.set_overloaded_instances(&[1]);
2321
2322        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2323            .await
2324            .unwrap();
2325
2326        // Round-robin over N requests should land on worker 2 every time.
2327        // We use peek_next_worker for a side-effect-free probe.
2328        for _ in 0..6 {
2329            let selected = router
2330                .peek_next_worker()
2331                .expect("peek should succeed with a free worker");
2332            assert_eq!(
2333                selected, 2,
2334                "overloaded worker 1 must not appear in the candidate set"
2335            );
2336        }
2337
2338        rt.shutdown();
2339    }
2340
2341    #[tokio::test]
2342    async fn device_aware_cpu_only_selects_least_loaded_instance() {
2343        let state = RoutingOccupancyState::default();
2344        // All candidates are CPU. Make worker 2 the least-loaded one.
2345        for _ in 0..3 {
2346            state.increment(1);
2347        }
2348        state.increment(3);
2349
2350        let instance_ids = vec![1, 2, 3];
2351        let device_type_map = HashMap::from([
2352            (1, Some(DeviceType::Cpu)),
2353            (2, Some(DeviceType::Cpu)),
2354            (3, Some(DeviceType::Cpu)),
2355        ]);
2356
2357        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 8);
2358        assert_eq!(candidates, vec![1, 2, 3]);
2359
2360        let selected = state
2361            .select_exact_min_and_increment(&candidates)
2362            .await
2363            .unwrap();
2364        assert_eq!(selected, 2);
2365    }
2366
2367    #[tokio::test]
2368    async fn device_aware_non_cpu_only_selects_least_loaded_instance() {
2369        let state = RoutingOccupancyState::default();
2370        // All candidates are non-CPU. Make worker 2 the least-loaded one.
2371        for _ in 0..3 {
2372            state.increment(1);
2373        }
2374        state.increment(3);
2375
2376        let instance_ids = vec![1, 2, 3];
2377        let device_type_map = HashMap::from([
2378            (1, Some(DeviceType::Cuda)),
2379            (2, Some(DeviceType::Cuda)),
2380            (3, Some(DeviceType::Cuda)),
2381        ]);
2382
2383        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 8);
2384        assert_eq!(candidates, vec![1, 2, 3]);
2385
2386        let selected = state
2387            .select_exact_min_and_increment(&candidates)
2388            .await
2389            .unwrap();
2390        assert_eq!(selected, 2);
2391    }
2392
2393    #[test]
2394    fn device_aware_group_uses_ratio_budget() {
2395        let state = RoutingOccupancyState::default();
2396        // CPU ids: 1,2 ; non-CPU ids: 3,4
2397        for _ in 0..4 {
2398            state.increment(3);
2399            state.increment(4);
2400        }
2401        // CPU inflight can differ across instances; budgeting uses total CPU inflight.
2402        for _ in 0..3 {
2403            state.increment(1);
2404        }
2405        // total_non_cpu_inflight=8, cpu_count=2, non_cpu_count=2, ratio=2
2406        // allowed_cpu_inflight = 8*2/(2*2)=4
2407        // total_cpu_inflight=3 < 4 => choose CPU group.
2408        let instance_ids = vec![1, 2, 3, 4];
2409        let device_type_map = HashMap::from([
2410            (1, Some(DeviceType::Cpu)),
2411            (2, Some(DeviceType::Cpu)),
2412            (3, Some(DeviceType::Cuda)),
2413            (4, Some(DeviceType::Cuda)),
2414        ]);
2415
2416        let candidates = device_aware_candidate_group(&state, &instance_ids, &device_type_map, 2);
2417        assert_eq!(candidates, vec![1, 2]);
2418
2419        // Within selected CPU group, final choice should be the least-loaded instance (id=2).
2420        let selected =
2421            futures::executor::block_on(state.select_exact_min_and_increment(&candidates)).unwrap();
2422        assert_eq!(selected, 2);
2423    }
2424
2425    #[tokio::test]
2426    async fn device_aware_weighted_peek_returns_available_worker_select_stays_none() {
2427        let rt = Runtime::from_current().unwrap();
2428        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2429            .await
2430            .unwrap();
2431        let ns = drt
2432            .namespace("test_device_aware_router".to_string())
2433            .unwrap();
2434        let component = ns.component("test_component".to_string()).unwrap();
2435        let endpoint = component.endpoint("test_endpoint".to_string());
2436        let client = endpoint.client().await.unwrap();
2437
2438        endpoint.register_endpoint_instance().await.unwrap();
2439        client.wait_for_instances().await.unwrap();
2440
2441        let router =
2442            PushRouter::<u64, TestResponse>::from_client(client, RouterMode::DeviceAwareWeighted)
2443                .await
2444                .unwrap();
2445
2446        // DeviceAwareWeighted degenerates to least-loaded for peek (device-class
2447        // partitioning happens at dispatch); select_next_worker stays None.
2448        assert_eq!(router.select_next_worker(), None);
2449        assert!(
2450            router.peek_next_worker().is_some(),
2451            "DeviceAwareWeighted peek must return the available worker for disagg bootstrap"
2452        );
2453
2454        rt.shutdown();
2455    }
2456
2457    #[tokio::test]
2458    async fn device_aware_exact_selection_preserves_full_multimodal_cache_hit() {
2459        let rt = Runtime::from_current().unwrap();
2460        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2461            .await
2462            .unwrap();
2463        let ns = drt
2464            .namespace("test_device_aware_affinity_cache".to_string())
2465            .unwrap();
2466        let component = ns.component("test_component".to_string()).unwrap();
2467        let endpoint = component.endpoint("test_endpoint".to_string());
2468        let client = endpoint.client().await.unwrap();
2469        endpoint.register_endpoint_instance().await.unwrap();
2470        let cache_worker = client.wait_for_instances().await.unwrap()[0].id();
2471
2472        let router = PushRouter::<u64, TestResponse>::from_client_with_state(
2473            client,
2474            RouterMode::DeviceAwareWeighted,
2475            None,
2476            Some(Arc::new(StaticMultimodalCacheIndex {
2477                worker_id: cache_worker,
2478            })),
2479            Some(Arc::new(|_| vec!["image-key".to_string()])),
2480        )
2481        .await
2482        .unwrap();
2483
2484        let (worker_id, permit) = router.select_exact_target(&42, None).await.unwrap();
2485        assert_eq!(worker_id, cache_worker);
2486        assert!(
2487            permit.is_none(),
2488            "full cache hits bypass occupancy charging"
2489        );
2490
2491        rt.shutdown();
2492    }
2493
2494    /// When the router selects an instance that has deregistered between selection
2495    /// and transport resolution, it should fall back to another available instance
2496    /// rather than returning a 500 error.
2497    #[tokio::test]
2498    async fn transport_resolution_falls_back_when_selected_instance_disappears() {
2499        let rt = Runtime::from_current().unwrap();
2500        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2501            .await
2502            .unwrap();
2503        let ns = drt
2504            .namespace("test_transport_fallback".to_string())
2505            .unwrap();
2506        let component = ns.component("test_component".to_string()).unwrap();
2507        let endpoint = component.endpoint("test_endpoint".to_string());
2508        let client = endpoint.client().await.unwrap();
2509
2510        // Register one real instance so it appears in instance_source.
2511        endpoint.register_endpoint_instance().await.unwrap();
2512        client.wait_for_instances().await.unwrap();
2513
2514        let real_id = client.instance_ids()[0];
2515
2516        // Inject a stale ID into instance_avail that does NOT exist in
2517        // instance_source. This simulates the race window where an instance
2518        // deregistered after selection but before transport resolution.
2519        let stale_id = real_id + 1000;
2520        client.override_instance_avail(vec![stale_id, real_id]);
2521
2522        // Build a router and call direct() targeting the *real* instance to
2523        // verify the router can still resolve transport for known instances.
2524        let router =
2525            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2526                .await
2527                .unwrap();
2528
2529        // Round robin should succeed — even if it picks stale_id first, the
2530        // fallback logic should resolve transport via real_id.
2531        // We cannot fully test the network send without a worker, but we can
2532        // verify it doesn't fail at the transport resolution stage by checking
2533        // that the error (if any) is a transport/network error, not
2534        // "Instance not found".
2535        let request = SingleIn::new(42u64);
2536        let result = router.generate(request).await;
2537
2538        // The request may fail at the network level (no actual worker), but it
2539        // must NOT fail with "Instance X not found" — that would mean the
2540        // fallback did not work.
2541        if let Err(err) = &result {
2542            let msg = format!("{err}");
2543            assert!(
2544                !msg.contains("not found"),
2545                "Transport resolution should have fallen back, but got: {msg}"
2546            );
2547        }
2548
2549        rt.shutdown();
2550    }
2551
2552    #[tokio::test]
2553    async fn prepared_dispatch_observes_worker_after_transport_fallback() {
2554        let rt = Runtime::from_current().unwrap();
2555        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2556            .await
2557            .unwrap();
2558        let endpoint = drt
2559            .namespace("test_prepared_transport_fallback".to_string())
2560            .unwrap()
2561            .component("test_component".to_string())
2562            .unwrap()
2563            .endpoint("test_endpoint".to_string());
2564        let client = endpoint.client().await.unwrap();
2565        endpoint.register_endpoint_instance().await.unwrap();
2566        let real_id = client.wait_for_instances().await.unwrap()[0].id();
2567        let stale_id = real_id.wrapping_add(1);
2568        client.override_instance_avail(vec![stale_id, real_id]);
2569        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2570            .await
2571            .unwrap();
2572        let state = router.occupancy_state.clone().unwrap();
2573        state.increment(real_id);
2574        let state_for_prepare = state.clone();
2575        let observed = Arc::new(AtomicU64::new(0));
2576        let observed_for_prepare = observed.clone();
2577
2578        let _ = tokio::time::timeout(
2579            std::time::Duration::from_millis(100),
2580            router.select_and_dispatch(SingleIn::new(42), move |_, worker_id| {
2581                assert_eq!(state_for_prepare.load(stale_id), 0);
2582                assert_eq!(state_for_prepare.load(worker_id), 2);
2583                observed_for_prepare.store(worker_id, Ordering::Relaxed);
2584                Ok(())
2585            }),
2586        )
2587        .await;
2588
2589        assert_eq!(observed.load(Ordering::Relaxed), real_id);
2590        assert_eq!(state.load(real_id), 1);
2591        state.decrement(real_id);
2592        rt.shutdown();
2593    }
2594
2595    /// When no instances are available at all (both primary and fallback),
2596    /// the router should return a clear error.
2597    #[tokio::test]
2598    async fn transport_resolution_errors_when_no_instances_available() {
2599        let rt = Runtime::from_current().unwrap();
2600        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2601            .await
2602            .unwrap();
2603        let ns = drt
2604            .namespace("test_transport_no_fallback".to_string())
2605            .unwrap();
2606        let component = ns.component("test_component".to_string()).unwrap();
2607        let endpoint = component.endpoint("test_endpoint".to_string());
2608        let client = endpoint.client().await.unwrap();
2609
2610        // Register an instance so we can create the router (needs transport setup).
2611        endpoint.register_endpoint_instance().await.unwrap();
2612        client.wait_for_instances().await.unwrap();
2613
2614        let router =
2615            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2616                .await
2617                .unwrap();
2618
2619        // Override avail to contain only a stale ID with no real backing
2620        // instance AND no other available fallback.
2621        let stale_id = 99999;
2622        client.override_instance_avail(vec![stale_id]);
2623
2624        let request = SingleIn::new(42u64);
2625        let result = router.generate(request).await;
2626
2627        assert!(result.is_err());
2628        let msg = format!("{}", result.unwrap_err());
2629        assert!(
2630            msg.contains("not found") && msg.contains("no other instances available"),
2631            "Expected clear error about missing instance with no fallback, got: {msg}"
2632        );
2633
2634        rt.shutdown();
2635    }
2636
2637    #[tokio::test]
2638    async fn transport_resolution_honors_fallback_policy() {
2639        let rt = Runtime::from_current().unwrap();
2640        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2641            .await
2642            .unwrap();
2643        let ns = drt
2644            .namespace("test_exact_transport_no_fallback".to_string())
2645            .unwrap();
2646        let component = ns.component("test_component".to_string()).unwrap();
2647        let endpoint = component.endpoint("test_endpoint".to_string());
2648        let client = endpoint.client().await.unwrap();
2649        endpoint.register_endpoint_instance().await.unwrap();
2650        let instances = client.wait_for_instances().await.unwrap();
2651        let real_id = instances[0].id();
2652
2653        let router =
2654            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
2655                .await
2656                .unwrap();
2657        let stale_id = real_id.wrapping_add(1);
2658        client.override_instance_avail(vec![stale_id, real_id]);
2659
2660        assert!(
2661            router
2662                .resolve_transport(stale_id, TransportFallback::Allow)
2663                .is_ok(),
2664            "normal dispatch should preserve transport fallback"
2665        );
2666        let allowed = HashSet::from([real_id]);
2667        assert!(
2668            router
2669                .resolve_transport(stale_id, TransportFallback::Within(&allowed))
2670                .is_ok(),
2671            "constrained dispatch should fall back within the allowed worker set"
2672        );
2673        let disallowed = HashSet::new();
2674        assert!(
2675            router
2676                .resolve_transport(stale_id, TransportFallback::Within(&disallowed))
2677                .is_err(),
2678            "constrained dispatch must not fall back outside the allowed worker set"
2679        );
2680        let error = router
2681            .resolve_transport(stale_id, TransportFallback::Deny)
2682            .unwrap_err();
2683        assert!(
2684            error.to_string().contains("not found"),
2685            "exact dispatch must reject the missing selected worker"
2686        );
2687
2688        rt.shutdown();
2689    }
2690
2691    /// The watcher dedup guard must be released even if the spawned task panics.
2692    /// Without this, a panic anywhere in the watcher body would leave a stale
2693    /// `ENDPOINT_WATCHER_ACTIVE` entry, silently disabling orphaned-pending-
2694    /// request cancellation for that endpoint until process restart.
2695    ///
2696    /// We exercise the Drop-guard pattern directly against the same static
2697    /// rather than driving `spawn_instance_removal_watcher` end-to-end (which
2698    /// would require staging a panicking discovery stream). The test mirrors
2699    /// the production code's GuardRelease shape; if the production code stops
2700    /// using a Drop guard, the integration would regress and the existing
2701    /// orphan-cancellation tests would fail.
2702    #[tokio::test]
2703    async fn watcher_dedup_guard_released_on_panic() {
2704        let endpoint_id = EndpointId {
2705            namespace: "panic-test-ns".to_string(),
2706            component: "panic-test-comp".to_string(),
2707            name: "panic-test-endpoint".to_string(),
2708        };
2709
2710        // Mimic the production code's pre-spawn dedup insert.
2711        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
2712        map.insert(endpoint_id.clone(), ());
2713
2714        let endpoint_id_clone = endpoint_id.clone();
2715        let join = tokio::spawn(async move {
2716            // Same shape as in spawn_instance_removal_watcher.
2717            struct GuardRelease(EndpointId);
2718            impl Drop for GuardRelease {
2719                fn drop(&mut self) {
2720                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
2721                        map.remove(&self.0);
2722                    }
2723                }
2724            }
2725            let _release = GuardRelease(endpoint_id_clone);
2726            panic!("simulated watcher-task panic");
2727        });
2728
2729        let result = join.await;
2730        assert!(result.is_err() && result.unwrap_err().is_panic());
2731        assert!(
2732            !map.contains_key(&endpoint_id),
2733            "Drop guard must release the dedup entry even on panic"
2734        );
2735    }
2736
2737    /// Normal-exit path: the Drop guard releases the entry when the task
2738    /// finishes without panicking. This is the everyday case (cancel_token
2739    /// fires or discovery stream closes).
2740    #[tokio::test]
2741    async fn watcher_dedup_guard_released_on_normal_exit() {
2742        let endpoint_id = EndpointId {
2743            namespace: "normal-test-ns".to_string(),
2744            component: "normal-test-comp".to_string(),
2745            name: "normal-test-endpoint".to_string(),
2746        };
2747
2748        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
2749        map.insert(endpoint_id.clone(), ());
2750
2751        let endpoint_id_clone = endpoint_id.clone();
2752        tokio::spawn(async move {
2753            struct GuardRelease(EndpointId);
2754            impl Drop for GuardRelease {
2755                fn drop(&mut self) {
2756                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
2757                        map.remove(&self.0);
2758                    }
2759                }
2760            }
2761            let _release = GuardRelease(endpoint_id_clone);
2762            // task body returns normally
2763        })
2764        .await
2765        .unwrap();
2766
2767        assert!(!map.contains_key(&endpoint_id));
2768    }
2769}