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