Skip to main content

substrate_core/
routing_port.rs

1//! Routing superset — load-balancing strategies, circuit breakers, weighted fallback.
2//!
3//! Pure types and selection logic live here (no I/O). Adapters such as
4//! `routing-phenotype-router` and the legacy `omniroute-adapter` wire these
5//! primitives to real providers.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::domain::RoutingDecision;
12use crate::error::{Result, SubstrateError};
13
14/// Load-balancing strategy over a pool of candidate targets.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum RoutingStrategy {
17    /// Cycle through healthy targets in order.
18    RoundRobin,
19    /// Select proportionally to each target's `weight`.
20    Weighted,
21    /// Pick the healthy target with the lowest `in_flight` count.
22    LeastUsed,
23    /// Sample two healthy targets and pick the one with lower load.
24    PowerOfTwoChoices,
25}
26
27/// Circuit breaker state for a single target.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum CircuitState {
30    /// Requests flow normally.
31    Closed,
32    /// Requests are rejected until the reset timeout elapses.
33    Open,
34    /// A single probe request is allowed after the reset timeout.
35    HalfOpen,
36}
37
38/// Thresholds controlling circuit-breaker transitions.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CircuitBreakerConfig {
41    /// Consecutive failures in `Closed` before opening the circuit.
42    pub failure_threshold: u32,
43    /// Seconds the circuit stays `Open` before lazy recovery to `HalfOpen`.
44    pub reset_timeout_secs: u64,
45}
46
47impl Default for CircuitBreakerConfig {
48    fn default() -> Self {
49        CircuitBreakerConfig {
50            failure_threshold: 5,
51            reset_timeout_secs: 30,
52        }
53    }
54}
55
56/// Per-target circuit breaker with lazy recovery on read.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct CircuitBreaker {
59    state: CircuitState,
60    consecutive_failures: u32,
61    opened_at_secs: Option<u64>,
62    config: CircuitBreakerConfig,
63}
64
65impl CircuitBreaker {
66    /// Create a breaker in the `Closed` state.
67    pub fn new(config: CircuitBreakerConfig) -> Self {
68        CircuitBreaker {
69            state: CircuitState::Closed,
70            consecutive_failures: 0,
71            opened_at_secs: None,
72            config,
73        }
74    }
75
76    /// Current stored state (does not apply lazy timeout recovery).
77    pub fn state(&self) -> CircuitState {
78        self.state
79    }
80
81    /// Consecutive failure count (for tests and diagnostics).
82    pub fn consecutive_failures(&self) -> u32 {
83        self.consecutive_failures
84    }
85
86    /// Effective state after applying lazy open→half-open recovery.
87    pub fn effective_state(&self, now_secs: u64) -> CircuitState {
88        match self.state {
89            CircuitState::Open => {
90                if let Some(opened) = self.opened_at_secs {
91                    if now_secs.saturating_sub(opened) >= self.config.reset_timeout_secs {
92                        CircuitState::HalfOpen
93                    } else {
94                        CircuitState::Open
95                    }
96                } else {
97                    CircuitState::Open
98                }
99            }
100            other => other,
101        }
102    }
103
104    /// Whether a request may be sent to this target at `now_secs`.
105    pub fn allow_request(&self, now_secs: u64) -> bool {
106        matches!(
107            self.effective_state(now_secs),
108            CircuitState::Closed | CircuitState::HalfOpen
109        )
110    }
111
112    /// Record a successful response.
113    pub fn record_success(&mut self, now_secs: u64) {
114        match self.effective_state(now_secs) {
115            CircuitState::Closed | CircuitState::HalfOpen => {
116                self.state = CircuitState::Closed;
117                self.consecutive_failures = 0;
118                self.opened_at_secs = None;
119            }
120            CircuitState::Open => {}
121        }
122    }
123
124    /// Record a failed response.
125    pub fn record_failure(&mut self, now_secs: u64) {
126        match self.effective_state(now_secs) {
127            CircuitState::Closed => {
128                self.consecutive_failures += 1;
129                if self.consecutive_failures >= self.config.failure_threshold {
130                    self.state = CircuitState::Open;
131                    self.opened_at_secs = Some(now_secs);
132                }
133            }
134            CircuitState::HalfOpen => {
135                self.state = CircuitState::Open;
136                self.opened_at_secs = Some(now_secs);
137                self.consecutive_failures = self.config.failure_threshold;
138            }
139            CircuitState::Open => {}
140        }
141    }
142}
143
144/// A routable engine/model target in a load-balanced pool.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RoutingTarget {
147    /// Stable identifier for health tracking.
148    pub id: String,
149    /// Engine name (e.g. `"forge"`).
150    pub engine: String,
151    /// Model identifier passed to the engine.
152    pub model: String,
153    /// Relative weight for [`RoutingStrategy::Weighted`].
154    pub weight: u32,
155}
156
157/// One entry in an ordered fallback chain.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct FallbackEntry {
160    /// Lower rank = higher priority. Entries at the same rank compete by weight.
161    pub rank: u32,
162    /// The target to route to when this entry is selected.
163    pub target: RoutingTarget,
164    /// Weight among healthy entries sharing the same `rank`.
165    pub weight: u32,
166}
167
168/// Health and load counters for a single target.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct TargetHealth {
171    /// Circuit breaker for this target.
172    pub breaker: CircuitBreaker,
173    /// In-flight request count (for least-used / P2C).
174    pub in_flight: u64,
175}
176
177impl Default for TargetHealth {
178    fn default() -> Self {
179        TargetHealth {
180            breaker: CircuitBreaker::new(CircuitBreakerConfig::default()),
181            in_flight: 0,
182        }
183    }
184}
185
186/// Mutable routing pool state (cursors, counters, per-target health).
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct RoutingPoolState {
189    /// Cursor for round-robin among healthy indices.
190    pub round_robin_cursor: usize,
191    /// Monotonic counter driving weighted / P2C selection.
192    pub selection_counter: u64,
193    /// Per-target health keyed by target id.
194    pub health: HashMap<String, TargetHealth>,
195}
196
197impl RoutingPoolState {
198    /// Ensure every target in `pool` has a health entry.
199    pub fn ensure_targets(&mut self, pool: &[RoutingTarget], config: CircuitBreakerConfig) {
200        for t in pool {
201            self.health
202                .entry(t.id.clone())
203                .or_insert_with(|| TargetHealth {
204                    breaker: CircuitBreaker::new(config),
205                    in_flight: 0,
206                });
207        }
208    }
209
210    fn health_for<'a>(&'a self, id: &str) -> Option<&'a TargetHealth> {
211        self.health.get(id)
212    }
213}
214
215/// Pure selection helpers — deterministic given inputs and mutable state.
216#[derive(Debug, Clone, Copy, Default)]
217pub struct RoutingSelector;
218
219impl RoutingSelector {
220    /// Select a pool index using `strategy`. Returns `None` if no healthy target exists.
221    pub fn select(
222        strategy: RoutingStrategy,
223        pool: &[RoutingTarget],
224        state: &mut RoutingPoolState,
225        now_secs: u64,
226        p2c_seed: u64,
227    ) -> Option<usize> {
228        let healthy: Vec<usize> = pool
229            .iter()
230            .enumerate()
231            .filter(|(_, t)| {
232                state
233                    .health_for(&t.id)
234                    .map(|h| h.breaker.allow_request(now_secs))
235                    .unwrap_or(true)
236            })
237            .map(|(i, _)| i)
238            .collect();
239
240        if healthy.is_empty() {
241            return None;
242        }
243
244        let pick = match strategy {
245            RoutingStrategy::RoundRobin => {
246                let cursor = state.round_robin_cursor % healthy.len();
247                state.round_robin_cursor = state.round_robin_cursor.wrapping_add(1);
248                healthy[cursor]
249            }
250            RoutingStrategy::Weighted => {
251                let weights: Vec<u32> = healthy.iter().map(|&i| pool[i].weight.max(1)).collect();
252                let total: u32 = weights.iter().sum();
253                let mut pick_weight = (state.selection_counter % total as u64) as u32;
254                state.selection_counter = state.selection_counter.wrapping_add(1);
255                let mut chosen = healthy[0];
256                for (idx, &w) in weights.iter().enumerate() {
257                    if pick_weight < w {
258                        chosen = healthy[idx];
259                        break;
260                    }
261                    pick_weight -= w;
262                }
263                chosen
264            }
265            RoutingStrategy::LeastUsed => {
266                let mut best = healthy[0];
267                let mut best_load = u64::MAX;
268                for &i in &healthy {
269                    let load = state
270                        .health_for(&pool[i].id)
271                        .map(|h| h.in_flight)
272                        .unwrap_or(0);
273                    if load < best_load {
274                        best_load = load;
275                        best = i;
276                    }
277                }
278                best
279            }
280            RoutingStrategy::PowerOfTwoChoices => {
281                let n = healthy.len();
282                if n == 1 {
283                    healthy[0]
284                } else {
285                    let seed = state.selection_counter.wrapping_add(p2c_seed);
286                    state.selection_counter = state.selection_counter.wrapping_add(1);
287                    let i = (seed as usize) % n;
288                    let j = (seed.wrapping_mul(31).wrapping_add(17) as usize) % n;
289                    let (a, b) = if i == j {
290                        (healthy[i], healthy[(i + 1) % n])
291                    } else {
292                        (healthy[i], healthy[j])
293                    };
294                    let load_a = state
295                        .health_for(&pool[a].id)
296                        .map(|h| h.in_flight)
297                        .unwrap_or(0);
298                    let load_b = state
299                        .health_for(&pool[b].id)
300                        .map(|h| h.in_flight)
301                        .unwrap_or(0);
302                    if load_a <= load_b {
303                        a
304                    } else {
305                        b
306                    }
307                }
308            }
309        };
310
311        Some(pick)
312    }
313
314    /// Walk the fallback chain by rank; within each rank, weighted-select among healthy entries.
315    pub fn select_fallback<'a>(
316        chain: &'a [FallbackEntry],
317        health: &HashMap<String, TargetHealth>,
318        counter: &mut u64,
319        now_secs: u64,
320    ) -> Option<&'a RoutingTarget> {
321        if chain.is_empty() {
322            return None;
323        }
324        let min_rank = chain.iter().map(|e| e.rank).min().unwrap();
325        let max_rank = chain.iter().map(|e| e.rank).max().unwrap();
326
327        for rank in min_rank..=max_rank {
328            let tier: Vec<&FallbackEntry> = chain
329                .iter()
330                .filter(|e| e.rank == rank)
331                .filter(|e| {
332                    health
333                        .get(&e.target.id)
334                        .map(|h| h.breaker.allow_request(now_secs))
335                        .unwrap_or(true)
336                })
337                .collect();
338            if tier.is_empty() {
339                continue;
340            }
341            if tier.len() == 1 {
342                return Some(&tier[0].target);
343            }
344            let weights: Vec<u32> = tier.iter().map(|e| e.weight.max(1)).collect();
345            let total: u32 = weights.iter().sum();
346            let mut pick = (*counter % total as u64) as u32;
347            *counter = counter.wrapping_add(1);
348            for (entry, w) in tier.iter().zip(weights.iter()) {
349                if pick < *w {
350                    return Some(&entry.target);
351                }
352                pick -= w;
353            }
354            return Some(&tier[0].target);
355        }
356        None
357    }
358}
359
360/// A routing decision enriched with the chosen target id.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct SupersetRoutingDecision {
363    /// Chosen target id (for outcome recording).
364    pub target_id: String,
365    /// Engine + model decision for downstream engines.
366    pub decision: RoutingDecision,
367}
368
369impl SupersetRoutingDecision {
370    fn from_target(target: &RoutingTarget, reason: impl Into<String>) -> Self {
371        SupersetRoutingDecision {
372            target_id: target.id.clone(),
373            decision: RoutingDecision {
374                engine: target.engine.clone(),
375                model: target.model.clone(),
376                reason: Some(reason.into()),
377            },
378        }
379    }
380}
381
382/// Bundled routing superset: pool load-balancing with fallback chain and health tracking.
383#[derive(Debug, Clone)]
384pub struct RoutingSuperset {
385    pool: Vec<RoutingTarget>,
386    fallback: Vec<FallbackEntry>,
387    strategy: RoutingStrategy,
388    breaker_config: CircuitBreakerConfig,
389    state: RoutingPoolState,
390}
391
392impl RoutingSuperset {
393    /// Create a new superset router.
394    pub fn new(
395        pool: Vec<RoutingTarget>,
396        fallback: Vec<FallbackEntry>,
397        strategy: RoutingStrategy,
398        breaker_config: CircuitBreakerConfig,
399    ) -> Self {
400        let mut state = RoutingPoolState::default();
401        state.ensure_targets(&pool, breaker_config);
402        for entry in &fallback {
403            state
404                .health
405                .entry(entry.target.id.clone())
406                .or_insert_with(|| TargetHealth {
407                    breaker: CircuitBreaker::new(breaker_config),
408                    in_flight: 0,
409                });
410        }
411        RoutingSuperset {
412            pool,
413            fallback,
414            strategy,
415            breaker_config,
416            state,
417        }
418    }
419
420    /// Select a target and return a routing decision. Increments in-flight for the chosen target.
421    pub fn route(&mut self, now_secs: u64) -> Result<SupersetRoutingDecision> {
422        if let Some(idx) =
423            RoutingSelector::select(self.strategy, &self.pool, &mut self.state, now_secs, 0)
424        {
425            let target = &self.pool[idx];
426            if let Some(h) = self.state.health.get_mut(&target.id) {
427                h.in_flight += 1;
428            }
429            return Ok(SupersetRoutingDecision::from_target(
430                target,
431                format!("routing-superset:{strategy:?}", strategy = self.strategy),
432            ));
433        }
434
435        if let Some(target) = RoutingSelector::select_fallback(
436            &self.fallback,
437            &self.state.health,
438            &mut self.state.selection_counter,
439            now_secs,
440        ) {
441            if let Some(h) = self.state.health.get_mut(&target.id) {
442                h.in_flight += 1;
443            }
444            return Ok(SupersetRoutingDecision::from_target(
445                target,
446                "routing-superset:fallback",
447            ));
448        }
449
450        Err(SubstrateError::Routing(
451            "no healthy routing target available".to_string(),
452        ))
453    }
454
455    /// Record success or failure for a previously routed target.
456    pub fn record_outcome(&mut self, target_id: &str, success: bool, now_secs: u64) {
457        if let Some(h) = self.state.health.get_mut(target_id) {
458            h.in_flight = h.in_flight.saturating_sub(1);
459            if success {
460                h.breaker.record_success(now_secs);
461            } else {
462                h.breaker.record_failure(now_secs);
463            }
464        }
465    }
466
467    /// Current load-balancing strategy.
468    pub fn strategy(&self) -> RoutingStrategy {
469        self.strategy
470    }
471
472    /// Primary target pool.
473    pub fn pool(&self) -> &[RoutingTarget] {
474        &self.pool
475    }
476
477    /// Fallback chain entries.
478    pub fn fallback(&self) -> &[FallbackEntry] {
479        &self.fallback
480    }
481
482    /// Breaker configuration applied to new health entries.
483    pub fn breaker_config(&self) -> CircuitBreakerConfig {
484        self.breaker_config
485    }
486}