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::pipeline::network::egress::route_span::{
7    get_route_trace_context, record_route_error, record_route_span_start, wrap_route_span,
8};
9use crate::{
10    component::{Client, DeviceType, Endpoint, Instance, RoutingInstances},
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, StreamingDispatch,
17        error::{PipelineError, PipelineErrorExt},
18    },
19    protocols::{EndpointId, maybe_error::MaybeError},
20    routing_policy::{
21        CandidateView, OccupancyReservation, RouteCandidate, RouteContext, RouteDevice,
22        RoutePicker, RoutePolicy, RouteTarget, RoutingOccupancyState,
23        get_or_create_routing_occupancy_state,
24    },
25    traits::DistributedRuntimeProvider,
26};
27use async_trait::async_trait;
28use futures::Stream;
29use serde::{Deserialize, Serialize};
30use std::{
31    collections::{HashMap, HashSet},
32    marker::PhantomData,
33    pin::Pin,
34    sync::{Arc, atomic::AtomicU64},
35    task::Poll,
36    time::Instant,
37};
38use tokio_stream::StreamExt;
39use tracing::Instrument;
40
41/// Check if an error chain indicates the worker should be reported as down.
42fn is_inhibited(err: &(dyn std::error::Error + 'static)) -> bool {
43    const INHIBITED: &[ErrorType] = &[
44        ErrorType::CannotConnect,
45        ErrorType::Disconnected,
46        ErrorType::ConnectionTimeout,
47        ErrorType::ResponseTimeout,
48        ErrorType::Backend(BackendError::EngineShutdown),
49        // A stream that ends mid-generation means this worker dropped the
50        // request. Quarantine it, or a migration retry can reselect the same
51        // worker before discovery removal catches up.
52        ErrorType::Backend(BackendError::StreamIncomplete),
53    ];
54    match_error_chain(err, INHIBITED, &[])
55}
56
57/// Read the backend response inactivity timeout from the environment.
58/// Reuses `DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS` — the same env var
59/// as the HTTP-layer safety net in `disconnect.rs`.
60fn response_inactivity_timeout() -> Option<std::time::Duration> {
61    use crate::config::environment_names::llm::DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS;
62    std::env::var(DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS)
63        .ok()
64        .and_then(|s| s.parse::<u64>().ok())
65        .filter(|&secs| secs > 0)
66        .map(std::time::Duration::from_secs)
67}
68
69/// RAII handle for one in-flight unit of work charged against
70/// [`RoutingOccupancyState`]. The counter is incremented at construction; the
71/// matching decrement is emitted on drop (or by [`Self::into_tracked_stream`]).
72struct OccupancyPermit {
73    reservation: Option<OccupancyReservation>,
74}
75
76impl OccupancyPermit {
77    fn acquire(state: Arc<RoutingOccupancyState>, instance_id: u64) -> Self {
78        Self {
79            reservation: Some(state.reserve(instance_id)),
80        }
81    }
82
83    fn from_counter(
84        state: Arc<RoutingOccupancyState>,
85        instance_id: u64,
86        counter: Arc<AtomicU64>,
87    ) -> Self {
88        Self {
89            reservation: Some(OccupancyReservation::from_counter(
90                state,
91                instance_id,
92                counter,
93            )),
94        }
95    }
96
97    fn retarget(&mut self, instance_id: u64) {
98        self.reservation
99            .as_mut()
100            .expect("occupancy permit must be armed before stream tracking")
101            .retarget(instance_id);
102    }
103
104    fn into_tracked_stream<U: Data + MaybeError>(mut self, stream: ManyOut<U>) -> ManyOut<U> {
105        let engine_ctx = stream.context();
106        ResponseStream::new(
107            Box::pin(OccupancyTrackedStream {
108                inner: stream,
109                reservation: self.reservation.take(),
110            }),
111            engine_ctx,
112        )
113    }
114}
115
116/// Trait for monitoring worker load and determining overload state.
117/// Implementations can define custom load metrics and overload thresholds.
118#[async_trait]
119pub trait WorkerLoadMonitor: Send + Sync {
120    /// Start background monitoring of worker load.
121    /// This should spawn background tasks that update the client's overloaded instances.
122    async fn start_monitoring(&self) -> anyhow::Result<()>;
123}
124
125/// Query interface for routing against multimodal embedding cache state.
126pub trait MultimodalCacheIndex: Send + Sync {
127    fn workers_with_cache_key_hits(&self, cache_keys: &[String]) -> Vec<(u64, usize)>;
128    fn remove_worker(&self, worker_id: u64);
129}
130
131pub type MultimodalCacheKeyExtractor<T> = Arc<dyn Fn(&T) -> Vec<String> + Send + Sync>;
132
133#[derive(Clone)]
134pub struct PushRouter<T, U>
135where
136    T: Data + Serialize,
137    U: Data + for<'de> Deserialize<'de>,
138{
139    // TODO: This shouldn't be pub, but lib/bindings/python/rust/lib.rs exposes it.
140    /// The Client is how we gather remote endpoint information from etcd.
141    pub client: Client,
142
143    /// How we choose which instance to send traffic to.
144    ///
145    /// Setting this to KV means we never intend to call `generate` on this PushRouter. We are
146    /// not using it as an AsyncEngine.
147    /// Instead we will decide whether to call random/round_robin/direct ourselves and call them directly.
148    /// dynamo-llm's KV Routing does this.
149    router_mode: RouterMode,
150
151    /// Shared, scheduler-independent policy state. KV and Direct have no picker.
152    picker: Option<Arc<RoutePicker>>,
153
154    /// Policy-specific state for callers that explicitly request static routing,
155    /// independently of the router's configured generate mode.
156    round_robin_picker: Arc<RoutePicker>,
157    random_picker: Arc<RoutePicker>,
158
159    /// The final hop: after selecting an instance, `PushRouter` hands it to this
160    /// `StreamingDispatch` (the request-plane `AddressedPushRouter` by default).
161    /// A trait object so an alternate transport can swap it out.
162    addressed: Arc<dyn StreamingDispatch<T, U>>,
163
164    /// When false, `generate_with_fault_detection` skips fault detection logic:
165    /// it won't call `report_instance_down` on errors, and it uses the raw discovery
166    /// instance list instead of the filtered avail list. Use for recovery/query paths
167    /// where transient failures are expected.
168    fault_detection_enabled: bool,
169
170    /// Cached response inactivity timeout. Read once at construction from
171    /// [`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.
172    response_timeout: Option<std::time::Duration>,
173
174    /// Shared request occupancy state for tracked routing modes.
175    occupancy_state: Option<Arc<RoutingOccupancyState>>,
176
177    /// Optional cache index for direct multimodal embedding cache lookups.
178    /// Currently consumed by `RouterMode::DeviceAwareWeighted`.
179    multimodal_cache_indexer: Option<Arc<dyn MultimodalCacheIndex>>,
180
181    /// Optional typed request extractor for multimodal embedding cache keys.
182    multimodal_cache_key_extractor: Option<MultimodalCacheKeyExtractor<T>>,
183
184    /// An internal Rust type. This says that PushRouter is generic over the T and U types,
185    /// which are the input and output types of it's `generate` function. It allows the
186    /// compiler to specialize us at compile time.
187    _phantom: PhantomData<(T, U)>,
188}
189
190#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum RouterMode {
193    #[default]
194    RoundRobin,
195    Random,
196    PowerOfTwoChoices,
197    KV,
198    Direct,
199    LeastLoaded,
200    /// Device-aware weighted routing for heterogeneous workers.
201    DeviceAwareWeighted,
202}
203
204#[derive(Clone, Copy)]
205enum TransportFallback<'a> {
206    Allow,
207    Deny,
208    Within(&'a HashSet<u64>),
209}
210
211#[derive(Clone, Copy)]
212enum OverloadCheck {
213    Required,
214    AlreadyAdmitted,
215}
216
217struct DeviceAwareCandidates {
218    candidates: Vec<RouteCandidate>,
219    context: RouteContext,
220    embedding_cache_hit: bool,
221    request_cache_keys: usize,
222}
223
224/// A DeviceAware policy decision whose optional occupancy booking is owned by
225/// the caller's request lifecycle.
226pub struct DeviceAwareSelection {
227    worker_id: u64,
228    candidate_count: usize,
229    load: u64,
230    is_cpu: bool,
231    embedding_cache_hit: bool,
232    request_cache_keys: usize,
233    reservation: Option<OccupancyReservation>,
234}
235
236impl DeviceAwareSelection {
237    pub fn worker_id(&self) -> u64 {
238        self.worker_id
239    }
240
241    pub fn candidate_count(&self) -> usize {
242        self.candidate_count
243    }
244
245    pub fn load(&self) -> u64 {
246        self.load
247    }
248
249    pub fn is_cpu(&self) -> bool {
250        self.is_cpu
251    }
252
253    pub fn embedding_cache_hit(&self) -> bool {
254        self.embedding_cache_hit
255    }
256
257    pub fn request_cache_keys(&self) -> usize {
258        self.request_cache_keys
259    }
260
261    pub fn into_reservation(self) -> Option<OccupancyReservation> {
262        self.reservation
263    }
264}
265
266impl RouterMode {
267    pub const fn telemetry_label(self) -> &'static str {
268        match self {
269            Self::RoundRobin => "round-robin",
270            Self::Random => "random",
271            Self::PowerOfTwoChoices => "power-of-two-choices",
272            Self::KV => "kv",
273            Self::Direct => "direct",
274            Self::LeastLoaded => "least-loaded",
275            Self::DeviceAwareWeighted => "device-aware-weighted",
276        }
277    }
278
279    pub fn is_kv_routing(&self) -> bool {
280        *self == RouterMode::KV
281    }
282
283    pub fn is_direct_routing(&self) -> bool {
284        *self == RouterMode::Direct
285    }
286
287    /// Whether this mode admits requests against host-owned occupancy counters.
288    pub const fn requires_occupancy(self) -> bool {
289        matches!(
290            self,
291            Self::PowerOfTwoChoices | Self::LeastLoaded | Self::DeviceAwareWeighted
292        )
293    }
294
295    fn route_policy(self) -> Option<RoutePolicy> {
296        match self {
297            Self::RoundRobin => Some(RoutePolicy::RoundRobin),
298            Self::Random => Some(RoutePolicy::Random),
299            Self::PowerOfTwoChoices => Some(RoutePolicy::PowerOfTwoChoices),
300            Self::LeastLoaded => Some(RoutePolicy::LeastLoaded),
301            Self::DeviceAwareWeighted => Some(RoutePolicy::DeviceAwareWeighted),
302            Self::KV | Self::Direct => None,
303        }
304    }
305}
306
307fn route_pickers(
308    router_mode: RouterMode,
309) -> (Arc<RoutePicker>, Arc<RoutePicker>, Option<Arc<RoutePicker>>) {
310    let round_robin = Arc::new(RoutePicker::new(RoutePolicy::RoundRobin));
311    let random = Arc::new(RoutePicker::new(RoutePolicy::Random));
312    let configured = match router_mode {
313        RouterMode::RoundRobin => Some(round_robin.clone()),
314        RouterMode::Random => Some(random.clone()),
315        mode => mode.route_policy().map(RoutePicker::new).map(Arc::new),
316    };
317    (round_robin, random, configured)
318}
319
320/// Pick the instance with lower in-flight count from two random candidates.
321/// Returns the single instance if only one is available.
322#[cfg(test)]
323fn p2c_select_from(occupancy_state: &RoutingOccupancyState, instance_ids: &[u64]) -> u64 {
324    RoutePicker::new(RoutePolicy::PowerOfTwoChoices)
325        .peek(
326            CandidateView::Workers(instance_ids),
327            RouteContext::default(),
328            |id| occupancy_state.load(id),
329        )
330        .expect("p2c selection requires at least one candidate")
331        .target
332        .worker_id
333}
334
335/// At most one `list_and_watch` per endpoint, across all `PushRouter`
336/// instances. Entry removed on watcher exit so a later router can re-arm.
337static ENDPOINT_WATCHER_ACTIVE: std::sync::OnceLock<dashmap::DashMap<EndpointId, ()>> =
338    std::sync::OnceLock::new();
339
340#[derive(Clone, Debug, Eq, Hash, PartialEq)]
341struct RuntimeEndpointId {
342    connection_id: u64,
343    endpoint_id: EndpointId,
344}
345
346impl RuntimeEndpointId {
347    fn for_endpoint(endpoint: &Endpoint) -> Self {
348        Self {
349            connection_id: endpoint.drt().connection_id(),
350            endpoint_id: endpoint.id(),
351        }
352    }
353}
354
355/// At most one multimodal cache cleanup watcher per runtime endpoint.
356static ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE: std::sync::OnceLock<
357    dashmap::DashMap<RuntimeEndpointId, ()>,
358> = std::sync::OnceLock::new();
359
360/// Watch discovery for instance removals and cancel pending response-stream
361/// registrations on the removed instance, unblocking queued requests with
362/// a migratable `Disconnected` error. Uses raw `list_and_watch` events
363/// (not a coalesced snapshot diff) so a rapid remove→re-add of the same
364/// identity is not silently swallowed. Keyed by full `EndpointInstanceId`.
365fn spawn_instance_removal_watcher<T, U>(
366    endpoint: Endpoint,
367    dispatch: Arc<dyn StreamingDispatch<T, U>>,
368    cancel_token: tokio_util::sync::CancellationToken,
369) where
370    T: Data + Serialize + 'static,
371    U: Data + for<'de> Deserialize<'de> + MaybeError + 'static,
372{
373    use crate::discovery::{
374        DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
375    };
376    use tokio_stream::StreamExt as _;
377
378    // One watcher per endpoint: if one is already running, skip.
379    let guard = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
380    let endpoint_id = endpoint.id();
381    if guard.insert(endpoint_id.clone(), ()).is_some() {
382        tracing::debug!(
383            ?endpoint_id,
384            "Instance removal watcher already running for this endpoint, skipping"
385        );
386        return;
387    }
388
389    let endpoint_name = endpoint.name().to_string();
390
391    tokio::spawn(async move {
392        // Release on every exit path (including panic); a leaked entry
393        // silently disables removal cancellation until process restart.
394        struct GuardRelease(EndpointId);
395        impl Drop for GuardRelease {
396            fn drop(&mut self) {
397                if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
398                    map.remove(&self.0);
399                }
400            }
401        }
402        let _release = GuardRelease(endpoint_id);
403
404        let namespace = endpoint.component().namespace().name();
405        let component = endpoint.component().name().to_string();
406
407        // Reconnect on transient discovery failure; cancel-aware backoff.
408        const RECONNECT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
409        'reconnect: loop {
410            let query = DiscoveryQuery::Endpoint {
411                namespace: namespace.clone(),
412                component: component.clone(),
413                endpoint: endpoint_name.clone(),
414            };
415
416            let mut stream = match endpoint.drt().discovery().list_and_watch(query, None).await {
417                Ok(s) => s,
418                Err(e) => {
419                    tracing::warn!(
420                        endpoint = %endpoint_name,
421                        "Failed to start instance removal watcher (will retry): {e}"
422                    );
423                    tokio::select! {
424                        _ = tokio::time::sleep(RECONNECT_BACKOFF) => continue 'reconnect,
425                        _ = cancel_token.cancelled() => break 'reconnect,
426                    }
427                }
428            };
429
430            loop {
431                tokio::select! {
432                    event = stream.next() => {
433                        match event {
434                            Some(Ok(DiscoveryEvent::Removed(id))) => {
435                                if let DiscoveryInstanceId::Endpoint(eid) = &id {
436                                    dispatch.on_instance_removed(eid).await;
437                                }
438                            }
439                            Some(Ok(DiscoveryEvent::Added(DiscoveryInstance::Endpoint(inst)))) => {
440                                let eid: EndpointInstanceId = inst.endpoint_instance_id();
441                                dispatch.on_instance_added(&eid).await;
442                            }
443                            Some(Ok(_)) => {}
444                            Some(Err(e)) => {
445                                tracing::warn!(
446                                    endpoint = %endpoint_name,
447                                    "Instance removal watcher stream error: {e}"
448                                );
449                            }
450                            None => {
451                                tracing::warn!(
452                                    endpoint = %endpoint_name,
453                                    "Instance removal watcher stream ended; reconnecting"
454                                );
455                                continue 'reconnect;
456                            }
457                        }
458                    }
459                    _ = cancel_token.cancelled() => {
460                        break 'reconnect;
461                    }
462                }
463            }
464        }
465
466        tracing::debug!(endpoint = %endpoint_name, "Instance removal watcher exiting");
467    });
468}
469
470/// Watch discovery removals for cache-aware routers and drop stale worker cache entries.
471fn spawn_multimodal_cache_cleanup_watcher(
472    endpoint: Endpoint,
473    indexer: Arc<dyn MultimodalCacheIndex>,
474    cancel_token: tokio_util::sync::CancellationToken,
475) {
476    use crate::discovery::{DiscoveryEvent, DiscoveryInstanceId, DiscoveryQuery};
477    use tokio_stream::StreamExt as _;
478
479    let guard = ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
480    let watcher_id = RuntimeEndpointId::for_endpoint(&endpoint);
481    if guard.insert(watcher_id.clone(), ()).is_some() {
482        tracing::debug!(
483            connection_id = watcher_id.connection_id,
484            ?watcher_id.endpoint_id,
485            "Multimodal cache cleanup watcher already running for this runtime endpoint, skipping"
486        );
487        return;
488    }
489
490    let endpoint_name = endpoint.name().to_string();
491    let namespace = endpoint.component().namespace().name();
492    let component = endpoint.component().name().to_string();
493
494    tokio::spawn(async move {
495        struct GuardRelease(RuntimeEndpointId);
496        impl Drop for GuardRelease {
497            fn drop(&mut self) {
498                if let Some(map) = ENDPOINT_CACHE_INDEXER_WATCHER_ACTIVE.get() {
499                    map.remove(&self.0);
500                }
501            }
502        }
503        let _release = GuardRelease(watcher_id);
504
505        const RECONNECT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
506        'reconnect: loop {
507            let query = DiscoveryQuery::Endpoint {
508                namespace: namespace.clone(),
509                component: component.clone(),
510                endpoint: endpoint_name.clone(),
511            };
512
513            let mut stream = match endpoint.drt().discovery().list_and_watch(query, None).await {
514                Ok(stream) => stream,
515                Err(error) => {
516                    tracing::warn!(
517                        endpoint = %endpoint_name,
518                        "Failed to start multimodal cache cleanup watcher (will retry): {error}"
519                    );
520                    tokio::select! {
521                        _ = tokio::time::sleep(RECONNECT_BACKOFF) => continue 'reconnect,
522                        _ = cancel_token.cancelled() => break 'reconnect,
523                    }
524                }
525            };
526
527            loop {
528                tokio::select! {
529                    event = stream.next() => {
530                        match event {
531                            Some(Ok(DiscoveryEvent::Removed(DiscoveryInstanceId::Endpoint(eid)))) => {
532                                indexer.remove_worker(eid.instance_id);
533                            }
534                            Some(Ok(_)) => {}
535                            Some(Err(error)) => {
536                                tracing::warn!(
537                                    endpoint = %endpoint_name,
538                                    "Multimodal cache cleanup watcher stream error: {error}"
539                                );
540                                continue 'reconnect;
541                            }
542                            None => {
543                                tracing::warn!(
544                                    endpoint = %endpoint_name,
545                                    "Multimodal cache cleanup watcher stream ended; reconnecting"
546                                );
547                                continue 'reconnect;
548                            }
549                        }
550                    }
551                    _ = cancel_token.cancelled() => break 'reconnect,
552                }
553            }
554        }
555
556        tracing::debug!(endpoint = %endpoint_name, "Multimodal cache cleanup watcher exiting");
557    });
558}
559
560async fn addressed_router(endpoint: &Endpoint) -> anyhow::Result<Arc<AddressedPushRouter>> {
561    AddressedPushRouter::from_runtime_provider(endpoint).await
562}
563
564impl<T, U> PushRouter<T, U>
565where
566    T: Data + Serialize,
567    U: Data + for<'de> Deserialize<'de> + MaybeError,
568{
569    pub fn router_mode(&self) -> RouterMode {
570        self.router_mode
571    }
572
573    /// Create a new PushRouter without a worker load monitor (no overload detection)
574    pub async fn from_client(client: Client, router_mode: RouterMode) -> anyhow::Result<Self> {
575        Self::from_client_with_monitor(client, router_mode, None).await
576    }
577
578    /// Create a new PushRouter with fault detection disabled.
579    ///
580    /// Unlike `from_client`, this router will not call `report_instance_down` on
581    /// transient errors, and `direct()` uses the raw discovery instance list instead
582    /// of the filtered avail list. Use for recovery/query paths.
583    pub async fn from_client_no_fault_detection(
584        client: Client,
585        router_mode: RouterMode,
586    ) -> anyhow::Result<Self> {
587        let addressed = addressed_router(&client.endpoint).await?;
588
589        let occupancy_state = if router_mode.requires_occupancy() {
590            Some(get_or_create_routing_occupancy_state(&client.endpoint).await)
591        } else {
592            None
593        };
594
595        // Type-erase to the seam so discovery-removal cleanup runs through it.
596        let addressed: Arc<dyn StreamingDispatch<T, U>> = addressed;
597        spawn_instance_removal_watcher(
598            client.endpoint.clone(),
599            addressed.clone(),
600            client.endpoint.drt().primary_token(),
601        );
602        let (round_robin_picker, random_picker, picker) = route_pickers(router_mode);
603
604        Ok(PushRouter {
605            client,
606            addressed,
607            router_mode,
608            picker,
609            round_robin_picker,
610            random_picker,
611            fault_detection_enabled: false,
612            response_timeout: response_inactivity_timeout(),
613            occupancy_state,
614            multimodal_cache_indexer: None,
615            multimodal_cache_key_extractor: None,
616            _phantom: PhantomData,
617        })
618    }
619
620    /// Create a new PushRouter with an optional worker load monitor.
621    ///
622    /// The rejection path is gated by `fault_detection_enabled` (true here);
623    /// overload detection itself is driven by the monitor via `client.set_overloaded_instances(...)`.
624    /// If no thresholds are configured on the monitor (or no monitor is provided),
625    /// the routing snapshot reports at least one free instance and the gate never rejects.
626    pub async fn from_client_with_monitor(
627        client: Client,
628        router_mode: RouterMode,
629        worker_monitor: Option<Arc<dyn WorkerLoadMonitor>>,
630    ) -> anyhow::Result<Self> {
631        Self::from_client_with_state(client, router_mode, worker_monitor, None, None).await
632    }
633
634    /// Create a new PushRouter with optional load monitoring and multimodal cache indexing.
635    pub async fn from_client_with_state(
636        client: Client,
637        router_mode: RouterMode,
638        worker_monitor: Option<Arc<dyn WorkerLoadMonitor>>,
639        multimodal_cache_indexer: Option<Arc<dyn MultimodalCacheIndex>>,
640        multimodal_cache_key_extractor: Option<MultimodalCacheKeyExtractor<T>>,
641    ) -> anyhow::Result<Self> {
642        let addressed = addressed_router(&client.endpoint).await?;
643
644        // Start worker monitor if provided and in dynamic mode
645        if let Some(monitor) = worker_monitor.as_ref() {
646            monitor.start_monitoring().await?;
647        }
648
649        let occupancy_state = if router_mode.requires_occupancy() {
650            Some(get_or_create_routing_occupancy_state(&client.endpoint).await)
651        } else {
652            None
653        };
654
655        // Type-erase to the seam so discovery-removal cleanup runs through it.
656        let addressed: Arc<dyn StreamingDispatch<T, U>> = addressed;
657        spawn_instance_removal_watcher(
658            client.endpoint.clone(),
659            addressed.clone(),
660            client.endpoint.drt().primary_token(),
661        );
662
663        // Drop stale cache-index entries when workers leave discovery.
664        if let Some(indexer) = multimodal_cache_indexer.clone() {
665            spawn_multimodal_cache_cleanup_watcher(
666                client.endpoint.clone(),
667                indexer,
668                client.endpoint.drt().primary_token(),
669            );
670        }
671        let (round_robin_picker, random_picker, picker) = route_pickers(router_mode);
672
673        let router = PushRouter {
674            client,
675            addressed,
676            router_mode,
677            picker,
678            round_robin_picker,
679            random_picker,
680            fault_detection_enabled: true,
681            response_timeout: response_inactivity_timeout(),
682            occupancy_state,
683            multimodal_cache_indexer,
684            multimodal_cache_key_extractor,
685            _phantom: PhantomData,
686        };
687
688        Ok(router)
689    }
690
691    /// Like the other constructors but with a caller-supplied [`StreamingDispatch`]
692    /// as the final hop. Fault detection is on, so the dispatch's `ErrorType`
693    /// mapping drives report-down / overload / migration as usual.
694    ///
695    /// Wires frontend-local occupancy only — no `WorkerLoadMonitor` and no
696    /// multimodal cache indexer, so `RouterMode::DeviceAwareWeighted` is
697    /// non-functional; a caller needing those must extend it.
698    pub async fn from_client_with_dispatch(
699        client: Client,
700        router_mode: RouterMode,
701        dispatch: Arc<dyn StreamingDispatch<T, U>>,
702    ) -> anyhow::Result<Self> {
703        let occupancy_state = if router_mode.requires_occupancy() {
704            Some(get_or_create_routing_occupancy_state(&client.endpoint).await)
705        } else {
706            None
707        };
708
709        spawn_instance_removal_watcher(
710            client.endpoint.clone(),
711            dispatch.clone(),
712            client.endpoint.drt().primary_token(),
713        );
714        let (round_robin_picker, random_picker, picker) = route_pickers(router_mode);
715
716        Ok(PushRouter {
717            client,
718            addressed: dispatch,
719            router_mode,
720            picker,
721            round_robin_picker,
722            random_picker,
723            fault_detection_enabled: true,
724            response_timeout: response_inactivity_timeout(),
725            occupancy_state,
726            multimodal_cache_indexer: None,
727            multimodal_cache_key_extractor: None,
728            _phantom: PhantomData,
729        })
730    }
731
732    /// `ResourceExhausted` when workers are routable but all overloaded;
733    /// `Unavailable` when no routable workers exist.
734    fn empty_free_pool_error(&self, routing_instances: &RoutingInstances) -> anyhow::Error {
735        if !routing_instances.routable_ids().is_empty() {
736            let cause = PipelineError::ServiceOverloaded(
737                "All workers are busy, please retry later".to_string(),
738            );
739            return DynamoError::builder()
740                .error_type(ErrorType::ResourceExhausted)
741                .message("All workers are busy, please retry later")
742                .cause(cause)
743                .build()
744                .into();
745        }
746        DynamoError::builder()
747            .error_type(ErrorType::Unavailable)
748            .message(format!(
749                "No workers available for endpoint {}",
750                self.client.endpoint.id()
751            ))
752            .build()
753            .into()
754    }
755
756    fn picker(&self) -> anyhow::Result<&RoutePicker> {
757        self.picker.as_deref().ok_or_else(|| {
758            anyhow::anyhow!(
759                "{:?} routing does not use a worker picker",
760                self.router_mode
761            )
762        })
763    }
764
765    fn select_untracked_worker(&self, picker: &RoutePicker) -> anyhow::Result<(u64, usize)> {
766        let routing_instances = self.client.routing_instances();
767        let candidates = routing_instances.free_ids();
768        let decision = picker
769            .select(
770                CandidateView::Workers(candidates),
771                RouteContext::default(),
772                |_| 0,
773            )
774            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
775        Ok((decision.target.worker_id, candidates.len()))
776    }
777
778    /// Snapshot workers currently eligible for a new routing decision.
779    ///
780    /// Selection stays outside `PushRouter`; this is the discovery/admission
781    /// boundary used by routing hosts that own their policy lifecycle.
782    pub fn selectable_worker_ids(&self) -> anyhow::Result<Vec<u64>> {
783        self.with_selectable_worker_ids(<[u64]>::to_vec)
784    }
785
786    /// Borrow workers currently eligible for a new routing decision.
787    pub fn with_selectable_worker_ids<R>(
788        &self,
789        select: impl FnOnce(&[u64]) -> R,
790    ) -> anyhow::Result<R> {
791        let routing_instances = self.client.routing_instances();
792        if routing_instances.free_ids().is_empty() {
793            return Err(self.empty_free_pool_error(&routing_instances));
794        }
795        Ok(select(routing_instances.free_ids()))
796    }
797
798    /// Shared O(1) occupancy capability for load-aware routing hosts.
799    pub fn routing_occupancy_state(&self) -> Option<Arc<RoutingOccupancyState>> {
800        self.occupancy_state.clone()
801    }
802
803    /// Select a DeviceAware worker while leaving request-lifecycle cleanup to
804    /// the routing host. Request and device metadata is prepared before the
805    /// occupancy admission lock is acquired.
806    pub fn select_device_aware_and_reserve(
807        &self,
808        request: &T,
809        pinned_worker: Option<u64>,
810    ) -> anyhow::Result<DeviceAwareSelection> {
811        anyhow::ensure!(
812            self.router_mode == RouterMode::DeviceAwareWeighted,
813            "{:?} routing is not DeviceAwareWeighted",
814            self.router_mode
815        );
816
817        let state = self.occupancy_state()?;
818        if let Some(worker_id) = pinned_worker {
819            self.ensure_routable(worker_id)?;
820            let selection = self.device_aware_candidates(request, &[worker_id]);
821            let is_cpu = selection
822                .candidates
823                .first()
824                .is_some_and(|candidate| candidate.device == RouteDevice::Cpu);
825            let reservation = state.reserve(worker_id);
826            let load = reservation.load();
827            return Ok(DeviceAwareSelection {
828                worker_id,
829                candidate_count: 1,
830                load,
831                is_cpu,
832                embedding_cache_hit: selection.embedding_cache_hit,
833                request_cache_keys: selection.request_cache_keys,
834                reservation: Some(reservation),
835            });
836        }
837
838        let routing_instances = self.client.routing_instances();
839        let instance_ids = routing_instances.free_ids();
840        if instance_ids.is_empty() {
841            return Err(self.empty_free_pool_error(&routing_instances));
842        }
843        let selection = self.device_aware_candidates(request, instance_ids);
844        let (decision, counter) = state
845            .select_and_admit(
846                self.picker()?,
847                CandidateView::DeviceAware(&selection.candidates),
848                selection.context,
849            )
850            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
851        let worker_id = decision.target.worker_id;
852        let is_cpu = selection.candidates.iter().any(|candidate| {
853            candidate.target.worker_id == worker_id && candidate.device == RouteDevice::Cpu
854        });
855        let reservation = counter
856            .map(|counter| OccupancyReservation::from_counter(state.clone(), worker_id, counter));
857
858        Ok(DeviceAwareSelection {
859            worker_id,
860            candidate_count: selection.candidates.len(),
861            load: state.load(worker_id),
862            is_cpu,
863            embedding_cache_hit: selection.embedding_cache_hit,
864            request_cache_keys: selection.request_cache_keys,
865            reservation,
866        })
867    }
868
869    /// Reject an exact target that local fault detection has removed from routing.
870    pub fn ensure_routable(&self, instance_id: u64) -> anyhow::Result<()> {
871        if self
872            .client
873            .routing_instances()
874            .routable_ids()
875            .contains(&instance_id)
876        {
877            return Ok(());
878        }
879        anyhow::bail!(
880            "instance_id={instance_id} not found for endpoint {}",
881            self.client.endpoint.id()
882        )
883    }
884
885    fn ensure_discovered_for_dispatch(&self, instance_id: u64) -> anyhow::Result<()> {
886        if self.client.instance_ids().contains(&instance_id) {
887            return Ok(());
888        }
889        Err(DynamoError::builder()
890            .error_type(ErrorType::CannotConnect)
891            .message(format!(
892                "instance_id={instance_id} not found for endpoint {}",
893                self.client.endpoint.id()
894            ))
895            .build()
896            .into())
897    }
898
899    /// Issue a request to the next available instance in a round-robin fashion
900    pub async fn round_robin(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
901        self.round_robin_prepared(request, |_, _| Ok(()))
902            .await
903            .map(|(_, stream)| stream)
904    }
905
906    async fn round_robin_prepared<M, F>(
907        &self,
908        request: SingleIn<T>,
909        prepare: F,
910    ) -> anyhow::Result<(M, ManyOut<U>)>
911    where
912        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
913    {
914        let (instance_id, candidate_count) =
915            self.select_untracked_worker(self.round_robin_picker.as_ref())?;
916        tracing::info!(
917            router_mode = "round-robin",
918            worker_id = instance_id,
919            candidate_count,
920            "Selected worker"
921        );
922
923        self.dispatch_selected(instance_id, request, None, prepare)
924            .await
925    }
926
927    /// Issue a request to a random endpoint
928    pub async fn random(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
929        self.random_prepared(request, |_, _| Ok(()))
930            .await
931            .map(|(_, stream)| stream)
932    }
933
934    async fn random_prepared<M, F>(
935        &self,
936        request: SingleIn<T>,
937        prepare: F,
938    ) -> anyhow::Result<(M, ManyOut<U>)>
939    where
940        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
941    {
942        let (instance_id, candidate_count) =
943            self.select_untracked_worker(self.random_picker.as_ref())?;
944        tracing::info!(
945            router_mode = "random",
946            worker_id = instance_id,
947            candidate_count,
948            "Selected worker"
949        );
950
951        self.dispatch_selected(instance_id, request, None, prepare)
952            .await
953    }
954
955    /// Issue a request using power-of-two-choices: pick 2 random healthy workers,
956    /// route to the one with fewer in-flight requests.
957    pub async fn power_of_two_choices(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
958        self.power_of_two_choices_prepared(request, |_, _| Ok(()))
959            .await
960            .map(|(_, stream)| stream)
961    }
962
963    async fn power_of_two_choices_prepared<M, F>(
964        &self,
965        request: SingleIn<T>,
966        prepare: F,
967    ) -> anyhow::Result<(M, ManyOut<U>)>
968    where
969        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
970    {
971        let state = self.occupancy_state()?;
972        let (instance_id, counter, candidate_count) = {
973            let routing_instances = self.client.routing_instances();
974            let candidates = routing_instances.free_ids();
975            let (decision, counter) = state
976                .select_and_admit(
977                    self.picker()?,
978                    CandidateView::Workers(candidates),
979                    RouteContext::default(),
980                )
981                .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
982            (
983                decision.target.worker_id,
984                counter.expect("P2C selection always requests occupancy admission"),
985                candidates.len(),
986            )
987        };
988        tracing::info!(
989            router_mode = "power-of-two-choices",
990            worker_id = instance_id,
991            candidate_count,
992            load = state.load(instance_id),
993            "Selected worker"
994        );
995        let permit = OccupancyPermit::from_counter(state, instance_id, counter);
996        self.dispatch_selected(instance_id, request, Some(permit), prepare)
997            .await
998    }
999
1000    /// Issue a request to exactly one endpoint without transport fallback.
1001    pub async fn direct(
1002        &self,
1003        request: SingleIn<T>,
1004        instance_id: u64,
1005    ) -> anyhow::Result<ManyOut<U>> {
1006        tracing::info!(
1007            router_mode = "direct",
1008            worker_id = instance_id,
1009            "Selected worker"
1010        );
1011        self.generate_with_fault_detection(instance_id, request, TransportFallback::Deny)
1012            .await
1013    }
1014
1015    /// Dispatch to a selected endpoint with transport fallback.
1016    ///
1017    /// Unlike [`Self::direct`], if the selected instance disappears between selection and
1018    /// dispatch, this method may reselect another worker. When `allowed_fallback` is `Some`,
1019    /// reselection is constrained to that set; callers that pre-narrowed the candidates (e.g.
1020    /// LoRA replica-set filtering) use it to prevent fallback to an arbitrary worker.
1021    pub async fn direct_within(
1022        &self,
1023        request: SingleIn<T>,
1024        instance_id: u64,
1025        allowed_fallback: Option<&HashSet<u64>>,
1026    ) -> anyhow::Result<ManyOut<U>> {
1027        self.direct_within_prepared(request, instance_id, allowed_fallback, |_, _| Ok(()))
1028            .await
1029            .map(|(_, stream)| stream)
1030    }
1031
1032    /// Like [`Self::direct_within`], but prepares the request after transport resolution and
1033    /// returns the preparation metadata alongside the response stream.
1034    pub async fn direct_within_prepared<M, F>(
1035        &self,
1036        request: SingleIn<T>,
1037        instance_id: u64,
1038        allowed_fallback: Option<&HashSet<u64>>,
1039        prepare: F,
1040    ) -> anyhow::Result<(M, ManyOut<U>)>
1041    where
1042        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1043    {
1044        let fallback = allowed_fallback
1045            .map(TransportFallback::Within)
1046            .unwrap_or(TransportFallback::Allow);
1047        self.direct_prepared_with_fallback(instance_id, request, fallback, prepare)
1048            .await
1049    }
1050
1051    /// Dispatch a worker already selected by a routing host.
1052    ///
1053    /// `PushRouter` may still resolve transport fallback if the selected worker
1054    /// disappears before dispatch. The callback receives that final worker so
1055    /// the caller can retarget its own reservation and telemetry.
1056    pub async fn dispatch_preselected_prepared<M, F>(
1057        &self,
1058        request: SingleIn<T>,
1059        instance_id: u64,
1060        prepare: F,
1061    ) -> anyhow::Result<(M, ManyOut<U>)>
1062    where
1063        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1064    {
1065        self.dispatch_preselected_with_fallback(
1066            instance_id,
1067            request,
1068            TransportFallback::Allow,
1069            prepare,
1070        )
1071        .await
1072    }
1073
1074    async fn direct_prepared_with_fallback<M, F>(
1075        &self,
1076        instance_id: u64,
1077        request: SingleIn<T>,
1078        fallback: TransportFallback<'_>,
1079        prepare: F,
1080    ) -> anyhow::Result<(M, ManyOut<U>)>
1081    where
1082        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1083    {
1084        // Fallback resolution owns the unavailable-target decision. Allowing it
1085        // to see a worker already absent from discovery preserves Direct's
1086        // historical standalone fallback; `Deny` remains exact-target behavior.
1087        let selected_is_discovered = self
1088            .client
1089            .instances()
1090            .iter()
1091            .any(|instance| instance.instance_id == instance_id);
1092        if !selected_is_discovered {
1093            let fallback_is_available = self
1094                .client
1095                .routing_instances()
1096                .free_ids()
1097                .iter()
1098                .copied()
1099                .any(|candidate| {
1100                    candidate != instance_id
1101                        && match fallback {
1102                            TransportFallback::Allow => true,
1103                            TransportFallback::Deny => false,
1104                            TransportFallback::Within(allowed) => allowed.contains(&candidate),
1105                        }
1106                });
1107            if !fallback_is_available {
1108                self.ensure_discovered_for_dispatch(instance_id)?;
1109            }
1110        }
1111        let ((metadata, resolved_instance_id), response_stream) = self
1112            .generate_with_fault_detection_prepared(
1113                instance_id,
1114                request,
1115                fallback,
1116                |request, resolved_instance_id| {
1117                    prepare(request, resolved_instance_id)
1118                        .map(|metadata| (metadata, resolved_instance_id))
1119                },
1120            )
1121            .await?;
1122        tracing::info!(
1123            router_mode = self.router_mode.telemetry_label(),
1124            worker_id = resolved_instance_id,
1125            "Selected worker"
1126        );
1127        Ok((metadata, response_stream))
1128    }
1129
1130    async fn dispatch_preselected_with_fallback<M, F>(
1131        &self,
1132        instance_id: u64,
1133        request: SingleIn<T>,
1134        fallback: TransportFallback<'_>,
1135        prepare: F,
1136    ) -> anyhow::Result<(M, ManyOut<U>)>
1137    where
1138        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1139    {
1140        // Fallback-enabled dispatch still honors a selected worker while it remains in
1141        // discovery. Local inhibition only filters worker selection owned by this router;
1142        // fallback is considered only if the selected worker disappears after this check.
1143        self.ensure_discovered_for_dispatch(instance_id)?;
1144
1145        self.generate_with_fault_detection_prepared(instance_id, request, fallback, prepare)
1146            .await
1147    }
1148
1149    /// Dispatch to exactly one worker without transport fallback.
1150    ///
1151    /// The worker is revalidated against the latest discovery and overload
1152    /// state immediately before dispatch.
1153    pub async fn dispatch_exact(
1154        &self,
1155        request: SingleIn<T>,
1156        instance_id: u64,
1157    ) -> anyhow::Result<ManyOut<U>> {
1158        self.generate_with_fault_detection(instance_id, request, TransportFallback::Deny)
1159            .await
1160    }
1161
1162    /// Dispatch exactly to a worker whose KV selection step already performed
1163    /// overload admission.
1164    ///
1165    /// Discovery and fault detection are still enforced. The shared client
1166    /// overload state is not rechecked because admission may synchronously
1167    /// publish this request's own load before dispatch begins.
1168    pub async fn dispatch_kv_admitted(
1169        &self,
1170        request: SingleIn<T>,
1171        instance_id: u64,
1172    ) -> anyhow::Result<ManyOut<U>> {
1173        if !self.router_mode.is_kv_routing() {
1174            anyhow::bail!("admitted dispatch is only valid in KV routing mode");
1175        }
1176        self.generate_with_fault_detection_inner(
1177            instance_id,
1178            request,
1179            TransportFallback::Deny,
1180            OverloadCheck::AlreadyAdmitted,
1181        )
1182        .await
1183    }
1184
1185    /// Select and book one worker, prepare the request for that exact worker,
1186    /// then dispatch without reselection or transport fallback.
1187    pub async fn select_and_dispatch_exact<M, F>(
1188        &self,
1189        mut request: SingleIn<T>,
1190        pinned_worker: Option<u64>,
1191        prepare: F,
1192    ) -> anyhow::Result<(M, ManyOut<U>)>
1193    where
1194        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1195    {
1196        let (instance_id, permit) = self
1197            .select_exact_target(request.content(), pinned_worker)
1198            .await?;
1199        let metadata = prepare(&mut request, instance_id)?;
1200        let stream = self.dispatch_exact(request, instance_id).await?;
1201        let stream = match permit {
1202            Some(permit) => permit.into_tracked_stream(stream),
1203            None => stream,
1204        };
1205        Ok((metadata, stream))
1206    }
1207
1208    /// Select a worker using the configured routing mode, prepare the request with the worker
1209    /// that survives transport resolution, then dispatch with normal fallback behavior.
1210    pub async fn select_and_dispatch<M, F>(
1211        &self,
1212        request: SingleIn<T>,
1213        prepare: F,
1214    ) -> anyhow::Result<(M, ManyOut<U>)>
1215    where
1216        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1217    {
1218        match self.router_mode {
1219            RouterMode::Random => self.random_prepared(request, prepare).await,
1220            RouterMode::RoundRobin => self.round_robin_prepared(request, prepare).await,
1221            RouterMode::PowerOfTwoChoices => {
1222                self.power_of_two_choices_prepared(request, prepare).await
1223            }
1224            RouterMode::LeastLoaded => self.least_loaded_prepared(request, prepare).await,
1225            RouterMode::DeviceAwareWeighted => {
1226                self.device_aware_weighted_prepared(request, prepare).await
1227            }
1228            RouterMode::KV => anyhow::bail!("KV routing should not call select_and_dispatch"),
1229            RouterMode::Direct => anyhow::bail!(
1230                "Direct routing should use direct_within_prepared instead of select_and_dispatch"
1231            ),
1232        }
1233    }
1234
1235    async fn dispatch_selected<M, F>(
1236        &self,
1237        instance_id: u64,
1238        request: SingleIn<T>,
1239        mut permit: Option<OccupancyPermit>,
1240        prepare: F,
1241    ) -> anyhow::Result<(M, ManyOut<U>)>
1242    where
1243        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1244    {
1245        let (metadata, stream) = self
1246            .generate_with_fault_detection_prepared(
1247                instance_id,
1248                request,
1249                TransportFallback::Allow,
1250                |request, resolved_instance_id| {
1251                    if let Some(permit) = permit.as_mut() {
1252                        permit.retarget(resolved_instance_id);
1253                    }
1254                    prepare(request, resolved_instance_id)
1255                },
1256            )
1257            .await?;
1258        let stream = match permit {
1259            Some(permit) => permit.into_tracked_stream(stream),
1260            None => stream,
1261        };
1262        Ok((metadata, stream))
1263    }
1264
1265    /// Issue a request using device-aware weighted routing.
1266    ///
1267    /// Instances are partitioned by device type (CPU vs non-CPU), then the router
1268    /// applies a budget policy and selects the least-loaded instance within the
1269    /// chosen group.
1270    ///
1271    /// If only one device class exists (all CPU or all non-CPU), this naturally
1272    /// degenerates to least-loaded routing over the available instances.
1273    pub async fn device_aware_weighted(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1274        self.device_aware_weighted_prepared(request, |_, _| Ok(()))
1275            .await
1276            .map(|(_, stream)| stream)
1277    }
1278
1279    async fn device_aware_weighted_prepared<M, F>(
1280        &self,
1281        request: SingleIn<T>,
1282        prepare: F,
1283    ) -> anyhow::Result<(M, ManyOut<U>)>
1284    where
1285        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1286    {
1287        let state = self.occupancy_state()?;
1288        let routing_instances = self.client.routing_instances();
1289        let instance_ids = routing_instances.free_ids();
1290
1291        if instance_ids.is_empty() {
1292            return Err(self.empty_free_pool_error(&routing_instances));
1293        }
1294
1295        // Apply a unified policy for all endpoints.
1296        let endpoint_id = self.client.endpoint.id();
1297
1298        let selection = self.device_aware_candidates(request.content(), instance_ids);
1299
1300        // Only full cache hits bypass weighted accounting; partial hits still follow the
1301        // device-aware ratio because some image encoding remains for this request.
1302        let (decision, counter) = state
1303            .select_and_admit(
1304                self.picker()?,
1305                CandidateView::DeviceAware(&selection.candidates),
1306                selection.context,
1307            )
1308            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1309        let instance_id = decision.target.worker_id;
1310        let permit = counter
1311            .map(|counter| OccupancyPermit::from_counter(state.clone(), instance_id, counter));
1312        let is_cpu = selection.candidates.iter().any(|candidate| {
1313            candidate.target.worker_id == instance_id && candidate.device == RouteDevice::Cpu
1314        });
1315        tracing::info!(
1316            router_mode = "device-aware-weighted",
1317            worker_id = instance_id,
1318            candidate_count = selection.candidates.len(),
1319            load = state.load(instance_id),
1320            endpoint = %endpoint_id,
1321            is_cpu,
1322            embedding_cache_hit = selection.embedding_cache_hit,
1323            request_cache_keys = selection.request_cache_keys,
1324            "Selected worker"
1325        );
1326
1327        self.dispatch_selected(instance_id, request, permit, prepare)
1328            .await
1329    }
1330
1331    fn device_aware_candidates(&self, request: &T, instance_ids: &[u64]) -> DeviceAwareCandidates {
1332        let device_type_map = self
1333            .client
1334            .instances()
1335            .iter()
1336            .map(|instance| {
1337                let device = if matches!(instance.device_type, Some(DeviceType::Cpu)) {
1338                    RouteDevice::Cpu
1339                } else {
1340                    RouteDevice::Accelerator
1341                };
1342                (instance.instance_id, device)
1343            })
1344            .collect::<HashMap<_, _>>();
1345        let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1346            .ok()
1347            .and_then(|value| value.parse::<usize>().ok())
1348            .filter(|value| *value >= 1)
1349            .unwrap_or(8);
1350
1351        let (request_cache_keys, cache_matched_candidates) =
1352            if let (Some(indexer), Some(extractor)) = (
1353                self.multimodal_cache_indexer.as_ref(),
1354                self.multimodal_cache_key_extractor.as_ref(),
1355            ) {
1356                let request_cache_keys = extractor(request);
1357                let matched = if request_cache_keys.is_empty() {
1358                    Vec::new()
1359                } else {
1360                    let mut matched = indexer.workers_with_cache_key_hits(&request_cache_keys);
1361                    matched.retain(|(id, _)| instance_ids.contains(id));
1362                    matched
1363                };
1364                (request_cache_keys, matched)
1365            } else {
1366                (Vec::new(), Vec::new())
1367            };
1368
1369        let embedding_cache_hit = !cache_matched_candidates.is_empty();
1370        let cache_hits = cache_matched_candidates
1371            .into_iter()
1372            .collect::<HashMap<_, _>>();
1373        let request_cache_key_count = request_cache_keys
1374            .iter()
1375            .collect::<std::collections::HashSet<_>>()
1376            .len();
1377        let candidates = instance_ids
1378            .iter()
1379            .map(|worker_id| RouteCandidate {
1380                target: RouteTarget::worker(*worker_id),
1381                device: device_type_map.get(worker_id).copied().unwrap_or_default(),
1382                cache_hits: cache_hits.get(worker_id).copied().unwrap_or_default(),
1383            })
1384            .collect::<Vec<_>>();
1385
1386        DeviceAwareCandidates {
1387            candidates,
1388            context: RouteContext {
1389                required_cache_hits: request_cache_key_count,
1390                non_cpu_to_cpu_ratio: cuda_to_cpu_ratio,
1391            },
1392            embedding_cache_hit,
1393            request_cache_keys: request_cache_keys.len(),
1394        }
1395    }
1396
1397    /// Issue a request to the instance with the fewest active connections.
1398    pub async fn least_loaded(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1399        self.least_loaded_prepared(request, |_, _| Ok(()))
1400            .await
1401            .map(|(_, stream)| stream)
1402    }
1403
1404    async fn least_loaded_prepared<M, F>(
1405        &self,
1406        request: SingleIn<T>,
1407        prepare: F,
1408    ) -> anyhow::Result<(M, ManyOut<U>)>
1409    where
1410        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1411    {
1412        let state = self.occupancy_state()?;
1413        let routing_instances = self.client.routing_instances();
1414        let instance_ids = routing_instances.free_ids();
1415        let (decision, counter) = state
1416            .select_and_admit(
1417                self.picker()?,
1418                CandidateView::Workers(instance_ids),
1419                RouteContext::default(),
1420            )
1421            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?;
1422        let instance_id = decision.target.worker_id;
1423        let permit = OccupancyPermit::from_counter(
1424            state.clone(),
1425            instance_id,
1426            counter.expect("least-loaded selection always requests occupancy admission"),
1427        );
1428        tracing::info!(
1429            router_mode = "least-loaded",
1430            worker_id = instance_id,
1431            candidate_count = instance_ids.len(),
1432            load = state.load(instance_id),
1433            "Selected worker"
1434        );
1435
1436        self.dispatch_selected(instance_id, request, Some(permit), prepare)
1437            .await
1438    }
1439
1440    /// Select the next worker according to the routing mode.
1441    /// Increments round-robin counter if applicable.
1442    /// Returns None for modes that require request lifecycle tracking or explicit routing hints.
1443    pub fn select_next_worker(&self) -> Option<u64> {
1444        let routing_instances = self.client.routing_instances();
1445        match self.router_mode {
1446            RouterMode::RoundRobin | RouterMode::Random => self
1447                .picker
1448                .as_deref()?
1449                .select(
1450                    CandidateView::Workers(routing_instances.free_ids()),
1451                    RouteContext::default(),
1452                    |_| 0,
1453                )
1454                .map(|decision| decision.target.worker_id),
1455            RouterMode::PowerOfTwoChoices
1456            | RouterMode::Direct
1457            | RouterMode::LeastLoaded
1458            | RouterMode::DeviceAwareWeighted => None,
1459            RouterMode::KV => {
1460                panic!(
1461                    "select_next_worker should not be called for {:?} routing mode",
1462                    self.router_mode
1463                )
1464            }
1465        }
1466    }
1467
1468    /// Peek the next worker according to the routing mode without incrementing the counter.
1469    /// Useful for checking if a worker is suitable before committing to it.
1470    ///
1471    /// `None` for [`RouterMode::Direct`] (caller-supplied routing); panics for
1472    /// [`RouterMode::KV`], which selects via `kv_chooser::find_best_match`.
1473    pub fn peek_next_worker(&self) -> Option<u64> {
1474        // Select among free (admission-eligible) workers — see select_next_worker
1475        // for the per-mode selection rationale.
1476        let routing_instances = self.client.routing_instances();
1477        let instance_ids = routing_instances.free_ids();
1478        if instance_ids.is_empty() {
1479            return None;
1480        }
1481
1482        match self.router_mode {
1483            RouterMode::RoundRobin | RouterMode::Random => self
1484                .picker
1485                .as_deref()?
1486                .peek(
1487                    CandidateView::Workers(instance_ids),
1488                    RouteContext::default(),
1489                    |_| 0,
1490                )
1491                .map(|decision| decision.target.worker_id),
1492            RouterMode::LeastLoaded | RouterMode::PowerOfTwoChoices => self
1493                .occupancy_state
1494                .as_deref()?
1495                .peek(
1496                    self.picker.as_deref()?,
1497                    CandidateView::Workers(instance_ids),
1498                    RouteContext::default(),
1499                )
1500                .map(|decision| decision.target.worker_id),
1501            RouterMode::DeviceAwareWeighted => {
1502                let state = self.occupancy_state.as_deref()?;
1503                let device_type_map: HashMap<u64, Option<DeviceType>> = self
1504                    .client
1505                    .instances()
1506                    .iter()
1507                    .map(|instance| (instance.instance_id, instance.device_type.clone()))
1508                    .collect();
1509                let cuda_to_cpu_ratio = std::env::var("DYN_ENCODER_CUDA_TO_CPU_RATIO")
1510                    .ok()
1511                    .and_then(|value| value.parse::<usize>().ok())
1512                    .filter(|value| *value >= 1)
1513                    .unwrap_or(8);
1514                let candidates = instance_ids
1515                    .iter()
1516                    .map(|worker_id| RouteCandidate {
1517                        target: RouteTarget::worker(*worker_id),
1518                        device: if matches!(
1519                            device_type_map.get(worker_id),
1520                            Some(Some(DeviceType::Cpu))
1521                        ) {
1522                            RouteDevice::Cpu
1523                        } else {
1524                            RouteDevice::Accelerator
1525                        },
1526                        cache_hits: 0,
1527                    })
1528                    .collect::<Vec<_>>();
1529                state
1530                    .peek(
1531                        self.picker.as_deref()?,
1532                        CandidateView::DeviceAware(&candidates),
1533                        RouteContext {
1534                            required_cache_hits: 0,
1535                            non_cpu_to_cpu_ratio: cuda_to_cpu_ratio,
1536                        },
1537                    )
1538                    .map(|decision| decision.target.worker_id)
1539            }
1540            RouterMode::Direct => None,
1541            RouterMode::KV => {
1542                panic!(
1543                    "peek_next_worker should not be called for {:?} routing mode",
1544                    self.router_mode
1545                )
1546            }
1547        }
1548    }
1549
1550    #[cfg(any(test, feature = "testing"))]
1551    #[doc(hidden)]
1552    pub fn occupancy_for_test(&self, worker_id: u64) -> u64 {
1553        self.occupancy_state
1554            .as_deref()
1555            .map(|state| state.load(worker_id))
1556            .unwrap_or(0)
1557    }
1558
1559    async fn select_exact_target(
1560        &self,
1561        request: &T,
1562        pinned_worker: Option<u64>,
1563    ) -> anyhow::Result<(u64, Option<OccupancyPermit>)> {
1564        if let Some(instance_id) = pinned_worker {
1565            let routing_instances = self.client.routing_instances();
1566            if !routing_instances.routable_ids().contains(&instance_id) {
1567                return Err(anyhow::anyhow!(
1568                    "instance_id={instance_id} not found for endpoint {}",
1569                    self.client.endpoint.id()
1570                ));
1571            }
1572            let permit = match self.router_mode {
1573                RouterMode::LeastLoaded
1574                | RouterMode::PowerOfTwoChoices
1575                | RouterMode::DeviceAwareWeighted => {
1576                    let state = self.occupancy_state()?;
1577                    Some(OccupancyPermit::acquire(state, instance_id))
1578                }
1579                RouterMode::RoundRobin
1580                | RouterMode::Random
1581                | RouterMode::Direct
1582                | RouterMode::KV => None,
1583            };
1584            return Ok((instance_id, permit));
1585        }
1586
1587        match self.router_mode {
1588            RouterMode::LeastLoaded
1589            | RouterMode::PowerOfTwoChoices
1590            | RouterMode::DeviceAwareWeighted => {
1591                let state = self.occupancy_state()?;
1592                let routing_instances = self.client.routing_instances();
1593                let instance_ids = routing_instances.free_ids();
1594                if instance_ids.is_empty() {
1595                    return Err(self.empty_free_pool_error(&routing_instances));
1596                }
1597
1598                let (decision, counter) = match self.router_mode {
1599                    RouterMode::LeastLoaded | RouterMode::PowerOfTwoChoices => state
1600                        .select_and_admit(
1601                            self.picker()?,
1602                            CandidateView::Workers(instance_ids),
1603                            RouteContext::default(),
1604                        )
1605                        .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?,
1606                    RouterMode::DeviceAwareWeighted => {
1607                        let selection = self.device_aware_candidates(request, instance_ids);
1608                        state
1609                            .select_and_admit(
1610                                self.picker()?,
1611                                CandidateView::DeviceAware(&selection.candidates),
1612                                selection.context,
1613                            )
1614                            .ok_or_else(|| self.empty_free_pool_error(&routing_instances))?
1615                    }
1616                    _ => unreachable!(),
1617                };
1618                let instance_id = decision.target.worker_id;
1619                let permit = counter
1620                    .map(|counter| OccupancyPermit::from_counter(state, instance_id, counter));
1621                Ok((instance_id, permit))
1622            }
1623            RouterMode::RoundRobin => self
1624                .select_untracked_worker(self.round_robin_picker.as_ref())
1625                .map(|(instance_id, _)| (instance_id, None)),
1626            RouterMode::Random => self
1627                .select_untracked_worker(self.random_picker.as_ref())
1628                .map(|(instance_id, _)| (instance_id, None)),
1629            RouterMode::Direct => Err(anyhow::anyhow!(
1630                "Worker ID required for exact dispatch in Direct routing mode"
1631            )),
1632            RouterMode::KV => Err(anyhow::anyhow!(
1633                "select_and_dispatch_exact cannot select workers in KV routing mode"
1634            )),
1635        }
1636    }
1637
1638    fn occupancy_state(&self) -> anyhow::Result<Arc<RoutingOccupancyState>> {
1639        self.occupancy_state.clone().ok_or_else(|| {
1640            anyhow::anyhow!(
1641                "routing occupancy state not initialized for endpoint {}",
1642                self.client.endpoint.id()
1643            )
1644        })
1645    }
1646
1647    /*
1648    pub async fn r#static(&self, request: SingleIn<T>) -> anyhow::Result<ManyOut<U>> {
1649        let subject = self.client.endpoint.subject();
1650        tracing::debug!("static got subject: {subject}");
1651        let request = request.map(|req| AddressedRequest::new(req, subject));
1652        tracing::debug!("router generate");
1653        self.addressed.generate(request).await
1654    }
1655    */
1656
1657    async fn generate_with_fault_detection(
1658        &self,
1659        instance_id: u64,
1660        request: SingleIn<T>,
1661        fallback: TransportFallback<'_>,
1662    ) -> anyhow::Result<ManyOut<U>> {
1663        self.generate_with_fault_detection_inner(
1664            instance_id,
1665            request,
1666            fallback,
1667            OverloadCheck::Required,
1668        )
1669        .await
1670    }
1671
1672    async fn generate_with_fault_detection_inner(
1673        &self,
1674        instance_id: u64,
1675        request: SingleIn<T>,
1676        fallback: TransportFallback<'_>,
1677        overload_check: OverloadCheck,
1678    ) -> anyhow::Result<ManyOut<U>> {
1679        self.generate_with_fault_detection_prepared_inner(
1680            instance_id,
1681            request,
1682            fallback,
1683            overload_check,
1684            |_, _| Ok(()),
1685        )
1686        .await
1687        .map(|(_, stream)| stream)
1688    }
1689
1690    async fn generate_with_fault_detection_prepared<M, F>(
1691        &self,
1692        instance_id: u64,
1693        request: SingleIn<T>,
1694        fallback: TransportFallback<'_>,
1695        prepare: F,
1696    ) -> anyhow::Result<(M, ManyOut<U>)>
1697    where
1698        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1699    {
1700        self.generate_with_fault_detection_prepared_inner(
1701            instance_id,
1702            request,
1703            fallback,
1704            OverloadCheck::Required,
1705            prepare,
1706        )
1707        .await
1708    }
1709
1710    async fn generate_with_fault_detection_prepared_inner<M, F>(
1711        &self,
1712        instance_id: u64,
1713        mut request: SingleIn<T>,
1714        fallback: TransportFallback<'_>,
1715        overload_check: OverloadCheck,
1716        prepare: F,
1717    ) -> anyhow::Result<(M, ManyOut<U>)>
1718    where
1719        F: FnOnce(&mut T, u64) -> anyhow::Result<M>,
1720    {
1721        let route_start = Instant::now();
1722        let request_id = request.id().to_string();
1723        let route_trace_context = get_route_trace_context(&request);
1724        let route_span = if matches!(self.router_mode, RouterMode::KV) {
1725            tracing::Span::none()
1726        } else {
1727            tracing::info_span!(
1728                target: "request_span",
1729                "router.route_request",
1730                otel.kind = "client",
1731                request_id = %request_id,
1732                worker_id = tracing::field::Empty,
1733                router_mode = ?self.router_mode,
1734                "request.attempt" = tracing::field::Empty,
1735                "request.outcome" = tracing::field::Empty,
1736                "migration.is_retry" = tracing::field::Empty,
1737                "migration.reason" = tracing::field::Empty,
1738                "migration.from_worker_id" = tracing::field::Empty,
1739                "migration.tokens_completed" = tracing::field::Empty,
1740                "cancellation.signal" = tracing::field::Empty,
1741                "error.type" = tracing::field::Empty,
1742                otel.status_code = tracing::field::Empty,
1743                otel.status_description = tracing::field::Empty,
1744            )
1745        };
1746        let (instance_id, address, transport_kind, instance) = match self
1747            .resolve_transport(instance_id, fallback)
1748        {
1749            Ok(resolved) => resolved,
1750            Err(error) => {
1751                record_route_span_start(&route_span, route_trace_context.as_deref(), instance_id);
1752                record_route_error(&route_span, error.as_ref());
1753                return Err(error);
1754            }
1755        };
1756        record_route_span_start(&route_span, route_trace_context.as_deref(), instance_id);
1757        if matches!(overload_check, OverloadCheck::Required)
1758            && let Err(error) = self.check_workers_available(instance_id, &request_id)
1759        {
1760            record_route_error(&route_span, error.as_ref());
1761            return Err(error);
1762        }
1763
1764        let metadata = match prepare(&mut request, instance_id) {
1765            Ok(metadata) => metadata,
1766            Err(error) => {
1767                record_route_error(&route_span, error.as_ref());
1768                return Err(error);
1769            }
1770        };
1771        let request = request.map(|req| AddressedRequest::with_instance(req, address, instance));
1772
1773        STAGE_DURATION_SECONDS
1774            .with_label_values(&[STAGE_ROUTE])
1775            .observe(route_start.elapsed().as_secs_f64());
1776
1777        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
1778        let stream = self
1779            .addressed
1780            .generate(request)
1781            .instrument(route_span.clone())
1782            .await;
1783        let stream = self.wrap_with_fault_detection(stream, instance_id, route_span)?;
1784        Ok((metadata, stream))
1785    }
1786
1787    /// Reject early if the selected worker is overloaded and fault detection
1788    /// is enabled. The request_id is only used for the debug-level "checked
1789    /// worker overload state" trace; pass an empty string from callers that
1790    /// don't have one handy.
1791    fn check_workers_available(&self, instance_id: u64, request_id: &str) -> anyhow::Result<()> {
1792        if !self.fault_detection_enabled {
1793            return Ok(());
1794        }
1795        let routing_instances = self.client.routing_instances();
1796        let selected_worker_overloaded = routing_instances.is_overloaded(instance_id);
1797        let counts = routing_instances.counts();
1798        if tracing::enabled!(tracing::Level::DEBUG) {
1799            tracing::debug!(
1800                request_id,
1801                instance_id,
1802                router_mode = ?self.router_mode,
1803                free_workers = counts.free,
1804                overloaded_workers = counts.overloaded,
1805                total_workers = counts.discovered,
1806                selected_worker_overloaded,
1807                "checked worker overload state"
1808            );
1809        }
1810        if !selected_worker_overloaded {
1811            return Ok(());
1812        }
1813        tracing::warn!(
1814            instance_id,
1815            overloaded_workers = counts.overloaded,
1816            total_workers = counts.discovered,
1817            "Rejecting request: selected worker is overloaded"
1818        );
1819        let cause = PipelineError::ServiceOverloaded(
1820            "Selected worker is overloaded, please retry later".into(),
1821        );
1822        Err(DynamoError::builder()
1823            .error_type(ErrorType::WorkerOverloaded)
1824            .message("Selected worker is overloaded, please retry later")
1825            .cause(cause)
1826            .build()
1827            .into())
1828    }
1829
1830    /// Resolve `(instance_id, address, transport_kind_label, Instance)` for
1831    /// the selected worker. If that worker has disappeared, apply the caller's
1832    /// fallback policy. `CannotConnect` is returned when fallback is forbidden
1833    /// or when a selected fallback disappears before its transport can be
1834    /// resolved.
1835    fn resolve_transport(
1836        &self,
1837        instance_id: u64,
1838        fallback: TransportFallback<'_>,
1839    ) -> anyhow::Result<(u64, String, &'static str, Instance)> {
1840        use crate::component::TransportType;
1841
1842        let lookup = |id: u64| {
1843            self.client
1844                .instances()
1845                .iter()
1846                .find(|i| i.instance_id == id)
1847                .map(|instance| {
1848                    let (addr, kind) = match &instance.transport {
1849                        TransportType::Tcp(tcp_endpoint) => {
1850                            (tcp_endpoint.clone(), "transport.tcp.request")
1851                        }
1852                        TransportType::Nats(subject) => (subject.clone(), "transport.nats.request"),
1853                    };
1854                    (addr, kind, instance.clone())
1855                })
1856        };
1857
1858        if let Some((addr, kind, inst)) = lookup(instance_id) {
1859            return Ok((instance_id, addr, kind, inst));
1860        }
1861        let allowed_fallback = match fallback {
1862            TransportFallback::Allow => None,
1863            TransportFallback::Deny => {
1864                return Err(DynamoError::builder()
1865                    .error_type(ErrorType::CannotConnect)
1866                    .message(format!(
1867                        "instance_id={instance_id} not found for endpoint {}",
1868                        self.client.endpoint.id()
1869                    ))
1870                    .build()
1871                    .into());
1872            }
1873            TransportFallback::Within(allowed) => Some(allowed),
1874        };
1875
1876        let routing_instances = self.client.routing_instances();
1877        let fallback_id = routing_instances.free_ids().iter().copied().find(|&id| {
1878            id != instance_id && allowed_fallback.is_none_or(|allowed| allowed.contains(&id))
1879        });
1880        match fallback_id {
1881            Some(id) => {
1882                tracing::warn!(
1883                    original_instance = instance_id,
1884                    fallback_instance = id,
1885                    "Instance disappeared during routing, reselecting"
1886                );
1887                let (addr, kind, inst) = lookup(id).ok_or_else(|| {
1888                    DynamoError::builder()
1889                        .error_type(ErrorType::CannotConnect)
1890                        .message(format!(
1891                            "Fallback instance {} also not found for endpoint {}",
1892                            id,
1893                            self.client.endpoint.id()
1894                        ))
1895                        .build()
1896                })?;
1897                Ok((id, addr, kind, inst))
1898            }
1899            // The selected instance vanished from discovery and no permitted
1900            // fallback is free. Typed rather than a bare `anyhow!` so
1901            // `error_type_from_chain` (route spans) and the frontend's status
1902            // mapping see a real category instead of `unknown`/500.
1903            //
1904            // Uniformly `Unavailable`, not a pool-state split: reaching here
1905            // means *this request's* worker is gone, which is a discovery fact,
1906            // and `transport_resolution_precedes_stale_overload_check` requires
1907            // that a vanished instance is never redressed as overload. Nor
1908            // `CannotConnect`, which stays the signature of exact dispatch
1909            // (`TransportFallback::Deny`) and must remain distinguishable from a
1910            // fallback-enabled failure.
1911            None => Err(DynamoError::builder()
1912                .error_type(ErrorType::Unavailable)
1913                .message(format!(
1914                    "Instance {} not found and no other instances available for endpoint {}",
1915                    instance_id,
1916                    self.client.endpoint.id()
1917                ))
1918                .build()
1919                .into()),
1920        }
1921    }
1922
1923    /// Wrap a dispatched stream with fault detection + inactivity timeout.
1924    /// `is_inhibited` errors trigger `report_instance_down`; the timeout
1925    /// (driven by `DYN_HTTP_BACKEND_STREAM_TIMEOUT_SECS`) yields a synthetic
1926    /// `ResponseTimeout` and quarantines the worker.
1927    fn wrap_with_fault_detection(
1928        &self,
1929        stream: anyhow::Result<ManyOut<U>>,
1930        instance_id: u64,
1931        route_span: tracing::Span,
1932    ) -> anyhow::Result<ManyOut<U>> {
1933        let stream = match stream {
1934            Ok(stream) => stream,
1935            Err(err) => {
1936                record_route_error(&route_span, err.as_ref());
1937                if self.fault_detection_enabled {
1938                    if is_inhibited(err.as_ref()) {
1939                        tracing::debug!(
1940                            "Reporting instance {instance_id} down due to error: {err}"
1941                        );
1942                        self.client.report_instance_down(instance_id);
1943                    } else if match_error_chain(err.as_ref(), &[ErrorType::WorkerOverloaded], &[]) {
1944                        // Backpressure: worker said "my queue is full,
1945                        // retry later". Mark overloaded so this FE skips it on
1946                        // the next selection; the next ActiveLoad event from the
1947                        // worker monitor overwrites the overloaded set from fresh
1948                        // metrics. This is NOT report_instance_down (fault path).
1949                        tracing::debug!(
1950                            "Marking instance {instance_id} overloaded due to backpressure: {err}"
1951                        );
1952                        self.client.mark_overloaded_immediate(instance_id);
1953                    }
1954                }
1955                return Err(err);
1956            }
1957        };
1958
1959        if !self.fault_detection_enabled {
1960            return Ok(wrap_route_span(stream, route_span));
1961        }
1962
1963        let engine_ctx = stream.context();
1964        let client = self.client.clone();
1965        let client_for_timeout = self.client.clone();
1966        let stream = stream.map(move |res| {
1967            if let Some(err) = res.err()
1968                && is_inhibited(&err)
1969            {
1970                tracing::debug!(
1971                    "Reporting instance {instance_id} down due to migratable error: {err}"
1972                );
1973                client.report_instance_down(instance_id);
1974            }
1975            res
1976        });
1977
1978        let stream: Pin<Box<dyn Stream<Item = U> + Send>> =
1979            if let Some(timeout) = self.response_timeout {
1980                Box::pin(async_stream::stream! {
1981                    let mut inner = Box::pin(stream);
1982                    loop {
1983                        tokio::select! {
1984                            biased;
1985                            item = inner.next() => {
1986                                match item {
1987                                    Some(item) => yield item,
1988                                    None => break,
1989                                }
1990                            }
1991                            _ = tokio::time::sleep(timeout) => {
1992                                tracing::warn!(
1993                                    instance_id,
1994                                    timeout_secs = timeout.as_secs(),
1995                                    "backend response inactivity timeout — quarantining worker"
1996                                );
1997                                client_for_timeout.report_instance_down(instance_id);
1998                                yield U::from_err(
1999                                    crate::error::DynamoError::builder()
2000                                        .error_type(crate::error::ErrorType::ResponseTimeout)
2001                                        .message("backend response inactivity timeout")
2002                                        .build()
2003                                );
2004                                break;
2005                            }
2006                        }
2007                    }
2008                })
2009            } else {
2010                Box::pin(stream)
2011            };
2012
2013        Ok(wrap_route_span(
2014            ResponseStream::new(stream, engine_ctx),
2015            route_span,
2016        ))
2017    }
2018}
2019
2020#[async_trait]
2021impl<T, U> AsyncEngine<SingleIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
2022where
2023    T: Data + Serialize,
2024    U: Data + for<'de> Deserialize<'de> + MaybeError,
2025{
2026    async fn generate(&self, request: SingleIn<T>) -> Result<ManyOut<U>, Error> {
2027        match self.router_mode {
2028            RouterMode::Random => self.random(request).await,
2029            RouterMode::RoundRobin => self.round_robin(request).await,
2030            RouterMode::PowerOfTwoChoices => self.power_of_two_choices(request).await,
2031            RouterMode::KV => {
2032                anyhow::bail!("KV routing should not call generate on PushRouter");
2033            }
2034            RouterMode::Direct => {
2035                anyhow::bail!(
2036                    "Direct routing should not call generate on PushRouter directly; use RoutingHost"
2037                );
2038            }
2039            RouterMode::LeastLoaded => self.least_loaded(request).await,
2040            RouterMode::DeviceAwareWeighted => self.device_aware_weighted(request).await,
2041        }
2042    }
2043}
2044
2045impl<T, U> PushRouter<T, U>
2046where
2047    T: Data + Serialize,
2048    U: Data + for<'de> Deserialize<'de> + MaybeError,
2049{
2050    /// Bidirectional sibling of [`Self::generate_with_fault_detection`].
2051    async fn bidirectional_dispatch(
2052        &self,
2053        instance_id: u64,
2054        input: ManyIn<T>,
2055    ) -> anyhow::Result<ManyOut<U>> {
2056        let route_start = Instant::now();
2057        let request_id = input.context().id().to_string();
2058        let route_trace_context = get_route_trace_context(&input);
2059        let route_span = tracing::info_span!(
2060            target: "request_span",
2061            "router.route_request_bidirectional",
2062            otel.kind = "client",
2063            request_id = %request_id,
2064            worker_id = tracing::field::Empty,
2065            router_mode = ?self.router_mode,
2066            "request.attempt" = tracing::field::Empty,
2067            "request.outcome" = tracing::field::Empty,
2068            "migration.is_retry" = tracing::field::Empty,
2069            "migration.reason" = tracing::field::Empty,
2070            "migration.from_worker_id" = tracing::field::Empty,
2071            "migration.tokens_completed" = tracing::field::Empty,
2072            "cancellation.signal" = tracing::field::Empty,
2073            "error.type" = tracing::field::Empty,
2074            otel.status_code = tracing::field::Empty,
2075            otel.status_description = tracing::field::Empty,
2076        );
2077
2078        let (instance_id, address, transport_kind, instance) = match self
2079            .resolve_transport(instance_id, TransportFallback::Allow)
2080        {
2081            Ok(resolved) => resolved,
2082            Err(error) => {
2083                record_route_span_start(&route_span, route_trace_context.as_deref(), instance_id);
2084                record_route_error(&route_span, error.as_ref());
2085                return Err(error);
2086            }
2087        };
2088        record_route_span_start(&route_span, route_trace_context.as_deref(), instance_id);
2089        if let Err(error) = self.check_workers_available(instance_id, &request_id) {
2090            record_route_error(&route_span, error.as_ref());
2091            return Err(error);
2092        }
2093
2094        STAGE_DURATION_SECONDS
2095            .with_label_values(&[STAGE_ROUTE])
2096            .observe(route_start.elapsed().as_secs_f64());
2097
2098        let _nvtx_transport = dynamo_nvtx_range!(transport_kind);
2099        let stream: anyhow::Result<ManyOut<U>> = self
2100            .addressed
2101            .generate_bidirectional(instance, address, input)
2102            .instrument(route_span.clone())
2103            .await;
2104        self.wrap_with_fault_detection(stream, instance_id, route_span)
2105    }
2106}
2107
2108/// Bidirectional `AsyncEngine` impl for streaming-input workloads (e.g. the
2109/// OpenAI Realtime API). Reserves a sticky worker up front — before any
2110/// inbound frame is observed — and binds the whole input stream to that
2111/// worker. KV and Direct modes inherit the same `bail!` invariants as the
2112/// unary impl.
2113///
2114/// **Reserve-before-observe rationale.** The router-mode strategies
2115/// (`RoundRobin`, `Random`, `PowerOfTwoChoices`, `LeastLoaded`,
2116/// `DeviceAwareWeighted`) don't depend on frame contents, so selection
2117/// runs immediately and connection setup proceeds in parallel with the
2118/// client producing its first frame. A client that connects but never
2119/// sends one still releases the slot via the response-stream-drop path;
2120/// the dispatch-side `cancel_both` cleanup covers the early-bail case.
2121#[async_trait]
2122impl<T, U> AsyncEngine<ManyIn<T>, ManyOut<U>, Error> for PushRouter<T, U>
2123where
2124    T: Data + Serialize,
2125    U: Data + for<'de> Deserialize<'de> + MaybeError,
2126{
2127    async fn generate(&self, input: ManyIn<T>) -> Result<ManyOut<U>, Error> {
2128        match self.router_mode {
2129            RouterMode::KV => {
2130                anyhow::bail!("KV routing should not call generate on PushRouter");
2131            }
2132            RouterMode::Direct => {
2133                anyhow::bail!(
2134                    "Direct routing should not call generate on PushRouter directly; use RoutingHost"
2135                );
2136            }
2137            // These modes drive `select_next_worker()` to `None` — they rely on
2138            // the occupancy/load-aware selection the bidirectional path does not
2139            // wire yet, which would otherwise surface as a misleading "no
2140            // instances available" error below. Reject them explicitly until
2141            // bidirectional support lands; tracked in
2142            // https://github.com/ai-dynamo/dynamo/issues/10320.
2143            RouterMode::PowerOfTwoChoices
2144            | RouterMode::LeastLoaded
2145            | RouterMode::DeviceAwareWeighted => {
2146                anyhow::bail!(
2147                    "{:?} routing is not yet supported for bidirectional dispatch",
2148                    self.router_mode
2149                );
2150            }
2151            RouterMode::RoundRobin | RouterMode::Random => {}
2152        }
2153
2154        let instance_id = self
2155            .select_next_worker()
2156            .ok_or_else(|| anyhow::anyhow!("no instances available for bidirectional routing"))?;
2157
2158        self.bidirectional_dispatch(instance_id, input).await
2159    }
2160}
2161
2162struct OccupancyTrackedStream<U: Data + MaybeError> {
2163    inner: ManyOut<U>,
2164    reservation: Option<OccupancyReservation>,
2165}
2166
2167impl<U: Data + MaybeError> OccupancyTrackedStream<U> {
2168    fn release(&mut self) {
2169        drop(self.reservation.take());
2170    }
2171}
2172
2173impl<U: Data + MaybeError> Drop for OccupancyTrackedStream<U> {
2174    fn drop(&mut self) {
2175        self.release();
2176    }
2177}
2178
2179impl<U: Data + MaybeError> std::fmt::Debug for OccupancyTrackedStream<U> {
2180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2181        f.debug_struct("OccupancyTrackedStream")
2182            .field(
2183                "instance_id",
2184                &self
2185                    .reservation
2186                    .as_ref()
2187                    .map(OccupancyReservation::worker_id),
2188            )
2189            .finish()
2190    }
2191}
2192
2193impl<U: Data + MaybeError> Stream for OccupancyTrackedStream<U> {
2194    type Item = U;
2195
2196    fn poll_next(
2197        mut self: Pin<&mut Self>,
2198        cx: &mut std::task::Context<'_>,
2199    ) -> Poll<Option<Self::Item>> {
2200        let poll = self.inner.as_mut().poll_next(cx);
2201        if matches!(&poll, Poll::Ready(None))
2202            || matches!(&poll, Poll::Ready(Some(item)) if item.err().is_some())
2203        {
2204            self.release();
2205        }
2206        poll
2207    }
2208}
2209
2210impl<U: Data + MaybeError> AsyncEngineContextProvider for OccupancyTrackedStream<U> {
2211    fn context(&self) -> Arc<dyn AsyncEngineContext> {
2212        self.inner.context()
2213    }
2214}
2215
2216impl<U: Data + MaybeError> crate::engine::AsyncEngineStream<U> for OccupancyTrackedStream<U> {}
2217
2218#[cfg(test)]
2219mod tests {
2220    use super::*;
2221    use std::sync::atomic::Ordering;
2222
2223    use crate::{
2224        DistributedRuntime, Runtime,
2225        distributed::DistributedConfig,
2226        error::DynamoError,
2227        pipeline::{
2228            RequestStream, ResponseStream,
2229            context::{Context, Controller},
2230            network::egress::route_span,
2231        },
2232    };
2233    use serde::{Deserialize, Serialize};
2234
2235    #[derive(Clone, Debug, Deserialize, Serialize)]
2236    struct TestResponse {
2237        error: Option<DynamoError>,
2238    }
2239
2240    impl MaybeError for TestResponse {
2241        fn from_err(err: impl std::error::Error + 'static) -> Self {
2242            Self {
2243                error: Some(DynamoError::from(
2244                    Box::new(err) as Box<dyn std::error::Error + 'static>
2245                )),
2246            }
2247        }
2248
2249        fn err(&self) -> Option<DynamoError> {
2250            self.error.clone()
2251        }
2252    }
2253
2254    fn assert_cannot_connect(error: &anyhow::Error) {
2255        assert!(
2256            match_error_chain(error.as_ref(), &[ErrorType::CannotConnect], &[]),
2257            "expected CannotConnect error, got: {error}"
2258        );
2259        assert!(
2260            !match_error_chain(error.as_ref(), &[ErrorType::ResourceExhausted], &[]),
2261            "CannotConnect failure must not be masked as ResourceExhausted: {error}"
2262        );
2263    }
2264
2265    fn assert_not_cannot_connect(error: &anyhow::Error) {
2266        assert!(
2267            !match_error_chain(error.as_ref(), &[ErrorType::CannotConnect], &[]),
2268            "fallback-enabled failure must preserve its existing error semantics: {error}"
2269        );
2270    }
2271
2272    /// The no-permitted-fallback path must carry a typed `Unavailable`, not a
2273    /// bare `anyhow!`. Asserted through `error_type_from_chain` because that is
2274    /// what route spans and the frontend's 503 mapping actually call: an
2275    /// untyped error classifies as `Unknown` there and is exported as
2276    /// `error.type=unknown` / HTTP 500.
2277    fn assert_unavailable(error: &anyhow::Error) {
2278        assert_eq!(
2279            route_span::error_type_from_chain(error.as_ref()),
2280            ErrorType::Unavailable,
2281            "no-permitted-fallback failure must be typed Unavailable, got: {error}"
2282        );
2283        assert_not_cannot_connect(error);
2284    }
2285
2286    struct StaticMultimodalCacheIndex {
2287        worker_id: u64,
2288    }
2289
2290    impl MultimodalCacheIndex for StaticMultimodalCacheIndex {
2291        fn workers_with_cache_key_hits(&self, cache_keys: &[String]) -> Vec<(u64, usize)> {
2292            vec![(self.worker_id, cache_keys.len())]
2293        }
2294
2295        fn remove_worker(&self, _worker_id: u64) {}
2296    }
2297
2298    #[test]
2299    fn router_mode_telemetry_labels_are_stable() {
2300        assert_eq!(RouterMode::RoundRobin.telemetry_label(), "round-robin");
2301        assert_eq!(RouterMode::Random.telemetry_label(), "random");
2302        assert_eq!(
2303            RouterMode::PowerOfTwoChoices.telemetry_label(),
2304            "power-of-two-choices"
2305        );
2306        assert_eq!(RouterMode::KV.telemetry_label(), "kv");
2307        assert_eq!(RouterMode::Direct.telemetry_label(), "direct");
2308        assert_eq!(RouterMode::LeastLoaded.telemetry_label(), "least-loaded");
2309        assert_eq!(
2310            RouterMode::DeviceAwareWeighted.telemetry_label(),
2311            "device-aware-weighted"
2312        );
2313    }
2314
2315    #[test]
2316    fn p2c_selects_lower_load_worker() {
2317        let state = RoutingOccupancyState::default();
2318        for _ in 0..10 {
2319            state.increment(1);
2320        }
2321        state.increment(2);
2322
2323        // With only two workers, p2c_select_from must pick both and choose id=2 (lower load).
2324        let result = p2c_select_from(&state, &[1, 2]);
2325        assert_eq!(result, 2);
2326    }
2327
2328    #[test]
2329    fn explicit_static_pickers_keep_policy_specific_state() {
2330        let (round_robin, random, configured) = route_pickers(RouterMode::KV);
2331        assert!(configured.is_none());
2332        assert_eq!(random.policy(), RoutePolicy::Random);
2333
2334        let candidates = CandidateView::Workers(&[10, 20, 30, 40]);
2335        random
2336            .select(candidates, RouteContext::default(), |_| 0)
2337            .expect("random selection must have a candidate");
2338        let selected = (0..2)
2339            .map(|_| {
2340                round_robin
2341                    .select(candidates, RouteContext::default(), |_| 0)
2342                    .expect("round-robin selection must have a candidate")
2343                    .target
2344                    .worker_id
2345            })
2346            .collect::<Vec<_>>();
2347        assert_eq!(selected, [10, 20]);
2348    }
2349
2350    #[test]
2351    fn p2c_selects_single_worker() {
2352        let state = RoutingOccupancyState::default();
2353        assert_eq!(p2c_select_from(&state, &[42]), 42);
2354    }
2355
2356    #[test]
2357    fn p2c_treats_missing_counts_as_zero() {
2358        let state = RoutingOccupancyState::default();
2359        for _ in 0..5 {
2360            state.increment(1);
2361        }
2362        // Worker 2 has no entry — should be treated as 0, so it wins.
2363        let result = p2c_select_from(&state, &[1, 2]);
2364        assert_eq!(result, 2);
2365    }
2366
2367    #[test]
2368    fn p2c_returns_valid_worker_on_tie() {
2369        let state = RoutingOccupancyState::default();
2370        for _ in 0..3 {
2371            state.increment(1);
2372            state.increment(2);
2373        }
2374
2375        for _ in 0..100 {
2376            let result = p2c_select_from(&state, &[1, 2]);
2377            assert!(result == 1 || result == 2);
2378        }
2379    }
2380
2381    #[test]
2382    fn occupancy_permit_decrements_before_stream_creation() {
2383        let state = Arc::new(RoutingOccupancyState::default());
2384        let counter = state.increment(42);
2385        let permit = OccupancyPermit::from_counter(state.clone(), 42, counter);
2386        assert_eq!(state.load(42), 1);
2387        drop(permit);
2388        assert_eq!(state.load(42), 0);
2389    }
2390
2391    #[test]
2392    fn occupancy_tracked_stream_decrements_on_drop() {
2393        let state = Arc::new(RoutingOccupancyState::default());
2394        let counter = state.increment(7);
2395        let permit = OccupancyPermit::from_counter(state.clone(), 7, counter);
2396        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
2397        let stream = permit.into_tracked_stream(ResponseStream::new(
2398            Box::pin(tokio_stream::iter(vec![TestResponse { error: None }])),
2399            ctx,
2400        ));
2401        assert_eq!(state.load(7), 1);
2402        drop(stream);
2403        assert_eq!(state.load(7), 0);
2404    }
2405
2406    #[tokio::test]
2407    async fn occupancy_tracked_stream_decrements_on_completion() {
2408        let state = Arc::new(RoutingOccupancyState::default());
2409        let counter = state.increment(7);
2410        let permit = OccupancyPermit::from_counter(state.clone(), 7, counter);
2411        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
2412        let mut stream = permit.into_tracked_stream(ResponseStream::new(
2413            Box::pin(tokio_stream::iter(vec![TestResponse { error: None }])),
2414            ctx,
2415        ));
2416
2417        assert!(stream.next().await.unwrap().err().is_none());
2418        assert_eq!(state.load(7), 1);
2419        assert!(stream.next().await.is_none());
2420        assert_eq!(state.load(7), 0);
2421        drop(stream);
2422        assert_eq!(state.load(7), 0, "drop must not release twice after EOF");
2423    }
2424
2425    #[tokio::test]
2426    async fn occupancy_tracked_stream_releases_before_yielding_error() {
2427        let state = Arc::new(RoutingOccupancyState::default());
2428        let counter = state.increment(7);
2429        let permit = OccupancyPermit::from_counter(state.clone(), 7, counter);
2430        let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
2431        let error = DynamoError::builder()
2432            .error_type(ErrorType::WorkerOverloaded)
2433            .message("worker queue full")
2434            .build();
2435        let mut stream = permit.into_tracked_stream(ResponseStream::new(
2436            Box::pin(tokio_stream::iter(vec![TestResponse {
2437                error: Some(error),
2438            }])),
2439            ctx,
2440        ));
2441
2442        let response = stream.next().await.expect("error response");
2443        assert!(response.err().is_some());
2444        assert_eq!(
2445            state.load(7),
2446            0,
2447            "occupancy must be released before retry observes the error"
2448        );
2449    }
2450
2451    #[test]
2452    fn same_id_readd_keeps_existing_permit_accounting() {
2453        let state = Arc::new(RoutingOccupancyState::default());
2454        state.retain(&[7]);
2455        let old_counter = state.increment(7);
2456        let old_permit = OccupancyPermit::from_counter(state.clone(), 7, old_counter);
2457
2458        state.retain(&[]);
2459        assert_eq!(state.load(7), 1);
2460        state.retain(&[7]);
2461        let new_permit = OccupancyPermit::acquire(state.clone(), 7);
2462        assert_eq!(state.load(7), 2);
2463
2464        drop(old_permit);
2465        assert_eq!(
2466            state.load(7),
2467            1,
2468            "re-added worker must retain the still-live request"
2469        );
2470        drop(new_permit);
2471        assert_eq!(state.load(7), 0);
2472    }
2473
2474    /// A mid-generation stream end means the worker dropped the request, so it
2475    /// must quarantine — otherwise a migration retry can reselect the same
2476    /// worker before discovery removal catches up.
2477    ///
2478    /// This pins `is_inhibited` against the migration layer's migratable set:
2479    /// a worker fault that is migratable must also inhibit, or migration
2480    /// bounces off the same dead worker.
2481    #[test]
2482    fn stream_incomplete_quarantines_the_worker() {
2483        let err = DynamoError::builder()
2484            .error_type(ErrorType::Backend(BackendError::StreamIncomplete))
2485            .message("stream ended before generation completed")
2486            .build();
2487        assert!(
2488            is_inhibited(&err),
2489            "StreamIncomplete must inhibit; it is migratable, so leaving it out \
2490             lets a retry reselect the failed worker"
2491        );
2492
2493        let cancelled = DynamoError::builder()
2494            .error_type(ErrorType::Cancelled)
2495            .message("client went away")
2496            .build();
2497        assert!(
2498            !is_inhibited(&cancelled),
2499            "client cancellation is not a worker fault"
2500        );
2501    }
2502
2503    #[test]
2504    fn p2c_lifecycle_tracks_inflight_counts_with_shared_tracker() {
2505        let state = Arc::new(RoutingOccupancyState::default());
2506        let mut permits = Vec::new();
2507        for _ in 0..5 {
2508            let selected = p2c_select_from(&state, &[1, 2]);
2509            permits.push(OccupancyPermit::acquire(state.clone(), selected));
2510        }
2511
2512        let total = state.load(1) + state.load(2);
2513        assert_eq!(total, 5, "5 in-flight requests should be tracked");
2514
2515        drop(permits);
2516        let total = state.load(1) + state.load(2);
2517        assert_eq!(total, 0, "All guards dropped, counts should be 0");
2518    }
2519
2520    #[test]
2521    fn p2c_never_selects_dominated_worker() {
2522        let state = RoutingOccupancyState::default();
2523        for _ in 0..100 {
2524            state.increment(3);
2525        }
2526
2527        let mut selected = [0u32; 3];
2528        for _ in 0..1000 {
2529            let result = p2c_select_from(&state, &[1, 2, 3]);
2530            match result {
2531                1 => selected[0] += 1,
2532                2 => selected[1] += 1,
2533                3 => selected[2] += 1,
2534                _ => panic!("unexpected worker id"),
2535            }
2536        }
2537        assert_eq!(
2538            selected[2], 0,
2539            "Worker 3 (load=100) should never be selected against load=0 workers, but got {} times",
2540            selected[2]
2541        );
2542    }
2543
2544    #[tokio::test]
2545    async fn least_loaded_selects_exact_min_and_tracks_counts() {
2546        let state = Arc::new(RoutingOccupancyState::default());
2547        state.increment(1);
2548        state.increment(1);
2549        state.increment(2);
2550
2551        let picker = RoutePicker::new(RoutePolicy::LeastLoaded);
2552        let (decision, counter) = state
2553            .select_and_admit(
2554                &picker,
2555                CandidateView::Workers(&[1, 2, 3]),
2556                RouteContext::default(),
2557            )
2558            .unwrap();
2559        let selected = decision.target.worker_id;
2560        assert_eq!(selected, 3);
2561
2562        let permit = OccupancyPermit::from_counter(
2563            state.clone(),
2564            selected,
2565            counter.expect("least-loaded selection must acquire a counter"),
2566        );
2567        assert_eq!(state.load(selected), 1);
2568        drop(permit);
2569        assert_eq!(state.load(selected), 0);
2570    }
2571
2572    #[tokio::test]
2573    async fn bidirectional_generate_bails_with_no_instances() {
2574        let rt = Runtime::from_current().unwrap();
2575        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2576            .await
2577            .unwrap();
2578        let ns = drt.namespace("test_bidi_no_instances".to_string()).unwrap();
2579        let component = ns.component("test_component".to_string()).unwrap();
2580        let endpoint = component.endpoint("test_endpoint".to_string());
2581        let client = endpoint.client().await.unwrap();
2582
2583        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2584            .await
2585            .unwrap();
2586
2587        let input: ManyIn<u64> =
2588            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![
2589                1u64, 2u64,
2590            ]))));
2591        let result = router.generate(input).await;
2592        assert!(
2593            result.is_err(),
2594            "bidirectional generate must bail when no instances are registered"
2595        );
2596
2597        rt.shutdown();
2598    }
2599
2600    #[tokio::test]
2601    async fn bidirectional_generate_bails_for_kv_router_mode() {
2602        let rt = Runtime::from_current().unwrap();
2603        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2604            .await
2605            .unwrap();
2606        let ns = drt.namespace("test_bidi_kv_mode".to_string()).unwrap();
2607        let component = ns.component("test_component".to_string()).unwrap();
2608        let endpoint = component.endpoint("test_endpoint".to_string());
2609        let client = endpoint.client().await.unwrap();
2610
2611        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::KV)
2612            .await
2613            .unwrap();
2614
2615        let input: ManyIn<u64> =
2616            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2617        let result = router.generate(input).await;
2618        assert!(
2619            result.is_err(),
2620            "bidirectional generate must bail for RouterMode::KV"
2621        );
2622        let err_msg = format!("{:?}", result.unwrap_err());
2623        assert!(
2624            err_msg.contains("KV") || err_msg.contains("kv"),
2625            "error should mention KV: got {err_msg}"
2626        );
2627
2628        rt.shutdown();
2629    }
2630
2631    #[tokio::test]
2632    async fn bidirectional_generate_bails_for_direct_router_mode() {
2633        let rt = Runtime::from_current().unwrap();
2634        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2635            .await
2636            .unwrap();
2637        let ns = drt.namespace("test_bidi_direct_mode".to_string()).unwrap();
2638        let component = ns.component("test_component".to_string()).unwrap();
2639        let endpoint = component.endpoint("test_endpoint".to_string());
2640        let client = endpoint.client().await.unwrap();
2641
2642        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::Direct)
2643            .await
2644            .unwrap();
2645
2646        let input: ManyIn<u64> =
2647            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2648        let result = router.generate(input).await;
2649        assert!(
2650            result.is_err(),
2651            "bidirectional generate must bail for RouterMode::Direct"
2652        );
2653        let err_msg = format!("{:?}", result.unwrap_err());
2654        assert!(
2655            err_msg.contains("Direct") || err_msg.contains("direct"),
2656            "error should mention Direct: got {err_msg}"
2657        );
2658
2659        rt.shutdown();
2660    }
2661
2662    #[tokio::test]
2663    async fn bidirectional_generate_rejects_unsupported_load_aware_modes() {
2664        let rt = Runtime::from_current().unwrap();
2665        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2666            .await
2667            .unwrap();
2668        let ns = drt.namespace("test_bidi_load_aware".to_string()).unwrap();
2669        let component = ns.component("test_component".to_string()).unwrap();
2670
2671        for mode in [
2672            RouterMode::PowerOfTwoChoices,
2673            RouterMode::LeastLoaded,
2674            RouterMode::DeviceAwareWeighted,
2675        ] {
2676            let endpoint = component.endpoint("test_endpoint".to_string());
2677            let client = endpoint.client().await.unwrap();
2678            let router = PushRouter::<u64, TestResponse>::from_client(client, mode)
2679                .await
2680                .unwrap();
2681
2682            let input: ManyIn<u64> =
2683                Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2684            let result = router.generate(input).await;
2685            assert!(
2686                result.is_err(),
2687                "bidirectional generate must reject {mode:?} (not yet supported)"
2688            );
2689            let err_msg = format!("{:?}", result.unwrap_err());
2690            assert!(
2691                err_msg.contains("not yet supported for bidirectional dispatch"),
2692                "error should explain the mode is unsupported, not 'no instances': got {err_msg}"
2693            );
2694        }
2695
2696        rt.shutdown();
2697    }
2698
2699    #[tokio::test]
2700    async fn least_loaded_peek_returns_available_worker_select_stays_none() {
2701        let rt = Runtime::from_current().unwrap();
2702        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2703            .await
2704            .unwrap();
2705        let ns = drt
2706            .namespace("test_least_loaded_router".to_string())
2707            .unwrap();
2708        let component = ns.component("test_component".to_string()).unwrap();
2709        let endpoint = component.endpoint("test_endpoint".to_string());
2710        let client = endpoint.client().await.unwrap();
2711
2712        endpoint.register_endpoint_instance().await.unwrap();
2713        client.wait_for_instances().await.unwrap();
2714
2715        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2716            .await
2717            .unwrap();
2718
2719        // LeastLoaded selection tracks request occupancy, so the advisory API is
2720        // separate from select_next_worker().
2721        assert_eq!(router.select_next_worker(), None);
2722        assert!(
2723            router.peek_next_worker().is_some(),
2724            "LeastLoaded peek must return the available worker for disagg bootstrap"
2725        );
2726
2727        rt.shutdown();
2728    }
2729
2730    #[tokio::test]
2731    async fn exact_selection_releases_occupancy_when_preparation_fails() {
2732        let rt = Runtime::from_current().unwrap();
2733        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2734            .await
2735            .unwrap();
2736        let ns = drt
2737            .namespace("test_exact_prepare_failure".to_string())
2738            .unwrap();
2739        let component = ns.component("test_component".to_string()).unwrap();
2740        let endpoint = component.endpoint("test_endpoint".to_string());
2741        let client = endpoint.client().await.unwrap();
2742        endpoint.register_endpoint_instance().await.unwrap();
2743        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2744
2745        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
2746            .await
2747            .unwrap();
2748        let state = router.occupancy_state.clone().unwrap();
2749        let result = router
2750            .select_and_dispatch_exact(SingleIn::new(42), None, |_, _| {
2751                Err::<(), _>(anyhow::anyhow!("metadata preparation failed"))
2752            })
2753            .await;
2754
2755        assert!(result.is_err());
2756        assert_eq!(
2757            state.load(worker_id),
2758            0,
2759            "preparation failure must release the selected worker"
2760        );
2761        rt.shutdown();
2762    }
2763
2764    #[tokio::test]
2765    async fn exact_dispatch_revalidates_overload_after_preparation() {
2766        let rt = Runtime::from_current().unwrap();
2767        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2768            .await
2769            .unwrap();
2770        let ns = drt
2771            .namespace("test_exact_overload_revalidation".to_string())
2772            .unwrap();
2773        let component = ns.component("test_component".to_string()).unwrap();
2774        let endpoint = component.endpoint("test_endpoint".to_string());
2775        let client = endpoint.client().await.unwrap();
2776        endpoint.register_endpoint_instance().await.unwrap();
2777        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2778
2779        let router =
2780            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::LeastLoaded)
2781                .await
2782                .unwrap();
2783        let state = router.occupancy_state.clone().unwrap();
2784        let result = router
2785            .select_and_dispatch_exact(SingleIn::new(42), Some(worker_id), |_, worker_id| {
2786                client.set_overloaded_instances(&[worker_id]);
2787                Ok(())
2788            })
2789            .await;
2790
2791        assert!(result.is_err());
2792        assert_eq!(
2793            state.load(worker_id),
2794            0,
2795            "validation failure must release the selected worker"
2796        );
2797        rt.shutdown();
2798    }
2799
2800    #[tokio::test]
2801    async fn transport_resolution_precedes_stale_overload_check() {
2802        let rt = Runtime::from_current().unwrap();
2803        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2804            .await
2805            .unwrap();
2806        let endpoint = drt
2807            .namespace("test_transport_precedes_stale_overload".to_string())
2808            .unwrap()
2809            .component("test_component".to_string())
2810            .unwrap()
2811            .endpoint("test_endpoint".to_string());
2812        let client = endpoint.client().await.unwrap();
2813        let stale_id = 99999;
2814        client.override_instance_avail(vec![stale_id]);
2815        client.set_overloaded_instances(&[stale_id]);
2816        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2817            .await
2818            .unwrap();
2819
2820        let unary_error = router
2821            .direct(SingleIn::new(42), stale_id)
2822            .await
2823            .unwrap_err();
2824        assert_cannot_connect(&unary_error);
2825
2826        let input: ManyIn<u64> =
2827            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![1u64]))));
2828        let bidirectional_error = router
2829            .bidirectional_dispatch(stale_id, input)
2830            .await
2831            .unwrap_err();
2832        assert_not_cannot_connect(&bidirectional_error);
2833        assert!(
2834            !match_error_chain(
2835                bidirectional_error.as_ref(),
2836                &[ErrorType::ResourceExhausted],
2837                &[]
2838            ),
2839            "transport resolution must precede the stale overload check: {bidirectional_error}"
2840        );
2841
2842        rt.shutdown();
2843    }
2844
2845    #[tokio::test]
2846    async fn selected_overloaded_worker_is_rejected_before_dispatch() {
2847        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
2848
2849        let rt = Runtime::from_current().unwrap();
2850        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2851            .await
2852            .unwrap();
2853        let ns = drt
2854            .namespace("test_selected_overloaded_worker_rejected".to_string())
2855            .unwrap();
2856        let component = ns.component("test_component".to_string()).unwrap();
2857        let endpoint = component.endpoint("test_endpoint".to_string());
2858        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2859            .await
2860            .unwrap();
2861
2862        endpoint.register_endpoint_instance().await.unwrap();
2863        let instances = client.wait_for_instances().await.unwrap();
2864        let worker_id = instances[0].id();
2865
2866        for _ in 0..10 {
2867            if client.instance_ids_avail().contains(&worker_id) {
2868                break;
2869            }
2870            tokio::time::sleep(TEST_RECONCILE_INTERVAL).await;
2871        }
2872        assert!(
2873            client.instance_ids_avail().contains(&worker_id),
2874            "worker should be routable before marking it overloaded"
2875        );
2876
2877        client.set_overloaded_instances(&[worker_id]);
2878        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2879            .await
2880            .unwrap();
2881
2882        let result = router.generate(SingleIn::new(42u64)).await;
2883        assert!(result.is_err());
2884        let msg = format!("{}", result.unwrap_err());
2885        // With pre-selection filtering on free_ids, the single-overloaded-worker
2886        // case is now caught before selection rather than after — the chosen
2887        // worker is never overloaded because the candidate pool excludes it.
2888        // The post-selection check in route() remains as a race-condition
2889        // backstop.
2890        assert!(
2891            msg.contains("All workers are busy"),
2892            "expected empty-free-pool rejection, got: {msg}"
2893        );
2894
2895        rt.shutdown();
2896    }
2897
2898    #[tokio::test]
2899    async fn direct_within_rejects_overloaded_constrained_target() {
2900        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2901
2902        let rt = Runtime::from_current().unwrap();
2903        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2904            .await
2905            .unwrap();
2906        let ns = drt
2907            .namespace("test_direct_within_overload_rejection".to_string())
2908            .unwrap();
2909        let component = ns.component("test_component".to_string()).unwrap();
2910        let endpoint = component.endpoint("test_endpoint".to_string());
2911        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2912            .await
2913            .unwrap();
2914
2915        endpoint.register_endpoint_instance().await.unwrap();
2916        let worker_id = client.wait_for_instances().await.unwrap()[0].id();
2917        client.set_overloaded_instances(&[worker_id]);
2918
2919        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2920            .await
2921            .unwrap();
2922        let allowed = HashSet::from([worker_id]);
2923        let error = router
2924            .direct_within(SingleIn::new(42), worker_id, Some(&allowed))
2925            .await
2926            .unwrap_err();
2927
2928        // A *selected* worker being overloaded is single-worker overload, distinct
2929        // from pool-wide exhaustion: migration may retry elsewhere. Previously
2930        // both collapsed to ResourceExhausted, which blocked that retry.
2931        assert!(match_error_chain(
2932            error.as_ref(),
2933            &[ErrorType::WorkerOverloaded],
2934            &[]
2935        ));
2936        assert!(
2937            error.to_string().contains("Selected worker is overloaded"),
2938            "expected overload rejection, got: {error}"
2939        );
2940
2941        rt.shutdown();
2942    }
2943
2944    #[tokio::test]
2945    async fn no_workers_is_reported_as_unavailable() {
2946        let rt = Runtime::from_current().unwrap();
2947        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2948            .await
2949            .unwrap();
2950        let ns = drt
2951            .namespace("test_no_workers_unavailable".to_string())
2952            .unwrap();
2953        let component = ns.component("test_component".to_string()).unwrap();
2954        let endpoint = component.endpoint("test_endpoint".to_string());
2955        let client = endpoint.client().await.unwrap();
2956        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
2957            .await
2958            .unwrap();
2959
2960        let error = router.generate(SingleIn::new(42)).await.unwrap_err();
2961        assert!(match_error_chain(
2962            error.as_ref(),
2963            &[ErrorType::Unavailable],
2964            &[]
2965        ));
2966
2967        rt.shutdown();
2968    }
2969
2970    #[tokio::test]
2971    async fn round_robin_excludes_overloaded_workers_from_candidates() {
2972        // Long reconcile interval so the synthetic override below survives
2973        // the test. We still register a real endpoint instance up front so
2974        // the initial reconcile (which fires immediately when the monitor
2975        // task spawns) settles on a non-empty source — without that, the
2976        // first reconcile would clobber the override before it takes effect.
2977        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
2978
2979        let rt = Runtime::from_current().unwrap();
2980        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
2981            .await
2982            .unwrap();
2983        let ns = drt
2984            .namespace("test_round_robin_excludes_overloaded".to_string())
2985            .unwrap();
2986        let component = ns.component("test_component".to_string()).unwrap();
2987        let endpoint = component.endpoint("test_endpoint".to_string());
2988        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
2989            .await
2990            .unwrap();
2991
2992        endpoint.register_endpoint_instance().await.unwrap();
2993        let instances = client.wait_for_instances().await.unwrap();
2994        let real_id = instances[0].id();
2995        for _ in 0..50 {
2996            if client.instance_ids_avail().contains(&real_id) {
2997                break;
2998            }
2999            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3000        }
3001
3002        // Now override with two synthetic IDs and mark one overloaded.
3003        // round_robin must never select the overloaded one — that's the
3004        // whole point of selecting from free_ids instead of routable_ids.
3005        // The post-selection overload check in route() would otherwise return 529
3006        // one of N requests on each pass, which is the bug this PR closes
3007        // for non-KV selectors.
3008        client.override_instance_avail(vec![1, 2]);
3009        client.set_overloaded_instances(&[1]);
3010
3011        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::RoundRobin)
3012            .await
3013            .unwrap();
3014
3015        // Round-robin over N requests should land on worker 2 every time.
3016        // We use peek_next_worker for a side-effect-free probe.
3017        for _ in 0..6 {
3018            let selected = router
3019                .peek_next_worker()
3020                .expect("peek should succeed with a free worker");
3021            assert_eq!(
3022                selected, 2,
3023                "overloaded worker 1 must not appear in the candidate set"
3024            );
3025        }
3026
3027        rt.shutdown();
3028    }
3029
3030    #[tokio::test]
3031    async fn device_aware_weighted_peek_returns_available_worker_select_stays_none() {
3032        let rt = Runtime::from_current().unwrap();
3033        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3034            .await
3035            .unwrap();
3036        let ns = drt
3037            .namespace("test_device_aware_router".to_string())
3038            .unwrap();
3039        let component = ns.component("test_component".to_string()).unwrap();
3040        let endpoint = component.endpoint("test_endpoint".to_string());
3041        let client = endpoint.client().await.unwrap();
3042
3043        endpoint.register_endpoint_instance().await.unwrap();
3044        client.wait_for_instances().await.unwrap();
3045
3046        let router =
3047            PushRouter::<u64, TestResponse>::from_client(client, RouterMode::DeviceAwareWeighted)
3048                .await
3049                .unwrap();
3050
3051        // DeviceAwareWeighted degenerates to least-loaded for peek (device-class
3052        // partitioning happens at dispatch); select_next_worker stays None.
3053        assert_eq!(router.select_next_worker(), None);
3054        assert!(
3055            router.peek_next_worker().is_some(),
3056            "DeviceAwareWeighted peek must return the available worker for disagg bootstrap"
3057        );
3058
3059        rt.shutdown();
3060    }
3061
3062    #[tokio::test]
3063    async fn device_aware_exact_selection_preserves_full_multimodal_cache_hit() {
3064        let rt = Runtime::from_current().unwrap();
3065        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3066            .await
3067            .unwrap();
3068        let ns = drt
3069            .namespace("test_device_aware_affinity_cache".to_string())
3070            .unwrap();
3071        let component = ns.component("test_component".to_string()).unwrap();
3072        let endpoint = component.endpoint("test_endpoint".to_string());
3073        let client = endpoint.client().await.unwrap();
3074        endpoint.register_endpoint_instance().await.unwrap();
3075        let cache_worker = client.wait_for_instances().await.unwrap()[0].id();
3076
3077        let router = PushRouter::<u64, TestResponse>::from_client_with_state(
3078            client,
3079            RouterMode::DeviceAwareWeighted,
3080            None,
3081            Some(Arc::new(StaticMultimodalCacheIndex {
3082                worker_id: cache_worker,
3083            })),
3084            Some(Arc::new(|_| vec!["image-key".to_string()])),
3085        )
3086        .await
3087        .unwrap();
3088
3089        let selection = router.select_device_aware_and_reserve(&42, None).unwrap();
3090        assert_eq!(selection.worker_id(), cache_worker);
3091        assert!(
3092            selection.into_reservation().is_none(),
3093            "full cache hits bypass occupancy charging"
3094        );
3095
3096        rt.shutdown();
3097    }
3098
3099    /// Direct dispatch honors an upstream-selected worker even after local inhibition.
3100    #[tokio::test]
3101    async fn direct_dispatch_ignores_local_inhibition() {
3102        let rt = Runtime::from_current().unwrap();
3103        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3104            .await
3105            .unwrap();
3106        let ns = drt
3107            .namespace("test_direct_bypasses_inhibition".to_string())
3108            .unwrap();
3109        let component = ns.component("test_component".to_string()).unwrap();
3110        let endpoint = component.endpoint("test_endpoint".to_string());
3111        let client = endpoint.client().await.unwrap();
3112        endpoint.register_endpoint_instance().await.unwrap();
3113        let instance_id = client.wait_for_instances().await.unwrap()[0].id();
3114
3115        // KV routing selects upstream and dispatches through PushRouter::direct.
3116        let router = PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::KV)
3117            .await
3118            .unwrap();
3119
3120        client.report_instance_down(instance_id);
3121        assert!(
3122            !client.instance_ids_avail().contains(&instance_id),
3123            "precondition: worker should be locally inhibited"
3124        );
3125
3126        let result = router
3127            .direct_within_prepared(
3128                SingleIn::new(42),
3129                instance_id,
3130                None,
3131                |_, selected_instance_id| {
3132                    assert_eq!(selected_instance_id, instance_id);
3133                    Err::<(), _>(anyhow::anyhow!("direct prepare sentinel"))
3134                },
3135            )
3136            .await;
3137        let error = match result {
3138            Ok(_) => panic!("direct dispatch should reach request preparation"),
3139            Err(error) => error,
3140        };
3141        assert_eq!(error.to_string(), "direct prepare sentinel");
3142
3143        let missing_instance_id = instance_id.wrapping_add(1);
3144        let result = router
3145            .direct_within_prepared(SingleIn::new(42), missing_instance_id, None, |_, _| {
3146                Ok::<(), anyhow::Error>(())
3147            })
3148            .await;
3149        let error = match result {
3150            Ok(_) => panic!("direct dispatch should reject a worker absent from discovery"),
3151            Err(error) => error,
3152        };
3153        assert!(
3154            error
3155                .to_string()
3156                .contains(&format!("instance_id={missing_instance_id} not found")),
3157            "unexpected missing-worker error: {error}"
3158        );
3159
3160        rt.shutdown();
3161    }
3162
3163    /// When the router selects an instance that has deregistered between selection
3164    /// and transport resolution, it should fall back to another available instance
3165    /// rather than returning a 500 error.
3166    #[tokio::test]
3167    async fn transport_resolution_falls_back_when_selected_instance_disappears() {
3168        let rt = Runtime::from_current().unwrap();
3169        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3170            .await
3171            .unwrap();
3172        let ns = drt
3173            .namespace("test_transport_fallback".to_string())
3174            .unwrap();
3175        let component = ns.component("test_component".to_string()).unwrap();
3176        let endpoint = component.endpoint("test_endpoint".to_string());
3177        let client = endpoint.client().await.unwrap();
3178
3179        // Register one real instance so it appears in instance_source.
3180        endpoint.register_endpoint_instance().await.unwrap();
3181        client.wait_for_instances().await.unwrap();
3182
3183        let real_id = client.instance_ids()[0];
3184
3185        // Inject a stale ID into instance_avail that does NOT exist in
3186        // instance_source. This simulates the race window where an instance
3187        // deregistered after selection but before transport resolution.
3188        let stale_id = real_id + 1000;
3189        client.override_instance_avail(vec![stale_id, real_id]);
3190
3191        let router =
3192            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
3193                .await
3194                .unwrap();
3195
3196        // Exercise transport resolution directly. Sending a request to this
3197        // registration would wait forever because the test intentionally has
3198        // no worker handler.
3199        let (resolved_id, _, _, _) = router
3200            .resolve_transport(stale_id, TransportFallback::Allow)
3201            .expect("normal routing should fall back from a stale worker");
3202        assert_eq!(resolved_id, real_id);
3203
3204        rt.shutdown();
3205    }
3206
3207    #[tokio::test]
3208    async fn prepared_dispatch_observes_worker_after_transport_fallback() {
3209        let rt = Runtime::from_current().unwrap();
3210        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3211            .await
3212            .unwrap();
3213        let endpoint = drt
3214            .namespace("test_prepared_transport_fallback".to_string())
3215            .unwrap()
3216            .component("test_component".to_string())
3217            .unwrap()
3218            .endpoint("test_endpoint".to_string());
3219        let client = endpoint.client().await.unwrap();
3220        endpoint.register_endpoint_instance().await.unwrap();
3221        let real_id = client.wait_for_instances().await.unwrap()[0].id();
3222        let stale_id = real_id.wrapping_add(1);
3223        client.override_instance_avail(vec![stale_id, real_id]);
3224        let router = PushRouter::<u64, TestResponse>::from_client(client, RouterMode::LeastLoaded)
3225            .await
3226            .unwrap();
3227        let state = router.occupancy_state.clone().unwrap();
3228        state.increment(real_id);
3229        let state_for_prepare = state.clone();
3230        let observed = Arc::new(AtomicU64::new(0));
3231        let observed_for_prepare = observed.clone();
3232
3233        let _ = tokio::time::timeout(
3234            std::time::Duration::from_millis(100),
3235            router.select_and_dispatch(SingleIn::new(42), move |_, worker_id| {
3236                assert_eq!(state_for_prepare.load(stale_id), 0);
3237                assert_eq!(state_for_prepare.load(worker_id), 2);
3238                observed_for_prepare.store(worker_id, Ordering::Relaxed);
3239                Ok(())
3240            }),
3241        )
3242        .await;
3243
3244        assert_eq!(observed.load(Ordering::Relaxed), real_id);
3245        assert_eq!(state.load(real_id), 1);
3246        state.decrement(real_id);
3247        rt.shutdown();
3248    }
3249
3250    /// When no instances are available at all (both primary and fallback),
3251    /// the router should return a clear error.
3252    #[tokio::test]
3253    async fn transport_resolution_errors_when_no_instances_available() {
3254        let rt = Runtime::from_current().unwrap();
3255        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3256            .await
3257            .unwrap();
3258        let ns = drt
3259            .namespace("test_transport_no_fallback".to_string())
3260            .unwrap();
3261        let component = ns.component("test_component".to_string()).unwrap();
3262        let endpoint = component.endpoint("test_endpoint".to_string());
3263        // Freeze `monitor_instance_source` before staging routing state. That
3264        // task reconciles `routable_ids` back to the real discovered set on
3265        // every discovery change *and* every `reconcile_interval` (5s by
3266        // default), so it silently undoes `override_instance_avail` mid-test.
3267        // Cancelling before registration means the only writer left is this
3268        // test. Safe because `wait_for_instances` reads `instance_source`
3269        // directly rather than the reconciled routing snapshot.
3270        let monitor = tokio_util::sync::CancellationToken::new();
3271        let client = endpoint
3272            .client_with_cancellation(monitor.clone())
3273            .await
3274            .unwrap();
3275        monitor.cancel();
3276
3277        // Register an instance so we can create the router (needs transport setup).
3278        endpoint.register_endpoint_instance().await.unwrap();
3279        client.wait_for_instances().await.unwrap();
3280
3281        let router =
3282            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
3283                .await
3284                .unwrap();
3285
3286        // Override avail to contain only a stale ID with no real backing
3287        // instance AND no other available fallback.
3288        let stale_id = 99999;
3289        client.override_instance_avail(vec![stale_id]);
3290
3291        let request = SingleIn::new(42u64);
3292        let result = router.generate(request).await;
3293
3294        assert!(result.is_err());
3295        let error = result.unwrap_err();
3296        assert_unavailable(&error);
3297        let msg = error.to_string();
3298        assert!(
3299            msg.contains("not found") && msg.contains("no other instances available"),
3300            "Expected clear error about missing instance with no fallback, got: {msg}"
3301        );
3302
3303        rt.shutdown();
3304    }
3305
3306    #[tokio::test]
3307    async fn transport_resolution_honors_fallback_policy() {
3308        let rt = Runtime::from_current().unwrap();
3309        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3310            .await
3311            .unwrap();
3312        let ns = drt
3313            .namespace("test_exact_transport_no_fallback".to_string())
3314            .unwrap();
3315        let component = ns.component("test_component".to_string()).unwrap();
3316        let endpoint = component.endpoint("test_endpoint".to_string());
3317        // Freeze `monitor_instance_source` before staging routing state. That
3318        // task reconciles `routable_ids` back to the real discovered set on
3319        // every discovery change *and* every `reconcile_interval` (5s by
3320        // default), so it silently undoes `override_instance_avail` mid-test.
3321        // Cancelling before registration means the only writer left is this
3322        // test. Safe because `wait_for_instances` reads `instance_source`
3323        // directly rather than the reconciled routing snapshot.
3324        let monitor = tokio_util::sync::CancellationToken::new();
3325        let client = endpoint
3326            .client_with_cancellation(monitor.clone())
3327            .await
3328            .unwrap();
3329        monitor.cancel();
3330        endpoint.register_endpoint_instance().await.unwrap();
3331        let instances = client.wait_for_instances().await.unwrap();
3332        let real_id = instances[0].id();
3333
3334        let router =
3335            PushRouter::<u64, TestResponse>::from_client(client.clone(), RouterMode::RoundRobin)
3336                .await
3337                .unwrap();
3338        let stale_id = real_id.wrapping_add(1);
3339        client.override_instance_avail(vec![stale_id, real_id]);
3340
3341        assert!(
3342            router
3343                .resolve_transport(stale_id, TransportFallback::Allow)
3344                .is_ok(),
3345            "normal dispatch should preserve transport fallback"
3346        );
3347        let allowed = HashSet::from([real_id]);
3348        assert!(
3349            router
3350                .resolve_transport(stale_id, TransportFallback::Within(&allowed))
3351                .is_ok(),
3352            "constrained dispatch should fall back within the allowed worker set"
3353        );
3354        let disallowed = HashSet::new();
3355        let disallowed_error = router
3356            .resolve_transport(stale_id, TransportFallback::Within(&disallowed))
3357            .unwrap_err();
3358        assert_unavailable(&disallowed_error);
3359
3360        let exact_error = router
3361            .resolve_transport(stale_id, TransportFallback::Deny)
3362            .unwrap_err();
3363        assert_cannot_connect(&exact_error);
3364
3365        let second_stale_id = stale_id.wrapping_add(1);
3366        client.override_instance_avail(vec![stale_id, second_stale_id]);
3367        let stale_fallback_error = router
3368            .resolve_transport(stale_id, TransportFallback::Allow)
3369            .unwrap_err();
3370        assert_cannot_connect(&stale_fallback_error);
3371        assert!(
3372            stale_fallback_error
3373                .to_string()
3374                .contains("Fallback instance"),
3375            "expected fallback lookup failure, got: {stale_fallback_error}"
3376        );
3377        rt.shutdown();
3378    }
3379
3380    /// The watcher dedup guard must be released even if the spawned task panics.
3381    /// Without this, a panic anywhere in the watcher body would leave a stale
3382    /// `ENDPOINT_WATCHER_ACTIVE` entry, silently disabling orphaned-pending-
3383    /// request cancellation for that endpoint until process restart.
3384    ///
3385    /// We exercise the Drop-guard pattern directly against the same static
3386    /// rather than driving `spawn_instance_removal_watcher` end-to-end (which
3387    /// would require staging a panicking discovery stream). The test mirrors
3388    /// the production code's GuardRelease shape; if the production code stops
3389    /// using a Drop guard, the integration would regress and the existing
3390    /// orphan-cancellation tests would fail.
3391    #[tokio::test]
3392    async fn watcher_dedup_guard_released_on_panic() {
3393        let endpoint_id = EndpointId {
3394            namespace: "panic-test-ns".to_string(),
3395            component: "panic-test-comp".to_string(),
3396            name: "panic-test-endpoint".to_string(),
3397        };
3398
3399        // Mimic the production code's pre-spawn dedup insert.
3400        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
3401        map.insert(endpoint_id.clone(), ());
3402
3403        let endpoint_id_clone = endpoint_id.clone();
3404        let join = tokio::spawn(async move {
3405            // Same shape as in spawn_instance_removal_watcher.
3406            struct GuardRelease(EndpointId);
3407            impl Drop for GuardRelease {
3408                fn drop(&mut self) {
3409                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
3410                        map.remove(&self.0);
3411                    }
3412                }
3413            }
3414            let _release = GuardRelease(endpoint_id_clone);
3415            panic!("simulated watcher-task panic");
3416        });
3417
3418        let result = join.await;
3419        assert!(result.is_err() && result.unwrap_err().is_panic());
3420        assert!(
3421            !map.contains_key(&endpoint_id),
3422            "Drop guard must release the dedup entry even on panic"
3423        );
3424    }
3425
3426    /// Normal-exit path: the Drop guard releases the entry when the task
3427    /// finishes without panicking. This is the everyday case (cancel_token
3428    /// fires or discovery stream closes).
3429    #[tokio::test]
3430    async fn watcher_dedup_guard_released_on_normal_exit() {
3431        let endpoint_id = EndpointId {
3432            namespace: "normal-test-ns".to_string(),
3433            component: "normal-test-comp".to_string(),
3434            name: "normal-test-endpoint".to_string(),
3435        };
3436
3437        let map = ENDPOINT_WATCHER_ACTIVE.get_or_init(dashmap::DashMap::new);
3438        map.insert(endpoint_id.clone(), ());
3439
3440        let endpoint_id_clone = endpoint_id.clone();
3441        tokio::spawn(async move {
3442            struct GuardRelease(EndpointId);
3443            impl Drop for GuardRelease {
3444                fn drop(&mut self) {
3445                    if let Some(map) = ENDPOINT_WATCHER_ACTIVE.get() {
3446                        map.remove(&self.0);
3447                    }
3448                }
3449            }
3450            let _release = GuardRelease(endpoint_id_clone);
3451            // task body returns normally
3452        })
3453        .await
3454        .unwrap();
3455
3456        assert!(!map.contains_key(&endpoint_id));
3457    }
3458
3459    #[tokio::test]
3460    async fn cache_cleanup_watcher_identity_includes_runtime() {
3461        let runtime = Runtime::from_current().unwrap();
3462        let first = DistributedRuntime::new(runtime.clone(), DistributedConfig::process_local())
3463            .await
3464            .unwrap();
3465        let second = DistributedRuntime::new(runtime.clone(), DistributedConfig::process_local())
3466            .await
3467            .unwrap();
3468        let endpoint = |distributed: &DistributedRuntime| {
3469            distributed
3470                .namespace("cache-watcher-identity".to_string())
3471                .unwrap()
3472                .component("workers".to_string())
3473                .unwrap()
3474                .endpoint("generate".to_string())
3475        };
3476        let first = RuntimeEndpointId::for_endpoint(&endpoint(&first));
3477        let second = RuntimeEndpointId::for_endpoint(&endpoint(&second));
3478
3479        assert_eq!(first.endpoint_id, second.endpoint_id);
3480        assert_ne!(first.connection_id, second.connection_id);
3481        assert_ne!(first, second);
3482
3483        runtime.shutdown();
3484    }
3485
3486    /// A `StreamingDispatch` that records what the router hands the seam, so the
3487    /// test can assert a *caller-supplied* dispatch (not just the default
3488    /// `AddressedPushRouter`) receives the selected address/instance and the
3489    /// discovery lifecycle events.
3490    #[derive(Default)]
3491    struct RecordingDispatch {
3492        unary: std::sync::Mutex<Vec<(u64, String, Option<u64>)>>,
3493        bidi: std::sync::Mutex<Vec<(String, u64)>>,
3494        added: std::sync::Mutex<Vec<u64>>,
3495        removed: std::sync::Mutex<Vec<u64>>,
3496    }
3497
3498    impl RecordingDispatch {
3499        fn canned_stream() -> ManyOut<TestResponse> {
3500            let ctx: Arc<dyn AsyncEngineContext> = Arc::new(Controller::default());
3501            ResponseStream::new(
3502                Box::pin(tokio_stream::iter(vec![TestResponse { error: None }])),
3503                ctx,
3504            )
3505        }
3506    }
3507
3508    #[async_trait::async_trait]
3509    impl StreamingDispatch<u64, TestResponse> for RecordingDispatch {
3510        async fn generate(
3511            &self,
3512            request: SingleIn<AddressedRequest<u64>>,
3513        ) -> Result<ManyOut<TestResponse>, Error> {
3514            let (addressed, _ctx) = request.transfer(());
3515            let (payload, address, instance) = addressed.into_parts();
3516            self.unary
3517                .lock()
3518                .unwrap()
3519                .push((payload, address, instance.map(|i| i.id())));
3520            Ok(Self::canned_stream())
3521        }
3522
3523        async fn generate_bidirectional(
3524            &self,
3525            instance: Instance,
3526            address: String,
3527            _input: ManyIn<u64>,
3528        ) -> Result<ManyOut<TestResponse>, Error> {
3529            self.bidi.lock().unwrap().push((address, instance.id()));
3530            Ok(Self::canned_stream())
3531        }
3532
3533        async fn on_instance_removed(&self, id: &EndpointInstanceId) {
3534            self.removed.lock().unwrap().push(id.instance_id);
3535        }
3536
3537        async fn on_instance_added(&self, id: &EndpointInstanceId) {
3538            self.added.lock().unwrap().push(id.instance_id);
3539        }
3540    }
3541
3542    /// The transport seam must deliver to a caller-supplied `StreamingDispatch`:
3543    /// unary and bidirectional requests arrive with the selected address and
3544    /// instance, and discovery removal/re-addition reach its lifecycle hooks.
3545    #[tokio::test]
3546    async fn from_client_with_dispatch_delivers_requests_and_lifecycle() {
3547        let rt = Runtime::from_current().unwrap();
3548        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3549            .await
3550            .unwrap();
3551        let endpoint = drt
3552            .namespace("test_dispatch_seam".to_string())
3553            .unwrap()
3554            .component("test_component".to_string())
3555            .unwrap()
3556            .endpoint("test_endpoint".to_string());
3557        let client = endpoint.client().await.unwrap();
3558
3559        endpoint.register_endpoint_instance().await.unwrap();
3560        let instance_id = client.wait_for_instances().await.unwrap()[0].id();
3561
3562        let dispatch = Arc::new(RecordingDispatch::default());
3563        let router = PushRouter::<u64, TestResponse>::from_client_with_dispatch(
3564            client.clone(),
3565            RouterMode::RoundRobin,
3566            dispatch.clone(),
3567        )
3568        .await
3569        .unwrap();
3570
3571        // Unary hop reaches the supplied dispatch with the selected worker.
3572        let mut stream = router.generate(SingleIn::new(42u64)).await.unwrap();
3573        while stream.next().await.is_some() {}
3574        {
3575            let unary = dispatch.unary.lock().unwrap();
3576            assert_eq!(unary.len(), 1, "one unary dispatch expected");
3577            let (payload, address, dispatched) = &unary[0];
3578            assert_eq!(*payload, 42);
3579            assert_eq!(*dispatched, Some(instance_id));
3580            assert!(!address.is_empty(), "selected transport address expected");
3581        }
3582
3583        // Bidirectional hop reaches the supplied dispatch with the same worker.
3584        let input: ManyIn<u64> =
3585            Context::new(RequestStream::new(Box::pin(tokio_stream::iter(vec![
3586                1u64, 2u64,
3587            ]))));
3588        let mut stream = router.generate(input).await.unwrap();
3589        while stream.next().await.is_some() {}
3590        {
3591            let bidi = dispatch.bidi.lock().unwrap();
3592            assert_eq!(bidi.len(), 1, "one bidirectional dispatch expected");
3593            assert_eq!(bidi[0].1, instance_id);
3594            assert!(!bidi[0].0.is_empty());
3595        }
3596
3597        // Gate on the initial-snapshot add before mutating discovery: this both
3598        // asserts on_instance_added is delivered and guarantees the watcher is
3599        // subscribed, so the removal broadcast can't race ahead of it.
3600        assert!(
3601            poll_until(|| dispatch.added.lock().unwrap().contains(&instance_id)).await,
3602            "on_instance_added (initial snapshot) not delivered to the supplied dispatch"
3603        );
3604
3605        endpoint.unregister_endpoint_instance().await.unwrap();
3606        assert!(
3607            poll_until(|| dispatch.removed.lock().unwrap().contains(&instance_id)).await,
3608            "on_instance_removed not delivered to the supplied dispatch"
3609        );
3610
3611        // A fresh add after re-registration must also reach the hook.
3612        let adds_before = dispatch.added.lock().unwrap().len();
3613        endpoint.register_endpoint_instance().await.unwrap();
3614        assert!(
3615            poll_until(|| dispatch.added.lock().unwrap().len() > adds_before).await,
3616            "on_instance_added (re-registration) not delivered to the supplied dispatch"
3617        );
3618
3619        rt.shutdown();
3620    }
3621
3622    #[tokio::test]
3623    async fn admitted_dispatch_does_not_reject_the_load_it_just_booked() {
3624        const TEST_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
3625
3626        let rt = Runtime::from_current().unwrap();
3627        let drt = DistributedRuntime::new(rt.clone(), DistributedConfig::process_local())
3628            .await
3629            .unwrap();
3630        let endpoint = drt
3631            .namespace("test_admitted_dispatch".to_string())
3632            .unwrap()
3633            .component("test_component".to_string())
3634            .unwrap()
3635            .endpoint("test_endpoint".to_string());
3636        let client = Client::with_reconcile_interval(endpoint.clone(), TEST_RECONCILE_INTERVAL)
3637            .await
3638            .unwrap();
3639
3640        endpoint.register_endpoint_instance().await.unwrap();
3641        let instance_id = client.wait_for_instances().await.unwrap()[0].id();
3642        for _ in 0..50 {
3643            if client.instance_ids_avail().contains(&instance_id) {
3644                break;
3645            }
3646            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3647        }
3648        assert!(client.instance_ids_avail().contains(&instance_id));
3649        let dispatch = Arc::new(RecordingDispatch::default());
3650        let router = PushRouter::<u64, TestResponse>::from_client_with_dispatch(
3651            client.clone(),
3652            RouterMode::KV,
3653            dispatch.clone(),
3654        )
3655        .await
3656        .unwrap();
3657
3658        client.set_overloaded_instances(&[instance_id]);
3659        let error = router
3660            .dispatch_exact(SingleIn::new(41), instance_id)
3661            .await
3662            .unwrap_err();
3663        assert!(match_error_chain(
3664            error.as_ref(),
3665            &[ErrorType::WorkerOverloaded],
3666            &[]
3667        ));
3668        assert!(dispatch.unary.lock().unwrap().is_empty());
3669
3670        let mut stream = router
3671            .dispatch_kv_admitted(SingleIn::new(42), instance_id)
3672            .await
3673            .unwrap();
3674        while stream.next().await.is_some() {}
3675        let unary = dispatch.unary.lock().unwrap();
3676        assert_eq!(unary.len(), 1);
3677        assert_eq!(unary[0].0, 42);
3678        assert!(!unary[0].1.is_empty());
3679        assert_eq!(unary[0].2, Some(instance_id));
3680        drop(unary);
3681
3682        rt.shutdown();
3683    }
3684
3685    /// Poll a predicate until it holds or a short deadline elapses; discovery
3686    /// events reach the watcher's spawned task asynchronously.
3687    async fn poll_until(mut pred: impl FnMut() -> bool) -> bool {
3688        for _ in 0..200 {
3689            if pred() {
3690                return true;
3691            }
3692            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3693        }
3694        pred()
3695    }
3696}