1use std::collections::{HashMap, HashSet};
4use std::future::Future;
5use std::panic::AssertUnwindSafe;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use async_trait::async_trait;
11use futures::{stream::FuturesUnordered, FutureExt, StreamExt};
12use hashtree_core::{
13 BlobReply, BlobRequest, BlobRoute, BlobRouteContext, Hash, Store, StoreError, BLOB_DEFAULT_HTL,
14 BLOB_MAX_BYTES, BLOB_MAX_HTL,
15};
16use tokio::sync::{Mutex, RwLock};
17
18const OUTCOME_DECAY: f64 = 0.875;
19
20#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct BlobRouteIdentity(String);
23
24impl BlobRouteIdentity {
25 pub fn new(identity: impl Into<String>) -> Self {
26 Self(identity.into())
27 }
28
29 pub fn as_str(&self) -> &str {
30 &self.0
31 }
32}
33
34impl From<&str> for BlobRouteIdentity {
35 fn from(value: &str) -> Self {
36 Self::new(value)
37 }
38}
39
40impl From<String> for BlobRouteIdentity {
41 fn from(value: String) -> Self {
42 Self::new(value)
43 }
44}
45
46#[derive(Clone)]
48pub struct BlobRouteEntry {
49 pub identity: BlobRouteIdentity,
50 pub route: Arc<dyn BlobRoute>,
51}
52
53impl BlobRouteEntry {
54 pub fn new(identity: impl Into<BlobRouteIdentity>, route: Arc<dyn BlobRoute>) -> Self {
55 Self {
56 identity: identity.into(),
57 route,
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug)]
64pub struct BlobRouterConfig {
65 pub request_timeout: Duration,
66 pub max_routes: usize,
67 pub max_route_attempts: usize,
68 pub route_attempt_budget: usize,
69 pub max_in_flight: usize,
70 pub hedge_delay: Duration,
71 pub initial_cooldown: Duration,
72 pub max_cooldown: Duration,
73 pub exploration_interval: u64,
74}
75
76impl Default for BlobRouterConfig {
77 fn default() -> Self {
78 Self {
79 request_timeout: Duration::from_secs(10),
80 max_routes: 32,
81 max_route_attempts: 32,
82 route_attempt_budget: 4,
83 max_in_flight: 2,
84 hedge_delay: Duration::from_millis(75),
85 initial_cooldown: Duration::from_millis(250),
86 max_cooldown: Duration::from_secs(10),
87 exploration_interval: 16,
88 }
89 }
90}
91
92#[derive(Clone, Debug, Default)]
93struct RouteOutcomes {
94 successes: f64,
95 failures: f64,
96 timeouts: f64,
97 successful_latency_ms: Option<f64>,
98 consecutive_failures: u32,
99 cooldown_until: Option<Instant>,
100 last_attempt: Option<Instant>,
101}
102
103impl RouteOutcomes {
104 fn decay(&mut self) {
105 self.successes *= OUTCOME_DECAY;
106 self.failures *= OUTCOME_DECAY;
107 self.timeouts *= OUTCOME_DECAY;
108 }
109
110 fn reliability(&self) -> f64 {
111 (self.successes + 1.0) / (self.successes + self.failures + self.timeouts + 2.0)
112 }
113
114 fn score(&self) -> f64 {
115 let latency = self
116 .successful_latency_ms
117 .map(|latency| 50.0 / (latency + 50.0))
118 .unwrap_or(0.5);
119 0.7 * self.reliability() + 0.3 * latency
120 }
121}
122
123#[derive(Default)]
124struct RouterState {
125 searches: u64,
126 outcomes: HashMap<BlobRouteIdentity, RouteOutcomes>,
127}
128
129#[derive(Clone, Debug)]
131pub struct BlobRouteOutcomeSnapshot {
132 pub successful_weight: f64,
133 pub failure_weight: f64,
134 pub timeout_weight: f64,
135 pub successful_latency_ms: Option<f64>,
136 pub cooling_down: bool,
137}
138
139enum AttemptOutcome {
140 Data {
141 identity: BlobRouteIdentity,
142 data: Vec<u8>,
143 elapsed: Duration,
144 },
145 NoResult {
146 identity: BlobRouteIdentity,
147 },
148 Failure {
149 identity: BlobRouteIdentity,
150 message: String,
151 },
152}
153
154type AttemptFuture = Pin<Box<dyn Future<Output = AttemptOutcome> + Send>>;
155
156fn launch_attempt(
157 ordered: &[BlobRouteEntry],
158 next_route: &mut usize,
159 attempted: &mut usize,
160 pending: &mut FuturesUnordered<AttemptFuture>,
161 pending_identities: &mut HashSet<BlobRouteIdentity>,
162 request: BlobRequest,
163 route_context: BlobRouteContext,
164) {
165 let entry = ordered[*next_route].clone();
166 *next_route += 1;
167 *attempted += 1;
168 pending_identities.insert(entry.identity.clone());
169 pending.push(
170 async move {
171 let started = Instant::now();
172 let result = AssertUnwindSafe(entry.route.route_with_context(request, route_context))
173 .catch_unwind()
174 .await;
175 match result {
176 Ok(Ok(BlobReply::Data(data))) => AttemptOutcome::Data {
177 identity: entry.identity,
178 data,
179 elapsed: started.elapsed(),
180 },
181 Ok(Ok(BlobReply::NoResult)) => AttemptOutcome::NoResult {
182 identity: entry.identity,
183 },
184 Ok(Err(error)) => AttemptOutcome::Failure {
185 identity: entry.identity,
186 message: error.to_string(),
187 },
188 Err(_) => AttemptOutcome::Failure {
189 identity: entry.identity,
190 message: "route panicked".to_string(),
191 },
192 }
193 }
194 .boxed(),
195 );
196}
197
198pub struct BlobRouter {
201 routes: RwLock<Vec<BlobRouteEntry>>,
202 state: Mutex<RouterState>,
203 cache: Option<Arc<dyn Store>>,
204 config: BlobRouterConfig,
205}
206
207impl BlobRouter {
208 pub fn new(
209 routes: Vec<BlobRouteEntry>,
210 cache: Option<Arc<dyn Store>>,
211 config: BlobRouterConfig,
212 ) -> Result<Self, StoreError> {
213 validate_routes(&routes, config.max_routes)?;
214 let outcomes = routes
215 .iter()
216 .map(|entry| (entry.identity.clone(), RouteOutcomes::default()))
217 .collect();
218 Ok(Self {
219 routes: RwLock::new(routes),
220 state: Mutex::new(RouterState {
221 searches: 0,
222 outcomes,
223 }),
224 cache,
225 config,
226 })
227 }
228
229 pub async fn set_routes(&self, routes: Vec<BlobRouteEntry>) -> Result<(), StoreError> {
233 validate_routes(&routes, self.config.max_routes)?;
234 let mut current = self.routes.write().await;
235 let old_routes: HashMap<_, _> = current
236 .iter()
237 .map(|entry| (entry.identity.clone(), Arc::clone(&entry.route)))
238 .collect();
239 let mut state = self.state.lock().await;
240 state.outcomes.retain(|identity, _| {
241 routes.iter().any(|entry| {
242 &entry.identity == identity
243 && old_routes
244 .get(identity)
245 .is_some_and(|old| Arc::ptr_eq(old, &entry.route))
246 })
247 });
248 for entry in &routes {
249 state.outcomes.entry(entry.identity.clone()).or_default();
250 }
251 *current = routes;
252 Ok(())
253 }
254
255 pub async fn route_count(&self) -> usize {
256 self.routes.read().await.len()
257 }
258
259 pub async fn outcomes(&self) -> HashMap<BlobRouteIdentity, BlobRouteOutcomeSnapshot> {
260 let now = Instant::now();
261 self.state
262 .lock()
263 .await
264 .outcomes
265 .iter()
266 .map(|(identity, outcome)| {
267 (
268 identity.clone(),
269 BlobRouteOutcomeSnapshot {
270 successful_weight: outcome.successes,
271 failure_weight: outcome.failures,
272 timeout_weight: outcome.timeouts,
273 successful_latency_ms: outcome.successful_latency_ms,
274 cooling_down: outcome.cooldown_until.is_some_and(|until| until > now),
275 },
276 )
277 })
278 .collect()
279 }
280
281 pub async fn get(
282 &self,
283 hash: &Hash,
284 preferred: Option<&[BlobRouteIdentity]>,
285 ) -> Result<Option<Vec<u8>>, StoreError> {
286 self.get_request(
287 BlobRequest {
288 hash: *hash,
289 htl: BLOB_DEFAULT_HTL,
290 },
291 preferred,
292 None,
293 )
294 .await
295 }
296
297 pub async fn get_request(
298 &self,
299 request: BlobRequest,
300 preferred: Option<&[BlobRouteIdentity]>,
301 outer_context: Option<BlobRouteContext>,
302 ) -> Result<Option<Vec<u8>>, StoreError> {
303 if request.htl > BLOB_MAX_HTL {
304 return Err(StoreError::Other(format!(
305 "Hashtree blob HTL {} exceeds the maximum of {BLOB_MAX_HTL}",
306 request.htl
307 )));
308 }
309
310 let now = Instant::now();
311 let own_deadline = now + self.config.request_timeout;
312 let deadline = outer_context
313 .map(|context| context.deadline.min(own_deadline))
314 .unwrap_or(own_deadline);
315 if deadline <= now {
316 return Err(StoreError::Other(
317 "blob retrieval deadline expired before the search started".to_string(),
318 ));
319 }
320 let outer_budget = outer_context
321 .map(|context| context.attempt_budget)
322 .unwrap_or(self.config.max_route_attempts);
323 let max_attempts = self.config.max_route_attempts.min(outer_budget);
324 if max_attempts == 0 {
325 return Err(StoreError::Other(
326 "blob retrieval has no route attempt budget".to_string(),
327 ));
328 }
329
330 let ordered = self.ordered_routes(preferred).await;
331 if ordered.is_empty() {
332 return Ok(None);
333 }
334 let max_attempts = max_attempts.min(ordered.len());
335 let max_in_flight = self.config.max_in_flight.max(1).min(max_attempts);
336 let route_context = BlobRouteContext {
337 deadline,
338 attempt_budget: self.config.route_attempt_budget.min(outer_budget).max(1),
339 };
340
341 let mut pending: FuturesUnordered<AttemptFuture> = FuturesUnordered::new();
342 let mut pending_identities = HashSet::new();
343 let mut next_route = 0usize;
344 let mut attempted = 0usize;
345 let mut failures = Vec::new();
346 launch_attempt(
347 &ordered,
348 &mut next_route,
349 &mut attempted,
350 &mut pending,
351 &mut pending_identities,
352 request,
353 route_context,
354 );
355 let mut next_hedge = Instant::now() + self.config.hedge_delay;
356 let deadline_sleep = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline));
357 tokio::pin!(deadline_sleep);
358
359 loop {
360 if pending.is_empty() {
361 if attempted < max_attempts {
362 launch_attempt(
363 &ordered,
364 &mut next_route,
365 &mut attempted,
366 &mut pending,
367 &mut pending_identities,
368 request,
369 route_context,
370 );
371 next_hedge = Instant::now() + self.config.hedge_delay;
372 } else {
373 break;
374 }
375 }
376
377 let hedge_at = tokio::time::Instant::from_std(next_hedge.min(deadline));
378 tokio::select! {
379 _ = &mut deadline_sleep => {
380 for identity in pending_identities.drain() {
381 self.record_timeout(&identity).await;
382 }
383 return Err(StoreError::Other(
384 "blob retrieval deadline expired before the search completed".to_string(),
385 ));
386 }
387 outcome = pending.next() => {
388 let Some(outcome) = outcome else { continue };
389 let identity = match &outcome {
390 AttemptOutcome::Data { identity, .. }
391 | AttemptOutcome::NoResult { identity }
392 | AttemptOutcome::Failure { identity, .. } => identity.clone(),
393 };
394 pending_identities.remove(&identity);
395 match outcome {
396 AttemptOutcome::Data { identity, data, elapsed } => {
397 if data.len() > BLOB_MAX_BYTES {
398 let message = format!(
399 "route {} returned {} bytes, exceeding the {BLOB_MAX_BYTES}-byte limit",
400 identity.as_str(),
401 data.len()
402 );
403 self.record_failure(&identity).await;
404 failures.push(message);
405 } else if hashtree_core::sha256(&data) != request.hash {
406 let message = format!(
407 "route {} returned content with the wrong hash",
408 identity.as_str()
409 );
410 self.record_failure(&identity).await;
411 failures.push(message);
412 } else {
413 self.record_success(&identity, elapsed).await;
414 if let Some(cache) = &self.cache {
415 let _ = cache.put(request.hash, data.clone()).await;
416 }
417 return Ok(Some(data));
418 }
419 }
420 AttemptOutcome::NoResult { identity } => {
421 self.record_no_result(&identity).await;
422 }
423 AttemptOutcome::Failure { identity, message } => {
424 self.record_failure(&identity).await;
425 failures.push(format!("route {}: {message}", identity.as_str()));
426 }
427 }
428 if attempted < max_attempts && pending.len() < max_in_flight {
429 launch_attempt(
430 &ordered,
431 &mut next_route,
432 &mut attempted,
433 &mut pending,
434 &mut pending_identities,
435 request,
436 route_context,
437 );
438 next_hedge = Instant::now() + self.config.hedge_delay;
439 }
440 }
441 _ = tokio::time::sleep_until(hedge_at), if attempted < max_attempts && pending.len() < max_in_flight => {
442 launch_attempt(
443 &ordered,
444 &mut next_route,
445 &mut attempted,
446 &mut pending,
447 &mut pending_identities,
448 request,
449 route_context,
450 );
451 next_hedge = Instant::now() + self.config.hedge_delay;
452 }
453 }
454 }
455
456 if attempted < ordered.len() {
457 return Err(StoreError::Other(format!(
458 "blob retrieval exhausted its bounded route attempt budget ({attempted}/{})",
459 ordered.len()
460 )));
461 }
462 if let Some(message) = failures.into_iter().next() {
463 return Err(StoreError::Other(format!(
464 "blob retrieval was incomplete: {message}"
465 )));
466 }
467 Ok(None)
468 }
469
470 async fn ordered_routes(&self, preferred: Option<&[BlobRouteIdentity]>) -> Vec<BlobRouteEntry> {
471 let routes = self.routes.read().await.clone();
472 let preferred_order: HashMap<_, _> = preferred
473 .unwrap_or_default()
474 .iter()
475 .enumerate()
476 .map(|(index, identity)| (identity.clone(), index))
477 .collect();
478 let now = Instant::now();
479 let mut state = self.state.lock().await;
480 state.searches = state.searches.wrapping_add(1);
481 let explore = self.config.exploration_interval > 0
482 && state
483 .searches
484 .is_multiple_of(self.config.exploration_interval);
485 let mut ordered = routes;
486 ordered.sort_by(|left, right| {
487 match (
488 preferred_order.get(&left.identity),
489 preferred_order.get(&right.identity),
490 ) {
491 (Some(left), Some(right)) => return left.cmp(right),
492 (Some(_), None) => return std::cmp::Ordering::Less,
493 (None, Some(_)) => return std::cmp::Ordering::Greater,
494 (None, None) => {}
495 }
496 let left_stats = state
497 .outcomes
498 .get(&left.identity)
499 .cloned()
500 .unwrap_or_default();
501 let right_stats = state
502 .outcomes
503 .get(&right.identity)
504 .cloned()
505 .unwrap_or_default();
506 if explore {
507 return left_stats
508 .last_attempt
509 .cmp(&right_stats.last_attempt)
510 .then_with(|| left.identity.cmp(&right.identity));
511 }
512 let left_cooling = left_stats.cooldown_until.is_some_and(|until| until > now);
513 let right_cooling = right_stats.cooldown_until.is_some_and(|until| until > now);
514 left_cooling
515 .cmp(&right_cooling)
516 .then_with(|| {
517 right_stats
518 .score()
519 .partial_cmp(&left_stats.score())
520 .unwrap_or(std::cmp::Ordering::Equal)
521 })
522 .then_with(|| left_stats.last_attempt.cmp(&right_stats.last_attempt))
523 .then_with(|| left.identity.cmp(&right.identity))
524 });
525 ordered
526 }
527
528 async fn record_success(&self, identity: &BlobRouteIdentity, elapsed: Duration) {
529 let mut state = self.state.lock().await;
530 let outcome = state.outcomes.entry(identity.clone()).or_default();
531 outcome.decay();
532 outcome.successes += 1.0;
533 let elapsed_ms = elapsed.as_secs_f64() * 1_000.0;
534 outcome.successful_latency_ms = Some(
535 outcome
536 .successful_latency_ms
537 .map(|old| 0.875 * old + 0.125 * elapsed_ms)
538 .unwrap_or(elapsed_ms),
539 );
540 outcome.consecutive_failures = 0;
541 outcome.cooldown_until = None;
542 outcome.last_attempt = Some(Instant::now());
543 }
544
545 async fn record_no_result(&self, identity: &BlobRouteIdentity) {
546 let mut state = self.state.lock().await;
547 let outcome = state.outcomes.entry(identity.clone()).or_default();
548 outcome.decay();
549 outcome.consecutive_failures = 0;
550 outcome.cooldown_until = None;
551 outcome.last_attempt = Some(Instant::now());
552 }
553
554 async fn record_failure(&self, identity: &BlobRouteIdentity) {
555 let mut state = self.state.lock().await;
556 let outcome = state.outcomes.entry(identity.clone()).or_default();
557 outcome.decay();
558 outcome.failures += 1.0;
559 self.apply_cooldown(outcome);
560 }
561
562 async fn record_timeout(&self, identity: &BlobRouteIdentity) {
563 let mut state = self.state.lock().await;
564 let outcome = state.outcomes.entry(identity.clone()).or_default();
565 outcome.decay();
566 outcome.timeouts += 1.0;
567 self.apply_cooldown(outcome);
568 }
569
570 fn apply_cooldown(&self, outcome: &mut RouteOutcomes) {
571 outcome.consecutive_failures = outcome.consecutive_failures.saturating_add(1);
572 let multiplier =
573 2u32.saturating_pow(outcome.consecutive_failures.saturating_sub(1).min(16));
574 let cooldown = self
575 .config
576 .initial_cooldown
577 .saturating_mul(multiplier)
578 .min(self.config.max_cooldown);
579 let now = Instant::now();
580 outcome.cooldown_until = Some(now + cooldown);
581 outcome.last_attempt = Some(now);
582 }
583}
584
585#[async_trait]
586impl BlobRoute for BlobRouter {
587 async fn route(&self, request: BlobRequest) -> Result<BlobReply, StoreError> {
588 Ok(match self.get_request(request, None, None).await? {
589 Some(data) => BlobReply::Data(data),
590 None => BlobReply::NoResult,
591 })
592 }
593
594 async fn route_with_context(
595 &self,
596 request: BlobRequest,
597 context: BlobRouteContext,
598 ) -> Result<BlobReply, StoreError> {
599 Ok(
600 match self.get_request(request, None, Some(context)).await? {
601 Some(data) => BlobReply::Data(data),
602 None => BlobReply::NoResult,
603 },
604 )
605 }
606}
607
608fn validate_routes(routes: &[BlobRouteEntry], max_routes: usize) -> Result<(), StoreError> {
609 if routes.len() > max_routes {
610 return Err(StoreError::Other(format!(
611 "blob router has {} routes, exceeding its bound of {max_routes}",
612 routes.len()
613 )));
614 }
615 let mut identities = HashSet::with_capacity(routes.len());
616 for entry in routes {
617 if entry.identity.as_str().is_empty() {
618 return Err(StoreError::Other(
619 "blob route identity must not be empty".to_string(),
620 ));
621 }
622 if !identities.insert(entry.identity.clone()) {
623 return Err(StoreError::Other(format!(
624 "duplicate blob route identity {}",
625 entry.identity.as_str()
626 )));
627 }
628 }
629 Ok(())
630}