1use std::{
10 sync::Arc,
11 time::{Instant, SystemTime, UNIX_EPOCH},
12};
13
14use async_trait::async_trait;
15use futures::future::join_all;
16use metrics::{counter, gauge, histogram};
17use rustc_hash::{FxHashMap, FxHashSet};
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: FxHashMap<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) -> FxHashSet<ComponentId> {
48 let mut all = FxHashSet::default();
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: FxHashMap<ComponentId, Vec<Address>> = FxHashMap::default();
66 let mut removed: FxHashSet<ComponentId> = FxHashSet::default();
67 let mut updated: FxHashSet<ComponentId> = FxHashSet::default();
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: FxHashSet<ComputationId> = FxHashSet::default();
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::sync::{
615 atomic::{AtomicBool, Ordering},
616 Arc,
617 };
618
619 use tokio::sync::broadcast;
620
621 use super::*;
622 use crate::{
623 algorithm::test_utils::{component, setup_market_weighted, token, MockProtocolSim},
624 derived::computation::{ComputationOutput, FailedItem, FailedItemError},
625 feed::market_data::{MarketData, MarketState},
626 types::BlockInfo,
627 };
628
629 fn drain_events(rx: &mut broadcast::Receiver<DerivedDataEvent>) -> Vec<DerivedDataEvent> {
631 let mut events = vec![];
632 loop {
633 match rx.try_recv() {
634 Ok(e) => events.push(e),
635 Err(broadcast::error::TryRecvError::Empty) => break,
636 Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
637 Err(broadcast::error::TryRecvError::Closed) => break,
638 }
639 }
640 events
641 }
642
643 #[test]
646 fn coalesce_empty_batch_returns_none() {
647 assert!(coalesce_market_events(&[]).is_none());
648 }
649
650 #[test]
651 fn coalesce_unions_added_and_updated_across_events() {
652 let eth = token(1, "ETH");
653 let usdc = token(2, "USDC");
654 let e1 = MarketEvent::MarketUpdated {
655 added_components: FxHashMap::from_iter([(
656 "eth_usdc".to_string(),
657 vec![eth.address.clone(), usdc.address.clone()],
658 )]),
659 removed_components: vec![],
660 updated_components: vec![],
661 };
662 let e2 = MarketEvent::MarketUpdated {
663 added_components: FxHashMap::default(),
664 removed_components: vec![],
665 updated_components: vec!["eth_usdc".to_string(), "dai_usdc".to_string()],
666 };
667 let c = coalesce_market_events(&[e1, e2]).expect("net changes present");
668 assert!(!c.is_full_recompute);
669 assert!(c.added.contains_key("eth_usdc"));
671 assert!(!c
672 .updated
673 .contains(&"eth_usdc".to_string()));
674 assert!(c
676 .updated
677 .contains(&"dai_usdc".to_string()));
678 }
679
680 #[test]
681 fn coalesce_add_then_remove_nets_to_removed() {
682 let eth = token(1, "ETH");
683 let usdc = token(2, "USDC");
684 let add = MarketEvent::MarketUpdated {
685 added_components: FxHashMap::from_iter([(
686 "eth_usdc".to_string(),
687 vec![eth.address.clone(), usdc.address.clone()],
688 )]),
689 removed_components: vec![],
690 updated_components: vec![],
691 };
692 let remove = MarketEvent::MarketUpdated {
693 added_components: FxHashMap::default(),
694 removed_components: vec!["eth_usdc".to_string()],
695 updated_components: vec![],
696 };
697 let c = coalesce_market_events(&[add, remove]).expect("net removal present");
698 assert!(!c.added.contains_key("eth_usdc"));
699 assert!(c
700 .removed
701 .contains(&"eth_usdc".to_string()));
702 assert!(!c
703 .updated
704 .contains(&"eth_usdc".to_string()));
705 }
706
707 #[tokio::test]
708 async fn lag_recovery_recomputes_incrementally_and_drains_to_tail() {
709 let eth = token(1, "ETH");
710 let usdc = token(2, "USDC");
711 let (market, _) = setup_market_weighted(vec![(
712 "eth_usdc",
713 ð,
714 &usdc,
715 MockProtocolSim::new(2000.0).with_gas(0),
716 )]);
717 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
718 let (manager, _out_rx) = ComputationManager::new(config, market).unwrap();
719
720 let (tx, mut rx) = broadcast::channel::<MarketEvent>(2);
722 for _ in 0..5 {
723 tx.send(MarketEvent::MarketUpdated {
724 added_components: FxHashMap::from_iter([(
725 "eth_usdc".to_string(),
726 vec![eth.address.clone(), usdc.address.clone()],
727 )]),
728 removed_components: vec![],
729 updated_components: vec![],
730 })
731 .unwrap();
732 }
733 let err = rx
734 .recv()
735 .await
736 .expect_err("receiver must have lagged");
737 assert!(matches!(err, broadcast::error::RecvError::Lagged(_)));
738
739 manager.recover_from_lag(&mut rx).await;
740
741 let store = manager.store();
743 let guard = store.read().await;
744 assert!(guard.spot_prices().is_some());
745 assert!(guard.token_prices().is_some());
746 drop(guard);
747 assert!(matches!(rx.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
749 }
750
751 #[test]
754 fn schedule_empty_has_no_stages() {
755 let schedule = build_schedule(&[]);
756 assert!(schedule.stages.is_empty());
757 assert!(schedule.unscheduled.is_empty());
758 }
759
760 #[test]
761 fn schedule_single_root_is_one_stage() {
762 let schedule = build_schedule(&[("a", ComputationRequirements::none())]);
763 assert_eq!(schedule.stages, vec![vec![0]]);
764 assert!(schedule.unscheduled.is_empty());
765 }
766
767 #[test]
768 fn schedule_independent_roots_share_one_stage() {
769 let schedule = build_schedule(&[
770 ("a", ComputationRequirements::none()),
771 ("b", ComputationRequirements::none()),
772 ]);
773 assert_eq!(schedule.stages, vec![vec![0, 1]]);
774 assert!(schedule.unscheduled.is_empty());
775 }
776
777 #[test]
778 fn schedule_chain_orders_into_successive_stages() {
779 let schedule = build_schedule(&[
781 ("a", ComputationRequirements::none()),
782 ("b", ComputationRequirements::fresh(["a"])),
783 ("c", ComputationRequirements::fresh(["b"])),
784 ]);
785 assert_eq!(schedule.stages, vec![vec![0], vec![1], vec![2]]);
786 assert!(schedule.unscheduled.is_empty());
787 }
788
789 #[test]
790 fn schedule_diamond_places_join_after_both_parents() {
791 let schedule = build_schedule(&[
793 ("a", ComputationRequirements::none()),
794 ("b", ComputationRequirements::fresh(["a"])),
795 ("c", ComputationRequirements::fresh(["a"])),
796 ("d", ComputationRequirements::fresh(["b", "c"])),
797 ]);
798 assert_eq!(schedule.stages, vec![vec![0], vec![1, 2], vec![3]]);
799 assert!(schedule.unscheduled.is_empty());
800 }
801
802 #[test]
803 fn schedule_preserves_input_order_within_a_stage() {
804 let schedule = build_schedule(&[
805 ("a", ComputationRequirements::none()),
806 ("b", ComputationRequirements::fresh(["a"])),
807 ("c", ComputationRequirements::fresh(["a"])),
808 ]);
809 assert_eq!(schedule.stages, vec![vec![0], vec![1, 2]]);
811 }
812
813 #[test]
814 fn schedule_stale_requirement_orders_after_its_producer() {
815 let schedule = build_schedule(&[
816 ("a", ComputationRequirements::none()),
817 ("b", ComputationRequirements::stale(["a"])),
818 ]);
819 assert_eq!(schedule.stages, vec![vec![0], vec![1]]);
820 }
821
822 #[test]
823 fn schedule_requirement_on_unregistered_id_does_not_affect_ordering() {
824 let schedule = build_schedule(&[("a", ComputationRequirements::fresh(["ghost"]))]);
826 assert_eq!(schedule.stages, vec![vec![0]]);
827 assert!(schedule.unscheduled.is_empty());
828 }
829
830 #[test]
831 fn schedule_two_node_cycle_is_unscheduled() {
832 let schedule = build_schedule(&[
833 ("a", ComputationRequirements::fresh(["b"])),
834 ("b", ComputationRequirements::fresh(["a"])),
835 ]);
836 assert!(schedule.stages.is_empty());
837 assert_eq!(schedule.unscheduled, vec![0, 1]);
838 }
839
840 #[test]
841 fn schedule_isolates_cycle_from_schedulable_nodes() {
842 let schedule = build_schedule(&[
844 ("root", ComputationRequirements::none()),
845 ("x", ComputationRequirements::fresh(["y"])),
846 ("y", ComputationRequirements::fresh(["x"])),
847 ]);
848 assert_eq!(schedule.stages, vec![vec![0]]);
849 assert_eq!(schedule.unscheduled, vec![1, 2]);
850 }
851
852 #[test]
853 fn invalid_slippage_threshold_returns_error() {
854 let (market, _) = setup_market_weighted(vec![]);
855 let config = ComputationManagerConfig::new().with_depth_slippage_threshold(1.5);
856
857 let result = ComputationManager::new(config, market);
858 assert!(matches!(result, Err(ComputationError::InvalidConfiguration(_))));
859 }
860
861 #[tokio::test]
862 async fn handle_event_runs_computations_on_market_update() {
863 let eth = token(1, "ETH");
864 let usdc = token(2, "USDC");
865
866 let (market, _) = setup_market_weighted(vec![(
867 "eth_usdc",
868 ð,
869 &usdc,
870 MockProtocolSim::new(2000.0).with_gas(0),
871 )]);
872
873 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
874 let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
875
876 let event = MarketEvent::MarketUpdated {
877 added_components: FxHashMap::from_iter([(
878 "eth_usdc".to_string(),
879 vec![eth.address.clone(), usdc.address.clone()],
880 )]),
881 removed_components: vec![],
882 updated_components: vec![],
883 };
884
885 manager
886 .handle_event(&event)
887 .await
888 .unwrap();
889
890 let store = manager.store();
891 let guard = store.read().await;
892 assert!(guard.token_prices().is_some());
893 assert!(guard.spot_prices().is_some());
894 }
895
896 #[tokio::test]
897 async fn handle_event_skips_empty_update() {
898 let (market, _) = setup_market_weighted(vec![]);
899 let config = ComputationManagerConfig::new();
900 let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
901
902 let event = MarketEvent::MarketUpdated {
903 added_components: FxHashMap::default(),
904 removed_components: vec![],
905 updated_components: vec![],
906 };
907
908 manager
909 .handle_event(&event)
910 .await
911 .unwrap();
912
913 let store = manager.store();
914 let guard = store.read().await;
915 assert!(guard.token_prices().is_none());
916 }
917
918 #[tokio::test]
919 async fn run_shuts_down_on_signal() {
920 let (market, _) = setup_market_weighted(vec![]);
921 let config = ComputationManagerConfig::new();
922 let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
923
924 let (_event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
925 let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
926
927 let handle = tokio::spawn(async move {
928 manager.run(event_rx, shutdown_rx).await;
929 });
930
931 shutdown_tx.send(()).unwrap();
932
933 tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
934 .await
935 .expect("manager should shutdown")
936 .expect("task should complete successfully");
937 }
938
939 #[derive(Clone, Debug, PartialEq)]
942 struct CounterOutput(u32);
943
944 struct CounterComputation;
947
948 #[async_trait::async_trait]
949 impl DerivedComputation for CounterComputation {
950 type Output = CounterOutput;
951 const ID: ComputationId = "counter";
952
953 async fn compute(
954 &self,
955 _market: &MarketData,
956 _store: &SharedDerivedDataRef,
957 _changed: &ChangedComponents,
958 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
959 Ok(ComputationOutput::success(CounterOutput(7)))
960 }
961 }
962
963 fn market_with_block() -> MarketData {
965 let eth = token(1, "ETH");
966 let usdc = token(2, "USDC");
967 let (market, _) = setup_market_weighted(vec![(
968 "eth_usdc",
969 ð,
970 &usdc,
971 MockProtocolSim::new(2000.0).with_gas(0),
972 )]);
973 market
974 }
975
976 #[tokio::test]
977 async fn registered_custom_computation_runs_and_persists_via_default_slot() {
978 let (mut manager, mut event_rx) = ComputationManager::empty(market_with_block());
979 manager
980 .register(CounterComputation)
981 .unwrap();
982
983 manager
984 .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
985 .await;
986
987 let store = manager.store();
988 let guard = store.read().await;
989 assert_eq!(
990 guard.output::<CounterOutput>(CounterComputation::ID),
991 Some(&CounterOutput(7)),
992 "default persist should write the output into the generic slot"
993 );
994 assert!(guard
995 .output_block(CounterComputation::ID)
996 .is_some());
997
998 let events = drain_events(&mut event_rx);
999 assert!(
1000 events.iter().any(|e| matches!(
1001 e,
1002 DerivedDataEvent::ComputationComplete { computation_id: "counter", .. }
1003 )),
1004 "expected ComputationComplete(counter), got: {events:?}"
1005 );
1006 }
1007
1008 #[test]
1009 fn registering_duplicate_id_is_rejected() {
1010 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1011 manager
1012 .register(CounterComputation)
1013 .unwrap();
1014
1015 let result = manager.register(CounterComputation);
1016
1017 assert!(matches!(result, Err(ComputationError::DuplicateComputationId("counter"))));
1018 }
1019
1020 fn event_summary(events: &[DerivedDataEvent]) -> Vec<(&'static str, &'static str)> {
1024 events
1025 .iter()
1026 .map(|event| match event {
1027 DerivedDataEvent::NewBlock { .. } => ("new_block", ""),
1028 DerivedDataEvent::ComputationComplete { computation_id, .. } => {
1029 ("complete", *computation_id)
1030 }
1031 DerivedDataEvent::ComputationFailed { computation_id, .. } => {
1032 ("failed", *computation_id)
1033 }
1034 })
1035 .collect()
1036 }
1037
1038 async fn run_full_recompute(manager: &ComputationManager) -> Vec<DerivedDataEvent> {
1040 let mut event_rx = manager.event_sender().subscribe();
1041 manager
1042 .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
1043 .await;
1044 drain_events(&mut event_rx)
1045 }
1046
1047 macro_rules! test_computation {
1049 ($name:ident, $id:literal, $reqs:expr, $result:expr) => {
1050 struct $name;
1051
1052 #[async_trait::async_trait]
1053 impl DerivedComputation for $name {
1054 type Output = ();
1055 const ID: ComputationId = $id;
1056
1057 fn requirements(&self) -> ComputationRequirements {
1058 $reqs
1059 }
1060
1061 async fn compute(
1062 &self,
1063 _market: &MarketData,
1064 _store: &SharedDerivedDataRef,
1065 _changed: &ChangedComponents,
1066 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1067 $result
1068 }
1069 }
1070 };
1071 }
1072
1073 test_computation!(
1074 RootOk,
1075 "root",
1076 ComputationRequirements::none(),
1077 Ok(ComputationOutput::success(()))
1078 );
1079 test_computation!(
1080 DepOnRoot,
1081 "dep",
1082 ComputationRequirements::fresh(["root"]),
1083 Ok(ComputationOutput::success(()))
1084 );
1085 test_computation!(
1086 SecondDepOnRoot,
1087 "dep2",
1088 ComputationRequirements::fresh(["root"]),
1089 Ok(ComputationOutput::success(()))
1090 );
1091 test_computation!(
1092 RootErr,
1093 "boom",
1094 ComputationRequirements::none(),
1095 Err(ComputationError::InvalidConfiguration("boom".to_string()))
1096 );
1097 test_computation!(
1098 DepOnBoom,
1099 "dep_boom",
1100 ComputationRequirements::fresh(["boom"]),
1101 Ok(ComputationOutput::success(()))
1102 );
1103 test_computation!(
1104 ThirdOnBoom,
1105 "third",
1106 ComputationRequirements::fresh(["dep_boom"]),
1107 Ok(ComputationOutput::success(()))
1108 );
1109 test_computation!(
1110 StaleDepOnFlaky,
1111 "stale_dep",
1112 ComputationRequirements::stale(["flaky"]),
1113 Ok(ComputationOutput::success(()))
1114 );
1115 test_computation!(
1116 GhostDependent,
1117 "needs_ghost",
1118 ComputationRequirements::fresh(["ghost"]),
1119 Ok(ComputationOutput::success(()))
1120 );
1121 test_computation!(
1122 PartialProducer,
1123 "partial",
1124 ComputationRequirements::none(),
1125 Ok(ComputationOutput::with_failures(
1126 (),
1127 vec![FailedItem { key: "x".to_string(), error: FailedItemError::MissingSpotPrice }]
1128 ))
1129 );
1130 test_computation!(
1131 DepOnPartial,
1132 "dep_partial",
1133 ComputationRequirements::fresh(["partial"]),
1134 Ok(ComputationOutput::success(()))
1135 );
1136
1137 struct FlakyProducer {
1141 succeed: Arc<AtomicBool>,
1142 }
1143
1144 #[async_trait::async_trait]
1145 impl DerivedComputation for FlakyProducer {
1146 type Output = ();
1147 const ID: ComputationId = "flaky";
1148
1149 async fn compute(
1150 &self,
1151 _market: &MarketData,
1152 _store: &SharedDerivedDataRef,
1153 _changed: &ChangedComponents,
1154 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1155 if self.succeed.load(Ordering::SeqCst) {
1156 Ok(ComputationOutput::success(()))
1157 } else {
1158 Err(ComputationError::InvalidConfiguration("flaky".to_string()))
1159 }
1160 }
1161 }
1162
1163 #[tokio::test]
1164 async fn events_follow_dependency_order_across_stages() {
1165 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1166 manager.register(RootOk).unwrap();
1167 manager.register(DepOnRoot).unwrap();
1168
1169 let events = run_full_recompute(&manager).await;
1170
1171 assert_eq!(
1172 event_summary(&events),
1173 vec![("new_block", ""), ("complete", "root"), ("complete", "dep")]
1174 );
1175 }
1176
1177 #[tokio::test]
1178 async fn events_preserve_registration_order_within_a_stage() {
1179 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1180 manager.register(RootOk).unwrap();
1181 manager.register(DepOnRoot).unwrap();
1182 manager
1183 .register(SecondDepOnRoot)
1184 .unwrap();
1185
1186 let events = run_full_recompute(&manager).await;
1187
1188 assert_eq!(
1190 event_summary(&events),
1191 vec![
1192 ("new_block", ""),
1193 ("complete", "root"),
1194 ("complete", "dep"),
1195 ("complete", "dep2"),
1196 ]
1197 );
1198 }
1199
1200 #[tokio::test]
1201 async fn failed_dependency_cascades_to_dependents() {
1202 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1203 manager.register(RootErr).unwrap();
1204 manager.register(DepOnBoom).unwrap();
1205
1206 let events = run_full_recompute(&manager).await;
1207
1208 assert_eq!(
1210 event_summary(&events),
1211 vec![("new_block", ""), ("failed", "boom"), ("failed", "dep_boom")]
1212 );
1213 }
1214
1215 #[tokio::test]
1216 async fn computation_with_unregistered_requirement_is_skipped() {
1217 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1218 manager
1219 .register(GhostDependent)
1220 .unwrap();
1221
1222 let events = run_full_recompute(&manager).await;
1223
1224 assert_eq!(event_summary(&events), vec![("new_block", ""), ("failed", "needs_ghost")]);
1226 }
1227
1228 #[tokio::test]
1229 async fn fresh_dependent_runs_when_producer_succeeds_partially() {
1230 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1231 manager
1232 .register(PartialProducer)
1233 .unwrap();
1234 manager.register(DepOnPartial).unwrap();
1235
1236 let events = run_full_recompute(&manager).await;
1237
1238 assert_eq!(
1241 event_summary(&events),
1242 vec![("new_block", ""), ("complete", "partial"), ("complete", "dep_partial"),]
1243 );
1244 }
1245
1246 #[tokio::test]
1247 async fn failure_cascade_propagates_through_three_levels() {
1248 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1249 manager.register(RootErr).unwrap();
1250 manager.register(DepOnBoom).unwrap();
1251 manager.register(ThirdOnBoom).unwrap();
1252
1253 let events = run_full_recompute(&manager).await;
1254
1255 assert_eq!(
1257 event_summary(&events),
1258 vec![
1259 ("new_block", ""),
1260 ("failed", "boom"),
1261 ("failed", "dep_boom"),
1262 ("failed", "third"),
1263 ]
1264 );
1265 }
1266
1267 #[tokio::test]
1268 async fn stale_dependency_runs_on_prior_value_after_producer_fails() {
1269 let succeed = Arc::new(AtomicBool::new(true));
1270 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1271 manager
1272 .register(FlakyProducer { succeed: Arc::clone(&succeed) })
1273 .unwrap();
1274 manager
1275 .register(StaleDepOnFlaky)
1276 .unwrap();
1277
1278 let first = run_full_recompute(&manager).await;
1280 assert_eq!(
1281 event_summary(&first),
1282 vec![("new_block", ""), ("complete", "flaky"), ("complete", "stale_dep")]
1283 );
1284
1285 succeed.store(false, Ordering::SeqCst);
1288 let second = run_full_recompute(&manager).await;
1289 assert_eq!(
1290 event_summary(&second),
1291 vec![("new_block", ""), ("failed", "flaky"), ("complete", "stale_dep")]
1292 );
1293 }
1294
1295 #[tokio::test]
1296 async fn default_computations_cascade_failure_in_registration_order() {
1297 let (manager, _event_rx) = ComputationManager::new(
1300 ComputationManagerConfig::new(),
1301 market_with_component_no_sim_state(),
1302 )
1303 .unwrap();
1304
1305 let events = run_full_recompute(&manager).await;
1306
1307 assert_eq!(
1308 event_summary(&events),
1309 vec![
1310 ("new_block", ""),
1311 ("failed", "spot_prices"),
1312 ("failed", "token_prices"),
1313 ("failed", "pool_depths"),
1314 ]
1315 );
1316 }
1317
1318 fn market_with_component_no_sim_state() -> MarketData {
1323 let eth = token(1, "ETH");
1324 let usdc = token(2, "USDC");
1325 let component = component("component", &[eth.clone(), usdc.clone()]);
1326
1327 let mut market = MarketState::new();
1328 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1329 market.upsert_components(std::iter::once(component));
1330 market.upsert_tokens([eth, usdc]);
1332 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1333 }
1334
1335 fn market_with_mixed_sim_states() -> MarketData {
1338 let eth = token(1, "ETH");
1339 let usdc = token(2, "USDC");
1340 let dai = token(3, "DAI");
1341
1342 let component1 = component("eth_usdc", &[eth.clone(), usdc.clone()]);
1343 let component2 = component("eth_dai", &[eth.clone(), dai.clone()]);
1344
1345 let mut market = MarketState::new();
1346 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1347 market.upsert_components([component1, component2]);
1348 market
1350 .update_states([("eth_usdc".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
1351 market.upsert_tokens([eth, usdc, dai]);
1352 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1353 }
1354
1355 fn market_with_sim_state_no_gas_price() -> MarketData {
1360 let eth = token(1, "ETH");
1361 let usdc = token(2, "USDC");
1362 let component = component("component", &[eth.clone(), usdc.clone()]);
1363
1364 let mut market = MarketState::new();
1365 market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1367 market.upsert_components(std::iter::once(component));
1368 market.update_states([(
1369 "component".to_string(),
1370 Box::new(MockProtocolSim::new(2000.0)) as _,
1371 )]);
1372 market.upsert_tokens([eth, usdc]);
1373 MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1374 }
1375
1376 #[tokio::test]
1377 async fn test_spot_price_failure_broadcasts_computation_failed() {
1378 let market = market_with_component_no_sim_state();
1379 let config = ComputationManagerConfig::new();
1380 let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1381
1382 let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1384 manager.compute_all(&changed).await;
1385
1386 let events = drain_events(&mut event_rx);
1387
1388 assert!(
1389 events.iter().any(|e| matches!(
1390 e,
1391 DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1392 )),
1393 "expected ComputationFailed(spot_prices) in events: {events:?}"
1394 );
1395 }
1396
1397 #[tokio::test]
1398 async fn test_token_price_failure_broadcasts_computation_failed() {
1399 let eth = token(1, "ETH");
1400 let usdc = token(2, "USDC");
1401 let market = market_with_sim_state_no_gas_price();
1402 let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
1403 let (mut manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1404
1405 let event = MarketEvent::MarketUpdated {
1407 added_components: FxHashMap::from_iter([(
1408 "component".to_string(),
1409 vec![eth.address.clone(), usdc.address.clone()],
1410 )]),
1411 removed_components: vec![],
1412 updated_components: vec![],
1413 };
1414 manager
1415 .handle_event(&event)
1416 .await
1417 .unwrap();
1418
1419 let events = drain_events(&mut event_rx);
1420 assert!(
1421 events.iter().any(|e| matches!(
1422 e,
1423 DerivedDataEvent::ComputationFailed { computation_id: "token_prices", .. }
1424 )),
1425 "expected ComputationFailed(token_prices) in events: {events:?}"
1426 );
1427 }
1428
1429 #[tokio::test]
1430 async fn run_shuts_down_on_channel_close() {
1431 let (market, _) = setup_market_weighted(vec![]);
1432 let config = ComputationManagerConfig::new();
1433 let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
1434
1435 let (event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
1436 let (_shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
1437
1438 let handle = tokio::spawn(async move {
1439 manager.run(event_rx, shutdown_rx).await;
1440 });
1441
1442 drop(event_tx);
1443
1444 tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
1445 .await
1446 .expect("manager should shutdown on channel close")
1447 .expect("task should complete successfully");
1448 }
1449
1450 #[tokio::test]
1451 async fn partial_spot_price_failure_broadcasts_computation_complete() {
1452 let market = market_with_mixed_sim_states();
1455 let config = ComputationManagerConfig::new();
1456 let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1457
1458 let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1459 manager.compute_all(&changed).await;
1460
1461 let events = drain_events(&mut event_rx);
1462
1463 assert!(
1465 events.iter().any(|e| matches!(
1466 e,
1467 DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. }
1468 )),
1469 "expected ComputationComplete(spot_prices), got: {events:?}"
1470 );
1471 assert!(
1472 !events.iter().any(|e| matches!(
1473 e,
1474 DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1475 )),
1476 "should not broadcast ComputationFailed for partial failure"
1477 );
1478
1479 let complete = events.iter().find(|e| {
1481 matches!(e, DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. })
1482 });
1483 if let Some(DerivedDataEvent::ComputationComplete { failed_items, .. }) = complete {
1484 assert!(
1485 !failed_items.is_empty(),
1486 "ComputationComplete should carry failed_items for component2"
1487 );
1488 }
1489
1490 let eth = token(1, "ETH");
1493 let dai = token(3, "DAI");
1494 let store = manager.store();
1495 let guard = store.read().await;
1496 let key_eth_dai = ("eth_dai".to_string(), eth.address.clone(), dai.address.clone());
1497 let key_dai_eth = ("eth_dai".to_string(), dai.address.clone(), eth.address.clone());
1498 assert!(
1499 guard
1500 .spot_price_failure(&key_eth_dai)
1501 .is_some() ||
1502 guard
1503 .spot_price_failure(&key_dai_eth)
1504 .is_some(),
1505 "store should persist failure reason for eth_dai (missing sim state)"
1506 );
1507 }
1508
1509 struct FailingComputation;
1513
1514 #[async_trait::async_trait]
1515 impl DerivedComputation for FailingComputation {
1516 type Output = ();
1517 const ID: ComputationId = "failing";
1518
1519 async fn compute(
1520 &self,
1521 _market: &MarketData,
1522 _store: &SharedDerivedDataRef,
1523 _changed: &ChangedComponents,
1524 ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1525 Err(ComputationError::InvalidConfiguration("always fails".to_string()))
1526 }
1527 }
1528
1529 fn find_metric<'a>(
1531 recorded: &'a [(
1532 metrics_util::CompositeKey,
1533 Option<metrics::Unit>,
1534 Option<metrics::SharedString>,
1535 metrics_util::debugging::DebugValue,
1536 )],
1537 name: &str,
1538 labels: &[(&str, &str)],
1539 ) -> &'a metrics_util::debugging::DebugValue {
1540 recorded
1541 .iter()
1542 .find(|(key, _, _, _)| {
1543 key.key().name() == name &&
1544 labels
1545 .iter()
1546 .all(|(label_key, label_value)| {
1547 key.key()
1548 .labels()
1549 .any(|l| l.key() == *label_key && l.value() == *label_value)
1550 })
1551 })
1552 .map(|(_, _, _, value)| value)
1553 .unwrap_or_else(|| panic!("missing {name}{labels:?}, got {recorded:?}"))
1554 }
1555
1556 #[test]
1557 fn compute_all_records_derived_metrics() {
1558 use metrics_util::debugging::DebugValue;
1559
1560 let recorder = metrics_util::debugging::DebuggingRecorder::new();
1561 let snapshotter = recorder.snapshotter();
1562 let rt = tokio::runtime::Builder::new_current_thread()
1563 .enable_all()
1564 .build()
1565 .expect("runtime builds");
1566
1567 metrics::with_local_recorder(&recorder, || {
1568 rt.block_on(async {
1569 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1570 manager
1571 .register(CounterComputation)
1572 .unwrap();
1573 manager
1574 .compute_all(&ChangedComponents {
1575 is_full_recompute: true,
1576 ..Default::default()
1577 })
1578 .await;
1579 })
1580 });
1581
1582 let recorded = snapshotter.snapshot().into_vec();
1583 let recorded_names: Vec<(String, Vec<String>)> = recorded
1584 .iter()
1585 .map(|(key, _, _, _)| {
1586 (
1587 key.key().name().to_string(),
1588 key.key()
1589 .labels()
1590 .map(|l| format!("{}={}", l.key(), l.value()))
1591 .collect(),
1592 )
1593 })
1594 .collect();
1595 for expected in
1596 ["derived_computation_duration_seconds", "derived_last_success_timestamp_seconds"]
1597 {
1598 assert!(
1599 recorded_names
1600 .iter()
1601 .any(|(name, labels)| name == expected &&
1602 labels.contains(&"computation=counter".to_string())),
1603 "missing {expected}{{computation=counter}}, got {recorded_names:?}"
1604 );
1605 }
1606
1607 match find_metric(
1608 &recorded,
1609 "derived_last_success_timestamp_seconds",
1610 &[("computation", "counter")],
1611 ) {
1612 DebugValue::Gauge(value) => {
1613 assert!(value.0 > 1.7e9, "gauge value {} not a sane unix timestamp", value.0);
1614 }
1615 other => panic!("derived_last_success_timestamp_seconds is not a gauge: {other:?}"),
1616 }
1617
1618 match find_metric(
1619 &recorded,
1620 "derived_computation_duration_seconds",
1621 &[("computation", "counter")],
1622 ) {
1623 DebugValue::Histogram(samples) => {
1624 assert!(!samples.is_empty(), "expected at least one recorded duration sample");
1625 }
1626 other => panic!("derived_computation_duration_seconds is not a histogram: {other:?}"),
1627 }
1628 }
1629
1630 #[test]
1631 fn compute_all_records_failure_metric() {
1632 use metrics_util::debugging::DebugValue;
1633
1634 let recorder = metrics_util::debugging::DebuggingRecorder::new();
1635 let snapshotter = recorder.snapshotter();
1636 let rt = tokio::runtime::Builder::new_current_thread()
1637 .enable_all()
1638 .build()
1639 .expect("runtime builds");
1640
1641 metrics::with_local_recorder(&recorder, || {
1642 rt.block_on(async {
1643 let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1644 manager
1645 .register(FailingComputation)
1646 .unwrap();
1647 manager
1648 .compute_all(&ChangedComponents {
1649 is_full_recompute: true,
1650 ..Default::default()
1651 })
1652 .await;
1653 })
1654 });
1655
1656 let recorded = snapshotter.snapshot().into_vec();
1657 match find_metric(
1658 &recorded,
1659 "derived_computation_failures_total",
1660 &[("computation", "failing"), ("reason", "error")],
1661 ) {
1662 DebugValue::Counter(value) => {
1663 assert!(*value >= 1, "expected failure counter >= 1, got {value}");
1664 }
1665 other => panic!("derived_computation_failures_total is not a counter: {other:?}"),
1666 }
1667 }
1668}