1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::domain::RoutingDecision;
12use crate::error::{Result, SubstrateError};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum RoutingStrategy {
17 RoundRobin,
19 Weighted,
21 LeastUsed,
23 PowerOfTwoChoices,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum CircuitState {
30 Closed,
32 Open,
34 HalfOpen,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CircuitBreakerConfig {
41 pub failure_threshold: u32,
43 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#[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 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 pub fn state(&self) -> CircuitState {
78 self.state
79 }
80
81 pub fn consecutive_failures(&self) -> u32 {
83 self.consecutive_failures
84 }
85
86 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RoutingTarget {
147 pub id: String,
149 pub engine: String,
151 pub model: String,
153 pub weight: u32,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct FallbackEntry {
160 pub rank: u32,
162 pub target: RoutingTarget,
164 pub weight: u32,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct TargetHealth {
171 pub breaker: CircuitBreaker,
173 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct RoutingPoolState {
189 pub round_robin_cursor: usize,
191 pub selection_counter: u64,
193 pub health: HashMap<String, TargetHealth>,
195}
196
197impl RoutingPoolState {
198 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#[derive(Debug, Clone, Copy, Default)]
217pub struct RoutingSelector;
218
219impl RoutingSelector {
220 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct SupersetRoutingDecision {
363 pub target_id: String,
365 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#[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 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 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 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 pub fn strategy(&self) -> RoutingStrategy {
469 self.strategy
470 }
471
472 pub fn pool(&self) -> &[RoutingTarget] {
474 &self.pool
475 }
476
477 pub fn fallback(&self) -> &[FallbackEntry] {
479 &self.fallback
480 }
481
482 pub fn breaker_config(&self) -> CircuitBreakerConfig {
484 self.breaker_config
485 }
486}