1use std::{
10 collections::{HashMap, HashSet},
11 sync::Arc,
12 time::{Instant, SystemTime, UNIX_EPOCH},
13};
14
15use async_trait::async_trait;
16use futures::future::join_all;
17use metrics::{counter, gauge, histogram};
18use tokio::sync::{broadcast, RwLock};
19use tracing::{error, info, trace, warn};
20use tycho_simulation::tycho_common::models::Address;
21
22use crate::types::ComponentId;
23
24#[derive(Debug, Clone, Default)]
29pub struct ChangedComponents {
30 pub added: HashMap<ComponentId, Vec<Address>>,
32 pub removed: Vec<ComponentId>,
34 pub updated: Vec<ComponentId>,
36 pub is_full_recompute: bool,
38}
39
40impl ChangedComponents {
41 pub fn is_topology_change(&self) -> bool {
43 !self.added.is_empty() || !self.removed.is_empty()
44 }
45
46 pub fn all_changed_ids(&self) -> HashSet<ComponentId> {
48 let mut all = HashSet::new();
49 all.extend(self.added.keys().cloned());
50 all.extend(self.removed.iter().cloned());
51 all.extend(self.updated.iter().cloned());
52 all
53 }
54}
55
56fn coalesce_market_events(events: &[MarketEvent]) -> Option<ChangedComponents> {
65 let mut added: HashMap<ComponentId, Vec<Address>> = HashMap::new();
66 let mut removed: HashSet<ComponentId> = HashSet::new();
67 let mut updated: HashSet<ComponentId> = HashSet::new();
68
69 for event in events {
70 match event {
71 MarketEvent::MarketUpdated {
72 added_components,
73 removed_components,
74 updated_components,
75 } => {
76 for (id, tokens) in added_components {
77 removed.remove(id);
78 updated.remove(id);
79 added.insert(id.clone(), tokens.clone());
80 }
81 for id in removed_components {
82 added.remove(id);
83 updated.remove(id);
84 removed.insert(id.clone());
85 }
86 for id in updated_components {
87 if !added.contains_key(id) && !removed.contains(id) {
88 updated.insert(id.clone());
89 }
90 }
91 }
92 }
93 }
94
95 if added.is_empty() && removed.is_empty() && updated.is_empty() {
96 return None;
97 }
98 Some(ChangedComponents {
99 added,
100 removed: removed.into_iter().collect(),
101 updated: updated.into_iter().collect(),
102 is_full_recompute: false,
103 })
104}
105
106use super::{
107 computation::{ComputationId, ComputationRequirements, DerivedComputation},
108 computations::{ComponentDepthComputation, SpotPriceComputation, TokenGasPriceComputation},
109 error::ComputationError,
110 events::DerivedDataEvent,
111 registry::ErasedComputation,
112 store::DerivedData,
113};
114use crate::feed::{
115 events::{EventError, MarketEvent, MarketEventHandler},
116 market_data::MarketData,
117};
118
119pub type SharedDerivedDataRef = Arc<RwLock<DerivedData>>;
121
122#[derive(Debug, Clone)]
124pub struct ComputationManagerConfig {
125 gas_token: Address,
127 max_hop: usize,
129 depth_slippage_threshold: f64,
131}
132
133impl ComputationManagerConfig {
134 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn with_depth_slippage_threshold(mut self, threshold: f64) -> Self {
141 self.depth_slippage_threshold = threshold;
142 self
143 }
144
145 pub fn with_max_hop(mut self, hop_count: usize) -> Self {
147 self.max_hop = hop_count;
148 self
149 }
150
151 pub fn with_gas_token(mut self, gas_token: Address) -> Self {
153 self.gas_token = gas_token;
154 self
155 }
156
157 pub fn gas_token(&self) -> &Address {
159 &self.gas_token
160 }
161
162 pub fn max_hop(&self) -> usize {
164 self.max_hop
165 }
166
167 pub fn depth_slippage_threshold(&self) -> f64 {
169 self.depth_slippage_threshold
170 }
171}
172
173impl Default for ComputationManagerConfig {
174 fn default() -> Self {
175 Self { gas_token: Address::zero(20), max_hop: 2, depth_slippage_threshold: 0.01 }
176 }
177}
178
179pub struct ComputationManager {
181 market_data: MarketData,
183 store: SharedDerivedDataRef,
185 computations: Vec<Box<dyn ErasedComputation>>,
187 event_tx: broadcast::Sender<DerivedDataEvent>,
189}
190
191struct ComputationSchedule {
193 stages: Vec<Vec<usize>>,
195 unscheduled: Vec<usize>,
197}
198
199impl ComputationManager {
200 pub fn new(
206 config: ComputationManagerConfig,
207 market_data: MarketData,
208 ) -> Result<(Self, broadcast::Receiver<DerivedDataEvent>), ComputationError> {
209 let (mut manager, event_rx) = Self::empty(market_data);
210 manager.register(SpotPriceComputation::new())?;
211 manager.register(
212 TokenGasPriceComputation::default()
213 .with_max_hops(config.max_hop)
214 .with_gas_token(config.gas_token),
215 )?;
216 manager.register(ComponentDepthComputation::new(config.depth_slippage_threshold)?)?;
217 Ok((manager, event_rx))
218 }
219
220 pub(crate) fn empty(market_data: MarketData) -> (Self, broadcast::Receiver<DerivedDataEvent>) {
225 let (event_tx, event_rx) = broadcast::channel(64);
226 (
227 Self {
228 market_data,
229 store: DerivedData::new_shared(),
230 computations: Vec::new(),
231 event_tx,
232 },
233 event_rx,
234 )
235 }
236
237 pub(crate) fn register<C: DerivedComputation>(
248 &mut self,
249 computation: C,
250 ) -> Result<(), ComputationError> {
251 if self
252 .computations
253 .iter()
254 .any(|existing| existing.id() == C::ID)
255 {
256 return Err(ComputationError::DuplicateComputationId(C::ID));
257 }
258 self.computations
259 .push(Box::new(computation));
260 Ok(())
261 }
262
263 pub fn store(&self) -> SharedDerivedDataRef {
265 Arc::clone(&self.store)
266 }
267
268 pub fn event_sender(&self) -> broadcast::Sender<DerivedDataEvent> {
270 self.event_tx.clone()
271 }
272
273 pub async fn run(
277 mut self,
278 mut event_rx: broadcast::Receiver<MarketEvent>,
279 mut shutdown_rx: broadcast::Receiver<()>,
280 ) {
281 info!("computation manager started");
282
283 loop {
284 tokio::select! {
285 biased;
286
287 _ = shutdown_rx.recv() => {
288 info!("computation manager shutting down");
289 break;
290 }
291
292 event_result = event_rx.recv() => {
293 match event_result {
294 Ok(event) => {
295 if let Err(e) = self.handle_event(&event).await {
296 warn!(error = ?e, "failed to handle market event");
297 }
298 }
299 Err(broadcast::error::RecvError::Closed) => {
300 info!("event channel closed, computation manager shutting down");
301 break;
302 }
303 Err(broadcast::error::RecvError::Lagged(skipped)) => {
304 warn!(
305 skipped,
306 "computation manager lagged; draining buffered events and \
307 recomputing changed components incrementally"
308 );
309 counter!("derived_manager_lag_recoveries_total").increment(1);
310 counter!("derived_manager_lagged_events_total")
311 .increment(skipped);
312 self.recover_from_lag(&mut event_rx).await;
313 }
314 }
315 }
316 }
317 }
318 }
319
320 async fn compute_all(&self, changed: &ChangedComponents) {
328 let total_start = Instant::now();
329
330 let Some(block) = self
332 .market_data
333 .read()
334 .await
335 .last_updated()
336 .map(|b| b.number())
337 else {
338 warn!("market data has no last updated block, skipping computations");
339 return;
340 };
341
342 let _ = self
344 .event_tx
345 .send(DerivedDataEvent::NewBlock { block });
346
347 let nodes: Vec<(ComputationId, ComputationRequirements)> = self
348 .computations
349 .iter()
350 .map(|computation| (computation.id(), computation.requirements()))
351 .collect();
352 let schedule = build_schedule(&nodes);
353 for &idx in &schedule.unscheduled {
354 let computation_id = nodes[idx].0;
355 error!(computation = computation_id, "computation skipped: requirement cycle");
356 counter!(
357 "derived_computation_failures_total",
358 "computation" => computation_id,
359 "reason" => "cycle"
360 )
361 .increment(1);
362 let _ = self
363 .event_tx
364 .send(DerivedDataEvent::ComputationFailed { computation_id, block });
365 }
366
367 let mut succeeded: HashSet<ComputationId> = HashSet::new();
368 for stage in &schedule.stages {
369 let mut runnable = Vec::new();
372 {
373 let store = self.store.read().await;
374 for &idx in stage {
375 let reqs = &nodes[idx].1;
376 let fresh_ready = reqs
377 .fresh_requirements()
378 .iter()
379 .all(|id| succeeded.contains(id));
380 let stale_ready = reqs
381 .stale_requirements()
382 .iter()
383 .all(|id| succeeded.contains(id) || store.output_block(id).is_some());
384 if fresh_ready && stale_ready {
385 runnable.push(idx);
386 } else {
387 let computation_id = nodes[idx].0;
388 counter!(
389 "derived_computation_failures_total",
390 "computation" => computation_id,
391 "reason" => "upstream_failed"
392 )
393 .increment(1);
394 let _ = self
395 .event_tx
396 .send(DerivedDataEvent::ComputationFailed { computation_id, block });
397 }
398 }
399 }
400
401 if runnable.is_empty() {
402 continue;
403 }
404
405 let results = join_all(runnable.iter().map(|&idx| async move {
407 let start = Instant::now();
408 let result = self.computations[idx]
409 .compute_erased(&self.market_data, &self.store, changed, block)
410 .await;
411 (idx, result, start.elapsed())
412 }))
413 .await;
414
415 let mut store = self.store.write().await;
417 for (idx, result, elapsed) in results {
418 let computation_id = nodes[idx].0;
419 match result {
420 Ok(write) => {
421 (write.persist)(&mut store);
422 histogram!(
423 "derived_computation_duration_seconds",
424 "computation" => computation_id
425 )
426 .record(elapsed.as_secs_f64());
427 gauge!(
428 "derived_last_success_timestamp_seconds",
429 "computation" => computation_id
430 )
431 .set(unix_now_seconds());
432 info!(
433 computation = computation_id,
434 failed = write.failed_items.len(),
435 elapsed_ms = elapsed.as_millis(),
436 "computation complete"
437 );
438 let _ = self
439 .event_tx
440 .send(DerivedDataEvent::ComputationComplete {
441 computation_id,
442 block,
443 failed_items: write.failed_items,
444 });
445 succeeded.insert(computation_id);
446 }
447 Err(e) => {
448 counter!(
449 "derived_computation_failures_total",
450 "computation" => computation_id,
451 "reason" => "error"
452 )
453 .increment(1);
454 warn!(
455 error = ?e,
456 computation = computation_id,
457 elapsed_ms = elapsed.as_millis(),
458 "computation failed"
459 );
460 let _ = self
461 .event_tx
462 .send(DerivedDataEvent::ComputationFailed { computation_id, block });
463 }
464 }
465 }
466 }
467
468 info!(
469 block,
470 total_ms = total_start.elapsed().as_millis(),
471 "all derived computations complete"
472 );
473 }
474
475 async fn recover_from_lag(&self, event_rx: &mut broadcast::Receiver<MarketEvent>) {
486 let mut drained = Vec::new();
487 loop {
488 match event_rx.try_recv() {
489 Ok(event) => drained.push(event),
490 Err(broadcast::error::TryRecvError::Empty) => break,
491 Err(broadcast::error::TryRecvError::Lagged(n)) => {
492 counter!("derived_manager_lagged_events_total").increment(n);
493 continue;
494 }
495 Err(broadcast::error::TryRecvError::Closed) => break,
496 }
497 }
498 if let Some(changed) = coalesce_market_events(&drained) {
499 self.compute_all(&changed).await;
500 }
501 }
502}
503
504fn unix_now_seconds() -> f64 {
506 SystemTime::now()
507 .duration_since(UNIX_EPOCH)
508 .map(|elapsed| elapsed.as_secs_f64())
509 .unwrap_or(0.0)
510}
511
512fn build_schedule(nodes: &[(ComputationId, ComputationRequirements)]) -> ComputationSchedule {
520 let ids: Vec<ComputationId> = nodes
521 .iter()
522 .map(|(id, _)| *id)
523 .collect();
524 let mut stage_of: Vec<Option<usize>> = vec![None; nodes.len()];
525
526 loop {
527 let mut progressed = false;
528 for (idx, (_, reqs)) in nodes.iter().enumerate() {
529 if stage_of[idx].is_some() {
530 continue;
531 }
532 let mut stage = 0;
533 let mut ready = true;
534 for dep in reqs
535 .fresh_requirements()
536 .iter()
537 .chain(reqs.stale_requirements().iter())
538 {
539 let Some(dep_idx) = ids.iter().position(|id| id == dep) else {
540 continue;
541 };
542 match stage_of[dep_idx] {
543 Some(dep_stage) => stage = stage.max(dep_stage + 1),
544 None => {
545 ready = false;
546 break;
547 }
548 }
549 }
550 if ready {
551 stage_of[idx] = Some(stage);
552 progressed = true;
553 }
554 }
555 if !progressed {
556 break;
557 }
558 }
559
560 let stage_count = stage_of
561 .iter()
562 .filter_map(|stage| *stage)
563 .max()
564 .map_or(0, |max| max + 1);
565 let mut stages = vec![Vec::new(); stage_count];
566 let mut unscheduled = Vec::new();
567 for (idx, stage) in stage_of.iter().enumerate() {
568 match stage {
569 Some(stage) => stages[*stage].push(idx),
570 None => unscheduled.push(idx),
571 }
572 }
573 ComputationSchedule { stages, unscheduled }
574}
575
576#[async_trait]
577impl MarketEventHandler for ComputationManager {
578 async fn handle_event(&mut self, event: &MarketEvent) -> Result<(), EventError> {
579 match event {
580 MarketEvent::MarketUpdated {
581 added_components,
582 removed_components,
583 updated_components,
584 } if !added_components.is_empty() ||
585 !removed_components.is_empty() ||
586 !updated_components.is_empty() =>
587 {
588 trace!(
589 added = added_components.len(),
590 removed = removed_components.len(),
591 updated = updated_components.len(),
592 "market updated, running incremental computations"
593 );
594
595 let changed = ChangedComponents {
596 added: added_components.clone(),
597 removed: removed_components.clone(),
598 updated: updated_components.clone(),
599 is_full_recompute: false,
600 };
601 self.compute_all(&changed).await;
602 }
603 _ => {
604 trace!("empty market update, skipping computations");
605 }
606 }
607
608 Ok(())
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use std::{
615 collections::HashMap,
616 sync::{
617 atomic::{AtomicBool, Ordering},
618 Arc,
619 },
620 };
621
622 use tokio::sync::broadcast;
623
624 use super::*;
625 use crate::{
626 algorithm::test_utils::{component, setup_market_weighted, token, MockProtocolSim},
627 derived::computation::{ComputationOutput, FailedItem, FailedItemError},
628 feed::market_data::{MarketData, MarketState},
629 types::BlockInfo,
630 };
631
632 fn drain_events(rx: &mut broadcast::Receiver<DerivedDataEvent>) -> Vec<DerivedDataEvent> {
634 let mut events = vec![];
635 loop {
636 match rx.try_recv() {
637 Ok(e) => events.push(e),
638 Err(broadcast::error::TryRecvError::Empty) => break,
639 Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
640 Err(broadcast::error::TryRecvError::Closed) => break,
641 }
642 }
643 events
644 }
645
646 #[test]
649 fn coalesce_empty_batch_returns_none() {
650 assert!(coalesce_market_events(&[]).is_none());
651 }
652
653 #[test]
654 fn coalesce_unions_added_and_updated_across_events() {
655 let eth = token(1, "ETH");
656 let usdc = token(2, "USDC");
657 let e1 = MarketEvent::MarketUpdated {
658 added_components: HashMap::from([(
659 "eth_usdc".to_string(),
660 vec![eth.address.clone(), usdc.address.clone()],
661 )]),
662 removed_components: vec![],
663 updated_components: vec![],
664 };
665 let e2 = MarketEvent::MarketUpdated {
666 added_components: HashMap::new(),
667 removed_components: vec![],
668 updated_components: vec!["eth_usdc".to_string(), "dai_usdc".to_string()],
669 };
670 let c = coalesce_market_events(&[e1, e2]).expect("net changes present");
671 assert!(!c.is_full_recompute);
672 assert!(c.added.contains_key("eth_usdc"));
674 assert!(!c
675 .updated
676 .contains(&"eth_usdc".to_string()));
677 assert!(c
679 .updated
680 .contains(&"dai_usdc".to_string()));
681 }
682
683 #[test]
684 fn coalesce_add_then_remove_nets_to_removed() {
685 let eth = token(1, "ETH");
686 let usdc = token(2, "USDC");
687 let add = MarketEvent::MarketUpdated {
688 added_components: HashMap::from([(
689 "eth_usdc".to_string(),
690 vec![eth.address.clone(), usdc.address.clone()],
691 )]),
692 removed_components: vec![],
693 updated_components: vec![],
694 };
695 let remove = MarketEvent::MarketUpdated {
696 added_components: HashMap::new(),
697 removed_components: vec!["eth_usdc".to_string()],
698 updated_components: vec![],
699 };
700 let c = coalesce_market_events(&[add, remove]).expect("net removal present");
701 assert!(!c.added.contains_key("eth_usdc"));
702 assert!(c
703 .removed
704 .contains(&"eth_usdc".to_string()));
705 assert!(!c
706 .updated
707 .contains(&"eth_usdc".to_string()));
708 }
709
710 #[tokio::test]
711 async fn lag_recovery_recomputes_incrementally_and_drains_to_tail() {
712 let eth = token(1, "ETH");
713 let usdc = token(2, "USDC");
714 let (market, _) = setup_market_weighted(vec![(
715 "eth_usdc",
716 ð,
717 &usdc,
718 MockProtocolSim::new(2000.0).with_gas(0),
719 )]);
720 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
721 let (manager, _out_rx) = ComputationManager::new(config, market).unwrap();
722
723 let (tx, mut rx) = broadcast::channel::<MarketEvent>(2);
725 for _ in 0..5 {
726 tx.send(MarketEvent::MarketUpdated {
727 added_components: HashMap::from([(
728 "eth_usdc".to_string(),
729 vec![eth.address.clone(), usdc.address.clone()],
730 )]),
731 removed_components: vec![],
732 updated_components: vec![],
733 })
734 .unwrap();
735 }
736 let err = rx
737 .recv()
738 .await
739 .expect_err("receiver must have lagged");
740 assert!(matches!(err, broadcast::error::RecvError::Lagged(_)));
741
742 manager.recover_from_lag(&mut rx).await;
743
744 let store = manager.store();
746 let guard = store.read().await;
747 assert!(guard.spot_prices().is_some());
748 assert!(guard.token_prices().is_some());
749 drop(guard);
750 assert!(matches!(rx.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
752 }
753
754 #[test]
757 fn schedule_empty_has_no_stages() {
758 let schedule = build_schedule(&[]);
759 assert!(schedule.stages.is_empty());
760 assert!(schedule.unscheduled.is_empty());
761 }
762
763 #[test]
764 fn schedule_single_root_is_one_stage() {
765 let schedule = build_schedule(&[("a", ComputationRequirements::none())]);
766 assert_eq!(schedule.stages, vec![vec![0]]);
767 assert!(schedule.unscheduled.is_empty());
768 }
769
770 #[test]
771 fn schedule_independent_roots_share_one_stage() {
772 let schedule = build_schedule(&[
773 ("a", ComputationRequirements::none()),
774 ("b", ComputationRequirements::none()),
775 ]);
776 assert_eq!(schedule.stages, vec![vec![0, 1]]);
777 assert!(schedule.unscheduled.is_empty());
778 }
779
780 #[test]
781 fn schedule_chain_orders_into_successive_stages() {
782 let schedule = build_schedule(&[
784 ("a", ComputationRequirements::none()),
785 ("b", ComputationRequirements::fresh(["a"])),
786 ("c", ComputationRequirements::fresh(["b"])),
787 ]);
788 assert_eq!(schedule.stages, vec![vec![0], vec![1], vec![2]]);
789 assert!(schedule.unscheduled.is_empty());
790 }
791
792 #[test]
793 fn schedule_diamond_places_join_after_both_parents() {
794 let schedule = build_schedule(&[
796 ("a", ComputationRequirements::none()),
797 ("b", ComputationRequirements::fresh(["a"])),
798 ("c", ComputationRequirements::fresh(["a"])),
799 ("d", ComputationRequirements::fresh(["b", "c"])),
800 ]);
801 assert_eq!(schedule.stages, vec![vec![0], vec![1, 2], vec![3]]);
802 assert!(schedule.unscheduled.is_empty());
803 }
804
805 #[test]
806 fn schedule_preserves_input_order_within_a_stage() {
807 let schedule = build_schedule(&[
808 ("a", ComputationRequirements::none()),
809 ("b", ComputationRequirements::fresh(["a"])),
810 ("c", ComputationRequirements::fresh(["a"])),
811 ]);
812 assert_eq!(schedule.stages, vec![vec![0], vec![1, 2]]);
814 }
815
816 #[test]
817 fn schedule_stale_requirement_orders_after_its_producer() {
818 let schedule = build_schedule(&[
819 ("a", ComputationRequirements::none()),
820 ("b", ComputationRequirements::stale(["a"])),
821 ]);
822 assert_eq!(schedule.stages, vec![vec![0], vec![1]]);
823 }
824
825 #[test]
826 fn schedule_requirement_on_unregistered_id_does_not_affect_ordering() {
827 let schedule = build_schedule(&[("a", ComputationRequirements::fresh(["ghost"]))]);
829 assert_eq!(schedule.stages, vec![vec![0]]);
830 assert!(schedule.unscheduled.is_empty());
831 }
832
833 #[test]
834 fn schedule_two_node_cycle_is_unscheduled() {
835 let schedule = build_schedule(&[
836 ("a", ComputationRequirements::fresh(["b"])),
837 ("b", ComputationRequirements::fresh(["a"])),
838 ]);
839 assert!(schedule.stages.is_empty());
840 assert_eq!(schedule.unscheduled, vec![0, 1]);
841 }
842
843 #[test]
844 fn schedule_isolates_cycle_from_schedulable_nodes() {
845 let schedule = build_schedule(&[
847 ("root", ComputationRequirements::none()),
848 ("x", ComputationRequirements::fresh(["y"])),
849 ("y", ComputationRequirements::fresh(["x"])),
850 ]);
851 assert_eq!(schedule.stages, vec![vec![0]]);
852 assert_eq!(schedule.unscheduled, vec![1, 2]);
853 }
854
855 #[test]
856 fn invalid_slippage_threshold_returns_error() {
857 let (market, _) = setup_market_weighted(vec![]);
858 let config = ComputationManagerConfig::new().with_depth_slippage_threshold(1.5);
859
860 let result = ComputationManager::new(config, market);
861 assert!(matches!(result, Err(ComputationError::InvalidConfiguration(_))));
862 }
863
864 #[tokio::test]
865 async fn handle_event_runs_computations_on_market_update() {
866 let eth = token(1, "ETH");
867 let usdc = token(2, "USDC");
868
869 let (market, _) = setup_market_weighted(vec![(
870 "eth_usdc",
871 ð,
872 &usdc,
873 MockProtocolSim::new(2000.0).with_gas(0),
874 )]);
875
876 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
877 let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
878
879 let event = MarketEvent::MarketUpdated {
880 added_components: HashMap::from([(
881 "eth_usdc".to_string(),
882 vec![eth.address.clone(), usdc.address.clone()],
883 )]),
884 removed_components: vec![],
885 updated_components: vec![],
886 };
887
888 manager
889 .handle_event(&event)
890 .await
891 .unwrap();
892
893 let store = manager.store();
894 let guard = store.read().await;
895 assert!(guard.token_prices().is_some());
896 assert!(guard.spot_prices().is_some());
897 }
898
899 #[tokio::test]
900 async fn handle_event_skips_empty_update() {
901 let (market, _) = setup_market_weighted(vec![]);
902 let config = ComputationManagerConfig::new();
903 let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
904
905 let event = MarketEvent::MarketUpdated {
906 added_components: HashMap::new(),
907 removed_components: vec![],
908 updated_components: vec![],
909 };
910
911 manager
912 .handle_event(&event)
913 .await
914 .unwrap();
915
916 let store = manager.store();
917 let guard = store.read().await;
918 assert!(guard.token_prices().is_none());
919 }
920
921 #[tokio::test]
922 async fn run_shuts_down_on_signal() {
923 let (market, _) = setup_market_weighted(vec![]);
924 let config = ComputationManagerConfig::new();
925 let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
926
927 let (_event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
928 let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
929
930 let handle = tokio::spawn(async move {
931 manager.run(event_rx, shutdown_rx).await;
932 });
933
934 shutdown_tx.send(()).unwrap();
935
936 tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
937 .await
938 .expect("manager should shutdown")
939 .expect("task should complete successfully");
940 }
941
942 #[derive(Clone, Debug, PartialEq)]
945 struct CounterOutput(u32);
946
947 struct CounterComputation;
950
951 #[async_trait::async_trait]
952 impl DerivedComputation for CounterComputation {
953 type Output = CounterOutput;
954 const ID: ComputationId = "counter";
955
956 async fn compute(
957 &self,
958 _market: &MarketData,
959 _store: &SharedDerivedDataRef,
960 _changed: &ChangedComponents,
961 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
962 Ok(ComputationOutput::success(CounterOutput(7)))
963 }
964 }
965
966 fn market_with_block() -> MarketData {
968 let eth = token(1, "ETH");
969 let usdc = token(2, "USDC");
970 let (market, _) = setup_market_weighted(vec![(
971 "eth_usdc",
972 ð,
973 &usdc,
974 MockProtocolSim::new(2000.0).with_gas(0),
975 )]);
976 market
977 }
978
979 #[tokio::test]
980 async fn registered_custom_computation_runs_and_persists_via_default_slot() {
981 let (mut manager, mut event_rx) = ComputationManager::empty(market_with_block());
982 manager
983 .register(CounterComputation)
984 .unwrap();
985
986 manager
987 .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
988 .await;
989
990 let store = manager.store();
991 let guard = store.read().await;
992 assert_eq!(
993 guard.output::<CounterOutput>(CounterComputation::ID),
994 Some(&CounterOutput(7)),
995 "default persist should write the output into the generic slot"
996 );
997 assert!(guard
998 .output_block(CounterComputation::ID)
999 .is_some());
1000
1001 let events = drain_events(&mut event_rx);
1002 assert!(
1003 events.iter().any(|e| matches!(
1004 e,
1005 DerivedDataEvent::ComputationComplete { computation_id: "counter", .. }
1006 )),
1007 "expected ComputationComplete(counter), got: {events:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn registering_duplicate_id_is_rejected() {
1013 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1014 manager
1015 .register(CounterComputation)
1016 .unwrap();
1017
1018 let result = manager.register(CounterComputation);
1019
1020 assert!(matches!(result, Err(ComputationError::DuplicateComputationId("counter"))));
1021 }
1022
1023 fn event_summary(events: &[DerivedDataEvent]) -> Vec<(&'static str, &'static str)> {
1027 events
1028 .iter()
1029 .map(|event| match event {
1030 DerivedDataEvent::NewBlock { .. } => ("new_block", ""),
1031 DerivedDataEvent::ComputationComplete { computation_id, .. } => {
1032 ("complete", *computation_id)
1033 }
1034 DerivedDataEvent::ComputationFailed { computation_id, .. } => {
1035 ("failed", *computation_id)
1036 }
1037 })
1038 .collect()
1039 }
1040
1041 async fn run_full_recompute(manager: &ComputationManager) -> Vec<DerivedDataEvent> {
1043 let mut event_rx = manager.event_sender().subscribe();
1044 manager
1045 .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
1046 .await;
1047 drain_events(&mut event_rx)
1048 }
1049
1050 macro_rules! test_computation {
1052 ($name:ident, $id:literal, $reqs:expr, $result:expr) => {
1053 struct $name;
1054
1055 #[async_trait::async_trait]
1056 impl DerivedComputation for $name {
1057 type Output = ();
1058 const ID: ComputationId = $id;
1059
1060 fn requirements(&self) -> ComputationRequirements {
1061 $reqs
1062 }
1063
1064 async fn compute(
1065 &self,
1066 _market: &MarketData,
1067 _store: &SharedDerivedDataRef,
1068 _changed: &ChangedComponents,
1069 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1070 $result
1071 }
1072 }
1073 };
1074 }
1075
1076 test_computation!(
1077 RootOk,
1078 "root",
1079 ComputationRequirements::none(),
1080 Ok(ComputationOutput::success(()))
1081 );
1082 test_computation!(
1083 DepOnRoot,
1084 "dep",
1085 ComputationRequirements::fresh(["root"]),
1086 Ok(ComputationOutput::success(()))
1087 );
1088 test_computation!(
1089 SecondDepOnRoot,
1090 "dep2",
1091 ComputationRequirements::fresh(["root"]),
1092 Ok(ComputationOutput::success(()))
1093 );
1094 test_computation!(
1095 RootErr,
1096 "boom",
1097 ComputationRequirements::none(),
1098 Err(ComputationError::InvalidConfiguration("boom".to_string()))
1099 );
1100 test_computation!(
1101 DepOnBoom,
1102 "dep_boom",
1103 ComputationRequirements::fresh(["boom"]),
1104 Ok(ComputationOutput::success(()))
1105 );
1106 test_computation!(
1107 ThirdOnBoom,
1108 "third",
1109 ComputationRequirements::fresh(["dep_boom"]),
1110 Ok(ComputationOutput::success(()))
1111 );
1112 test_computation!(
1113 StaleDepOnFlaky,
1114 "stale_dep",
1115 ComputationRequirements::stale(["flaky"]),
1116 Ok(ComputationOutput::success(()))
1117 );
1118 test_computation!(
1119 GhostDependent,
1120 "needs_ghost",
1121 ComputationRequirements::fresh(["ghost"]),
1122 Ok(ComputationOutput::success(()))
1123 );
1124 test_computation!(
1125 PartialProducer,
1126 "partial",
1127 ComputationRequirements::none(),
1128 Ok(ComputationOutput::with_failures(
1129 (),
1130 vec![FailedItem { key: "x".to_string(), error: FailedItemError::MissingSpotPrice }]
1131 ))
1132 );
1133 test_computation!(
1134 DepOnPartial,
1135 "dep_partial",
1136 ComputationRequirements::fresh(["partial"]),
1137 Ok(ComputationOutput::success(()))
1138 );
1139
1140 struct FlakyProducer {
1144 succeed: Arc<AtomicBool>,
1145 }
1146
1147 #[async_trait::async_trait]
1148 impl DerivedComputation for FlakyProducer {
1149 type Output = ();
1150 const ID: ComputationId = "flaky";
1151
1152 async fn compute(
1153 &self,
1154 _market: &MarketData,
1155 _store: &SharedDerivedDataRef,
1156 _changed: &ChangedComponents,
1157 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1158 if self.succeed.load(Ordering::SeqCst) {
1159 Ok(ComputationOutput::success(()))
1160 } else {
1161 Err(ComputationError::InvalidConfiguration("flaky".to_string()))
1162 }
1163 }
1164 }
1165
1166 #[tokio::test]
1167 async fn events_follow_dependency_order_across_stages() {
1168 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1169 manager.register(RootOk).unwrap();
1170 manager.register(DepOnRoot).unwrap();
1171
1172 let events = run_full_recompute(&manager).await;
1173
1174 assert_eq!(
1175 event_summary(&events),
1176 vec![("new_block", ""), ("complete", "root"), ("complete", "dep")]
1177 );
1178 }
1179
1180 #[tokio::test]
1181 async fn events_preserve_registration_order_within_a_stage() {
1182 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1183 manager.register(RootOk).unwrap();
1184 manager.register(DepOnRoot).unwrap();
1185 manager
1186 .register(SecondDepOnRoot)
1187 .unwrap();
1188
1189 let events = run_full_recompute(&manager).await;
1190
1191 assert_eq!(
1193 event_summary(&events),
1194 vec![
1195 ("new_block", ""),
1196 ("complete", "root"),
1197 ("complete", "dep"),
1198 ("complete", "dep2"),
1199 ]
1200 );
1201 }
1202
1203 #[tokio::test]
1204 async fn failed_dependency_cascades_to_dependents() {
1205 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1206 manager.register(RootErr).unwrap();
1207 manager.register(DepOnBoom).unwrap();
1208
1209 let events = run_full_recompute(&manager).await;
1210
1211 assert_eq!(
1213 event_summary(&events),
1214 vec![("new_block", ""), ("failed", "boom"), ("failed", "dep_boom")]
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn computation_with_unregistered_requirement_is_skipped() {
1220 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1221 manager
1222 .register(GhostDependent)
1223 .unwrap();
1224
1225 let events = run_full_recompute(&manager).await;
1226
1227 assert_eq!(event_summary(&events), vec![("new_block", ""), ("failed", "needs_ghost")]);
1229 }
1230
1231 #[tokio::test]
1232 async fn fresh_dependent_runs_when_producer_succeeds_partially() {
1233 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1234 manager
1235 .register(PartialProducer)
1236 .unwrap();
1237 manager.register(DepOnPartial).unwrap();
1238
1239 let events = run_full_recompute(&manager).await;
1240
1241 assert_eq!(
1244 event_summary(&events),
1245 vec![("new_block", ""), ("complete", "partial"), ("complete", "dep_partial"),]
1246 );
1247 }
1248
1249 #[tokio::test]
1250 async fn failure_cascade_propagates_through_three_levels() {
1251 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1252 manager.register(RootErr).unwrap();
1253 manager.register(DepOnBoom).unwrap();
1254 manager.register(ThirdOnBoom).unwrap();
1255
1256 let events = run_full_recompute(&manager).await;
1257
1258 assert_eq!(
1260 event_summary(&events),
1261 vec![
1262 ("new_block", ""),
1263 ("failed", "boom"),
1264 ("failed", "dep_boom"),
1265 ("failed", "third"),
1266 ]
1267 );
1268 }
1269
1270 #[tokio::test]
1271 async fn stale_dependency_runs_on_prior_value_after_producer_fails() {
1272 let succeed = Arc::new(AtomicBool::new(true));
1273 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1274 manager
1275 .register(FlakyProducer { succeed: Arc::clone(&succeed) })
1276 .unwrap();
1277 manager
1278 .register(StaleDepOnFlaky)
1279 .unwrap();
1280
1281 let first = run_full_recompute(&manager).await;
1283 assert_eq!(
1284 event_summary(&first),
1285 vec![("new_block", ""), ("complete", "flaky"), ("complete", "stale_dep")]
1286 );
1287
1288 succeed.store(false, Ordering::SeqCst);
1291 let second = run_full_recompute(&manager).await;
1292 assert_eq!(
1293 event_summary(&second),
1294 vec![("new_block", ""), ("failed", "flaky"), ("complete", "stale_dep")]
1295 );
1296 }
1297
1298 #[tokio::test]
1299 async fn default_computations_cascade_failure_in_registration_order() {
1300 let (manager, _event_rx) = ComputationManager::new(
1303 ComputationManagerConfig::new(),
1304 market_with_component_no_sim_state(),
1305 )
1306 .unwrap();
1307
1308 let events = run_full_recompute(&manager).await;
1309
1310 assert_eq!(
1311 event_summary(&events),
1312 vec![
1313 ("new_block", ""),
1314 ("failed", "spot_prices"),
1315 ("failed", "token_prices"),
1316 ("failed", "pool_depths"),
1317 ]
1318 );
1319 }
1320
1321 fn market_with_component_no_sim_state() -> MarketData {
1326 let eth = token(1, "ETH");
1327 let usdc = token(2, "USDC");
1328 let component = component("component", &[eth.clone(), usdc.clone()]);
1329
1330 let mut market = MarketState::new();
1331 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1332 market.upsert_components(std::iter::once(component));
1333 market.upsert_tokens([eth, usdc]);
1335 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1336 }
1337
1338 fn market_with_mixed_sim_states() -> MarketData {
1341 let eth = token(1, "ETH");
1342 let usdc = token(2, "USDC");
1343 let dai = token(3, "DAI");
1344
1345 let component1 = component("eth_usdc", &[eth.clone(), usdc.clone()]);
1346 let component2 = component("eth_dai", &[eth.clone(), dai.clone()]);
1347
1348 let mut market = MarketState::new();
1349 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1350 market.upsert_components([component1, component2]);
1351 market
1353 .update_states([("eth_usdc".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
1354 market.upsert_tokens([eth, usdc, dai]);
1355 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1356 }
1357
1358 fn market_with_sim_state_no_gas_price() -> MarketData {
1363 let eth = token(1, "ETH");
1364 let usdc = token(2, "USDC");
1365 let component = component("component", &[eth.clone(), usdc.clone()]);
1366
1367 let mut market = MarketState::new();
1368 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1370 market.upsert_components(std::iter::once(component));
1371 market.update_states([(
1372 "component".to_string(),
1373 Box::new(MockProtocolSim::new(2000.0)) as _,
1374 )]);
1375 market.upsert_tokens([eth, usdc]);
1376 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1377 }
1378
1379 #[tokio::test]
1380 async fn test_spot_price_failure_broadcasts_computation_failed() {
1381 let market = market_with_component_no_sim_state();
1382 let config = ComputationManagerConfig::new();
1383 let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1384
1385 let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1387 manager.compute_all(&changed).await;
1388
1389 let events = drain_events(&mut event_rx);
1390
1391 assert!(
1392 events.iter().any(|e| matches!(
1393 e,
1394 DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1395 )),
1396 "expected ComputationFailed(spot_prices) in events: {events:?}"
1397 );
1398 }
1399
1400 #[tokio::test]
1401 async fn test_token_price_failure_broadcasts_computation_failed() {
1402 let eth = token(1, "ETH");
1403 let usdc = token(2, "USDC");
1404 let market = market_with_sim_state_no_gas_price();
1405 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
1406 let (mut manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1407
1408 let event = MarketEvent::MarketUpdated {
1410 added_components: HashMap::from([(
1411 "component".to_string(),
1412 vec![eth.address.clone(), usdc.address.clone()],
1413 )]),
1414 removed_components: vec![],
1415 updated_components: vec![],
1416 };
1417 manager
1418 .handle_event(&event)
1419 .await
1420 .unwrap();
1421
1422 let events = drain_events(&mut event_rx);
1423 assert!(
1424 events.iter().any(|e| matches!(
1425 e,
1426 DerivedDataEvent::ComputationFailed { computation_id: "token_prices", .. }
1427 )),
1428 "expected ComputationFailed(token_prices) in events: {events:?}"
1429 );
1430 }
1431
1432 #[tokio::test]
1433 async fn run_shuts_down_on_channel_close() {
1434 let (market, _) = setup_market_weighted(vec![]);
1435 let config = ComputationManagerConfig::new();
1436 let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
1437
1438 let (event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
1439 let (_shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
1440
1441 let handle = tokio::spawn(async move {
1442 manager.run(event_rx, shutdown_rx).await;
1443 });
1444
1445 drop(event_tx);
1446
1447 tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
1448 .await
1449 .expect("manager should shutdown on channel close")
1450 .expect("task should complete successfully");
1451 }
1452
1453 #[tokio::test]
1454 async fn partial_spot_price_failure_broadcasts_computation_complete() {
1455 let market = market_with_mixed_sim_states();
1458 let config = ComputationManagerConfig::new();
1459 let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1460
1461 let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1462 manager.compute_all(&changed).await;
1463
1464 let events = drain_events(&mut event_rx);
1465
1466 assert!(
1468 events.iter().any(|e| matches!(
1469 e,
1470 DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. }
1471 )),
1472 "expected ComputationComplete(spot_prices), got: {events:?}"
1473 );
1474 assert!(
1475 !events.iter().any(|e| matches!(
1476 e,
1477 DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1478 )),
1479 "should not broadcast ComputationFailed for partial failure"
1480 );
1481
1482 let complete = events.iter().find(|e| {
1484 matches!(e, DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. })
1485 });
1486 if let Some(DerivedDataEvent::ComputationComplete { failed_items, .. }) = complete {
1487 assert!(
1488 !failed_items.is_empty(),
1489 "ComputationComplete should carry failed_items for component2"
1490 );
1491 }
1492
1493 let eth = token(1, "ETH");
1496 let dai = token(3, "DAI");
1497 let store = manager.store();
1498 let guard = store.read().await;
1499 let key_eth_dai = ("eth_dai".to_string(), eth.address.clone(), dai.address.clone());
1500 let key_dai_eth = ("eth_dai".to_string(), dai.address.clone(), eth.address.clone());
1501 assert!(
1502 guard
1503 .spot_price_failure(&key_eth_dai)
1504 .is_some() ||
1505 guard
1506 .spot_price_failure(&key_dai_eth)
1507 .is_some(),
1508 "store should persist failure reason for eth_dai (missing sim state)"
1509 );
1510 }
1511
1512 struct FailingComputation;
1516
1517 #[async_trait::async_trait]
1518 impl DerivedComputation for FailingComputation {
1519 type Output = ();
1520 const ID: ComputationId = "failing";
1521
1522 async fn compute(
1523 &self,
1524 _market: &MarketData,
1525 _store: &SharedDerivedDataRef,
1526 _changed: &ChangedComponents,
1527 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1528 Err(ComputationError::InvalidConfiguration("always fails".to_string()))
1529 }
1530 }
1531
1532 fn find_metric<'a>(
1534 recorded: &'a [(
1535 metrics_util::CompositeKey,
1536 Option<metrics::Unit>,
1537 Option<metrics::SharedString>,
1538 metrics_util::debugging::DebugValue,
1539 )],
1540 name: &str,
1541 labels: &[(&str, &str)],
1542 ) -> &'a metrics_util::debugging::DebugValue {
1543 recorded
1544 .iter()
1545 .find(|(key, _, _, _)| {
1546 key.key().name() == name &&
1547 labels
1548 .iter()
1549 .all(|(label_key, label_value)| {
1550 key.key()
1551 .labels()
1552 .any(|l| l.key() == *label_key && l.value() == *label_value)
1553 })
1554 })
1555 .map(|(_, _, _, value)| value)
1556 .unwrap_or_else(|| panic!("missing {name}{labels:?}, got {recorded:?}"))
1557 }
1558
1559 #[test]
1560 fn compute_all_records_derived_metrics() {
1561 use metrics_util::debugging::DebugValue;
1562
1563 let recorder = metrics_util::debugging::DebuggingRecorder::new();
1564 let snapshotter = recorder.snapshotter();
1565 let rt = tokio::runtime::Builder::new_current_thread()
1566 .enable_all()
1567 .build()
1568 .expect("runtime builds");
1569
1570 metrics::with_local_recorder(&recorder, || {
1571 rt.block_on(async {
1572 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1573 manager
1574 .register(CounterComputation)
1575 .unwrap();
1576 manager
1577 .compute_all(&ChangedComponents {
1578 is_full_recompute: true,
1579 ..Default::default()
1580 })
1581 .await;
1582 })
1583 });
1584
1585 let recorded = snapshotter.snapshot().into_vec();
1586 let recorded_names: Vec<(String, Vec<String>)> = recorded
1587 .iter()
1588 .map(|(key, _, _, _)| {
1589 (
1590 key.key().name().to_string(),
1591 key.key()
1592 .labels()
1593 .map(|l| format!("{}={}", l.key(), l.value()))
1594 .collect(),
1595 )
1596 })
1597 .collect();
1598 for expected in
1599 ["derived_computation_duration_seconds", "derived_last_success_timestamp_seconds"]
1600 {
1601 assert!(
1602 recorded_names
1603 .iter()
1604 .any(|(name, labels)| name == expected &&
1605 labels.contains(&"computation=counter".to_string())),
1606 "missing {expected}{{computation=counter}}, got {recorded_names:?}"
1607 );
1608 }
1609
1610 match find_metric(
1611 &recorded,
1612 "derived_last_success_timestamp_seconds",
1613 &[("computation", "counter")],
1614 ) {
1615 DebugValue::Gauge(value) => {
1616 assert!(value.0 > 1.7e9, "gauge value {} not a sane unix timestamp", value.0);
1617 }
1618 other => panic!("derived_last_success_timestamp_seconds is not a gauge: {other:?}"),
1619 }
1620
1621 match find_metric(
1622 &recorded,
1623 "derived_computation_duration_seconds",
1624 &[("computation", "counter")],
1625 ) {
1626 DebugValue::Histogram(samples) => {
1627 assert!(!samples.is_empty(), "expected at least one recorded duration sample");
1628 }
1629 other => panic!("derived_computation_duration_seconds is not a histogram: {other:?}"),
1630 }
1631 }
1632
1633 #[test]
1634 fn compute_all_records_failure_metric() {
1635 use metrics_util::debugging::DebugValue;
1636
1637 let recorder = metrics_util::debugging::DebuggingRecorder::new();
1638 let snapshotter = recorder.snapshotter();
1639 let rt = tokio::runtime::Builder::new_current_thread()
1640 .enable_all()
1641 .build()
1642 .expect("runtime builds");
1643
1644 metrics::with_local_recorder(&recorder, || {
1645 rt.block_on(async {
1646 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1647 manager
1648 .register(FailingComputation)
1649 .unwrap();
1650 manager
1651 .compute_all(&ChangedComponents {
1652 is_full_recompute: true,
1653 ..Default::default()
1654 })
1655 .await;
1656 })
1657 });
1658
1659 let recorded = snapshotter.snapshot().into_vec();
1660 match find_metric(
1661 &recorded,
1662 "derived_computation_failures_total",
1663 &[("computation", "failing"), ("reason", "error")],
1664 ) {
1665 DebugValue::Counter(value) => {
1666 assert!(*value >= 1, "expected failure counter >= 1, got {value}");
1667 }
1668 other => panic!("derived_computation_failures_total is not a counter: {other:?}"),
1669 }
1670 }
1671}