Skip to main content

fynd_core/derived/
manager.rs

1//! Computation manager for derived data.
2//!
3//! The ComputationManager:
4//! - Subscribes to MarketEvents from TychoFeed
5//! - Runs derived computations (token prices, spot prices, pool depths)
6//! - Updates DerivedDataStore (exclusive write access)
7//! - Provides read access to workers via shared store reference
8
9use 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/// Information about which components changed in a market update.
25///
26/// Used to enable incremental computation - only recomputing derived data
27/// for components that actually changed.
28#[derive(Debug, Clone, Default)]
29pub struct ChangedComponents {
30    /// Newly added components with their token addresses.
31    pub added: HashMap<ComponentId, Vec<Address>>,
32    /// Components that were removed.
33    pub removed: Vec<ComponentId>,
34    /// Components whose state was updated (but not added/removed).
35    pub updated: Vec<ComponentId>,
36    /// If true, this represents a full recompute (startup/lag recovery).
37    pub is_full_recompute: bool,
38}
39
40impl ChangedComponents {
41    /// Creates a marker for full recompute where all components are considered changed.
42    ///
43    /// Used for startup and lag recovery scenarios.
44    pub fn all(market: MarketDataView) -> Self {
45        Self {
46            added: market.component_topology().clone(),
47            removed: vec![],
48            updated: vec![],
49            is_full_recompute: true,
50        }
51    }
52
53    /// Returns true if this update changes the graph topology (adds or removes components).
54    pub fn is_topology_change(&self) -> bool {
55        !self.added.is_empty() || !self.removed.is_empty()
56    }
57
58    /// Returns a HashSet of all changed component IDs.
59    pub fn all_changed_ids(&self) -> HashSet<ComponentId> {
60        let mut all = HashSet::new();
61        all.extend(self.added.keys().cloned());
62        all.extend(self.removed.iter().cloned());
63        all.extend(self.updated.iter().cloned());
64        all
65    }
66}
67
68use super::{
69    computation::{ComputationId, ComputationRequirements, DerivedComputation},
70    computations::{PoolDepthComputation, SpotPriceComputation, TokenGasPriceComputation},
71    error::ComputationError,
72    events::DerivedDataEvent,
73    registry::ErasedComputation,
74    store::DerivedData,
75};
76use crate::feed::{
77    events::{EventError, MarketEvent, MarketEventHandler},
78    market_data::{MarketData, MarketDataView},
79};
80
81/// Thread-safe handle to shared derived data store.
82pub type SharedDerivedDataRef = Arc<RwLock<DerivedData>>;
83
84/// Configuration for the default computation set built by [`ComputationManager::new`].
85#[derive(Debug, Clone)]
86pub struct ComputationManagerConfig {
87    /// Gas token address (e.g., WETH) for token price computation.
88    gas_token: Address,
89    /// Max hop count for token gas price computation.
90    max_hop: usize,
91    /// Slippage threshold for pool depth computation (0.0 < threshold < 1.0).
92    depth_slippage_threshold: f64,
93}
94
95impl ComputationManagerConfig {
96    /// Creates a new configuration with the given gas token.
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Sets the slippage threshold for pool depth computation.
102    pub fn with_depth_slippage_threshold(mut self, threshold: f64) -> Self {
103        self.depth_slippage_threshold = threshold;
104        self
105    }
106
107    /// Sets the max hop count for token gas price computation.
108    pub fn with_max_hop(mut self, hop_count: usize) -> Self {
109        self.max_hop = hop_count;
110        self
111    }
112
113    /// Sets the gas token address.
114    pub fn with_gas_token(mut self, gas_token: Address) -> Self {
115        self.gas_token = gas_token;
116        self
117    }
118
119    /// Returns the gas token address.
120    pub fn gas_token(&self) -> &Address {
121        &self.gas_token
122    }
123
124    /// Returns the max hop count.
125    pub fn max_hop(&self) -> usize {
126        self.max_hop
127    }
128
129    /// Returns the depth slippage threshold.
130    pub fn depth_slippage_threshold(&self) -> f64 {
131        self.depth_slippage_threshold
132    }
133}
134
135impl Default for ComputationManagerConfig {
136    fn default() -> Self {
137        Self { gas_token: Address::zero(20), max_hop: 2, depth_slippage_threshold: 0.01 }
138    }
139}
140
141/// Manages derived data computations triggered by market events.
142pub struct ComputationManager {
143    /// Reference to shared market data (read access).
144    market_data: MarketData,
145    /// Shared derived data store (write access).
146    store: SharedDerivedDataRef,
147    /// Registered computations, driven in dependency-stage order each block.
148    computations: Vec<Box<dyn ErasedComputation>>,
149    /// Event broadcaster for derived data updates.
150    event_tx: broadcast::Sender<DerivedDataEvent>,
151}
152
153/// A dependency-ordered execution plan for the registered computations.
154struct ComputationSchedule {
155    /// Indices into `ComputationManager::computations`, grouped into stages run in order.
156    stages: Vec<Vec<usize>>,
157    /// Indices that could not be ordered because of a requirement cycle.
158    unscheduled: Vec<usize>,
159}
160
161impl ComputationManager {
162    /// Creates a new ComputationManager.
163    ///
164    /// Returns the manager and a receiver for derived data events.
165    /// Workers can subscribe to the event sender via `event_sender()` to track
166    /// computation readiness.
167    pub fn new(
168        config: ComputationManagerConfig,
169        market_data: MarketData,
170    ) -> Result<(Self, broadcast::Receiver<DerivedDataEvent>), ComputationError> {
171        let (mut manager, event_rx) = Self::empty(market_data);
172        manager.register(SpotPriceComputation::new())?;
173        manager.register(
174            TokenGasPriceComputation::default()
175                .with_max_hops(config.max_hop)
176                .with_gas_token(config.gas_token),
177        )?;
178        manager.register(PoolDepthComputation::new(config.depth_slippage_threshold)?)?;
179        Ok((manager, event_rx))
180    }
181
182    /// Creates a manager with no computations registered.
183    ///
184    /// [`new`](Self::new) builds on this to assemble the default computation set, and
185    /// tests drive a custom set through [`register`](Self::register).
186    pub(crate) fn empty(market_data: MarketData) -> (Self, broadcast::Receiver<DerivedDataEvent>) {
187        let (event_tx, event_rx) = broadcast::channel(64);
188        (
189            Self {
190                market_data,
191                store: DerivedData::new_shared(),
192                computations: Vec::new(),
193                event_tx,
194            },
195            event_rx,
196        )
197    }
198
199    /// Registers a computation to be driven each block.
200    ///
201    /// Registration order is preserved within a dependency stage; cross-stage order is
202    /// derived from each computation's
203    /// [`requirements`](crate::derived::computation::DerivedComputation::requirements).
204    ///
205    /// # Errors
206    ///
207    /// Returns [`ComputationError::DuplicateComputationId`] if a computation with the same
208    /// [`ID`](DerivedComputation::ID) is already registered.
209    pub(crate) fn register<C: DerivedComputation>(
210        &mut self,
211        computation: C,
212    ) -> Result<(), ComputationError> {
213        if self
214            .computations
215            .iter()
216            .any(|existing| existing.id() == C::ID)
217        {
218            return Err(ComputationError::DuplicateComputationId(C::ID));
219        }
220        self.computations
221            .push(Box::new(computation));
222        Ok(())
223    }
224
225    /// Returns a reference to the shared derived data store.
226    pub fn store(&self) -> SharedDerivedDataRef {
227        Arc::clone(&self.store)
228    }
229
230    /// Returns the event sender for workers to subscribe.
231    pub fn event_sender(&self) -> broadcast::Sender<DerivedDataEvent> {
232        self.event_tx.clone()
233    }
234
235    /// Runs the main loop until shutdown or channel close.
236    ///
237    /// **Note:** Consumes `self`. Call [`store()`](Self::store) before `run()` to retain access.
238    pub async fn run(
239        mut self,
240        mut event_rx: broadcast::Receiver<MarketEvent>,
241        mut shutdown_rx: broadcast::Receiver<()>,
242    ) {
243        info!("computation manager started");
244
245        loop {
246            tokio::select! {
247                biased;
248
249                _ = shutdown_rx.recv() => {
250                    info!("computation manager shutting down");
251                    break;
252                }
253
254                event_result = event_rx.recv() => {
255                    match event_result {
256                        Ok(event) => {
257                            if let Err(e) = self.handle_event(&event).await {
258                                warn!(error = ?e, "failed to handle market event");
259                            }
260                        }
261                        Err(broadcast::error::RecvError::Closed) => {
262                            info!("event channel closed, computation manager shutting down");
263                            break;
264                        }
265                        Err(broadcast::error::RecvError::Lagged(skipped)) => {
266                            warn!(
267                                skipped,
268                                "computation manager lagged, skipped {} events. Recomputing from current state.",
269                                skipped
270                            );
271                            let market = self.market_data.read().await;
272                            let changed = ChangedComponents::all(market);
273                            self.compute_all(&changed).await;
274                        }
275                    }
276                }
277            }
278        }
279    }
280
281    /// Runs all registered computations for the current block and updates the store.
282    ///
283    /// Computations run in dependency stages derived from their
284    /// [`requirements`](crate::derived::computation::DerivedComputation::requirements):
285    /// a stage runs concurrently and is written before the next stage starts, and a
286    /// computation whose requirement did not succeed this block is skipped and reported
287    /// as failed. Broadcasts a `DerivedDataEvent` per computation.
288    async fn compute_all(&self, changed: &ChangedComponents) {
289        let total_start = Instant::now();
290
291        // Get block info for tracking
292        let Some(block) = self
293            .market_data
294            .read()
295            .await
296            .last_updated()
297            .map(|b| b.number())
298        else {
299            warn!("market data has no last updated block, skipping computations");
300            return;
301        };
302
303        // Broadcast new block event
304        let _ = self
305            .event_tx
306            .send(DerivedDataEvent::NewBlock { block });
307
308        let nodes: Vec<(ComputationId, ComputationRequirements)> = self
309            .computations
310            .iter()
311            .map(|computation| (computation.id(), computation.requirements()))
312            .collect();
313        let schedule = build_schedule(&nodes);
314        for &idx in &schedule.unscheduled {
315            let computation_id = nodes[idx].0;
316            error!(computation = computation_id, "computation skipped: requirement cycle");
317            counter!(
318                "derived_computation_failures_total",
319                "computation" => computation_id,
320                "reason" => "cycle"
321            )
322            .increment(1);
323            let _ = self
324                .event_tx
325                .send(DerivedDataEvent::ComputationFailed { computation_id, block });
326        }
327
328        let mut succeeded: HashSet<ComputationId> = HashSet::new();
329        for stage in &schedule.stages {
330            // Split the stage into runnable computations and ones whose requirements did
331            // not hold this block; the latter are skipped and reported as failed.
332            let mut runnable = Vec::new();
333            {
334                let store = self.store.read().await;
335                for &idx in stage {
336                    let reqs = &nodes[idx].1;
337                    let fresh_ready = reqs
338                        .fresh_requirements()
339                        .iter()
340                        .all(|id| succeeded.contains(id));
341                    let stale_ready = reqs
342                        .stale_requirements()
343                        .iter()
344                        .all(|id| succeeded.contains(id) || store.output_block(id).is_some());
345                    if fresh_ready && stale_ready {
346                        runnable.push(idx);
347                    } else {
348                        let computation_id = nodes[idx].0;
349                        counter!(
350                            "derived_computation_failures_total",
351                            "computation" => computation_id,
352                            "reason" => "upstream_failed"
353                        )
354                        .increment(1);
355                        let _ = self
356                            .event_tx
357                            .send(DerivedDataEvent::ComputationFailed { computation_id, block });
358                    }
359                }
360            }
361
362            if runnable.is_empty() {
363                continue;
364            }
365
366            // Run this stage's computations concurrently; they read the store as needed.
367            let results = join_all(runnable.iter().map(|&idx| async move {
368                let start = Instant::now();
369                let result = self.computations[idx]
370                    .compute_erased(&self.market_data, &self.store, changed, block)
371                    .await;
372                (idx, result, start.elapsed())
373            }))
374            .await;
375
376            // Persist and report in stage order, taking the write lock once for the stage.
377            let mut store = self.store.write().await;
378            for (idx, result, elapsed) in results {
379                let computation_id = nodes[idx].0;
380                match result {
381                    Ok(write) => {
382                        (write.persist)(&mut store);
383                        histogram!(
384                            "derived_computation_duration_seconds",
385                            "computation" => computation_id
386                        )
387                        .record(elapsed.as_secs_f64());
388                        gauge!(
389                            "derived_last_success_timestamp_seconds",
390                            "computation" => computation_id
391                        )
392                        .set(unix_now_seconds());
393                        info!(
394                            computation = computation_id,
395                            failed = write.failed_items.len(),
396                            elapsed_ms = elapsed.as_millis(),
397                            "computation complete"
398                        );
399                        let _ = self
400                            .event_tx
401                            .send(DerivedDataEvent::ComputationComplete {
402                                computation_id,
403                                block,
404                                failed_items: write.failed_items,
405                            });
406                        succeeded.insert(computation_id);
407                    }
408                    Err(e) => {
409                        counter!(
410                            "derived_computation_failures_total",
411                            "computation" => computation_id,
412                            "reason" => "error"
413                        )
414                        .increment(1);
415                        warn!(
416                            error = ?e,
417                            computation = computation_id,
418                            elapsed_ms = elapsed.as_millis(),
419                            "computation failed"
420                        );
421                        let _ = self
422                            .event_tx
423                            .send(DerivedDataEvent::ComputationFailed { computation_id, block });
424                    }
425                }
426            }
427        }
428
429        info!(
430            block,
431            total_ms = total_start.elapsed().as_millis(),
432            "all derived computations complete"
433        );
434    }
435}
436
437/// Seconds since the Unix epoch, for freshness gauges consumed as `time() - <gauge>`.
438fn unix_now_seconds() -> f64 {
439    SystemTime::now()
440        .duration_since(UNIX_EPOCH)
441        .map(|elapsed| elapsed.as_secs_f64())
442        .unwrap_or(0.0)
443}
444
445/// Computes the dependency-ordered execution plan for `nodes` (id paired with its
446/// requirements).
447///
448/// Each node lands in a later stage than the `nodes` it requires; input order is
449/// preserved within a stage. Nodes caught in a requirement cycle cannot be ordered and
450/// are returned as `unscheduled`. A requirement naming an id absent from `nodes` does
451/// not affect ordering (it is left to the runtime readiness check).
452fn build_schedule(nodes: &[(ComputationId, ComputationRequirements)]) -> ComputationSchedule {
453    let ids: Vec<ComputationId> = nodes
454        .iter()
455        .map(|(id, _)| *id)
456        .collect();
457    let mut stage_of: Vec<Option<usize>> = vec![None; nodes.len()];
458
459    loop {
460        let mut progressed = false;
461        for (idx, (_, reqs)) in nodes.iter().enumerate() {
462            if stage_of[idx].is_some() {
463                continue;
464            }
465            let mut stage = 0;
466            let mut ready = true;
467            for dep in reqs
468                .fresh_requirements()
469                .iter()
470                .chain(reqs.stale_requirements().iter())
471            {
472                let Some(dep_idx) = ids.iter().position(|id| id == dep) else {
473                    continue;
474                };
475                match stage_of[dep_idx] {
476                    Some(dep_stage) => stage = stage.max(dep_stage + 1),
477                    None => {
478                        ready = false;
479                        break;
480                    }
481                }
482            }
483            if ready {
484                stage_of[idx] = Some(stage);
485                progressed = true;
486            }
487        }
488        if !progressed {
489            break;
490        }
491    }
492
493    let stage_count = stage_of
494        .iter()
495        .filter_map(|stage| *stage)
496        .max()
497        .map_or(0, |max| max + 1);
498    let mut stages = vec![Vec::new(); stage_count];
499    let mut unscheduled = Vec::new();
500    for (idx, stage) in stage_of.iter().enumerate() {
501        match stage {
502            Some(stage) => stages[*stage].push(idx),
503            None => unscheduled.push(idx),
504        }
505    }
506    ComputationSchedule { stages, unscheduled }
507}
508
509#[async_trait]
510impl MarketEventHandler for ComputationManager {
511    async fn handle_event(&mut self, event: &MarketEvent) -> Result<(), EventError> {
512        match event {
513            MarketEvent::MarketUpdated {
514                added_components,
515                removed_components,
516                updated_components,
517            } if !added_components.is_empty() ||
518                !removed_components.is_empty() ||
519                !updated_components.is_empty() =>
520            {
521                trace!(
522                    added = added_components.len(),
523                    removed = removed_components.len(),
524                    updated = updated_components.len(),
525                    "market updated, running incremental computations"
526                );
527
528                let changed = ChangedComponents {
529                    added: added_components.clone(),
530                    removed: removed_components.clone(),
531                    updated: updated_components.clone(),
532                    is_full_recompute: false,
533                };
534                self.compute_all(&changed).await;
535            }
536            _ => {
537                trace!("empty market update, skipping computations");
538            }
539        }
540
541        Ok(())
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use std::{
548        collections::HashMap,
549        sync::{
550            atomic::{AtomicBool, Ordering},
551            Arc,
552        },
553    };
554
555    use tokio::sync::broadcast;
556
557    use super::*;
558    use crate::{
559        algorithm::test_utils::{component, setup_market_weighted, token, MockProtocolSim},
560        derived::computation::{ComputationOutput, FailedItem, FailedItemError},
561        feed::market_data::{MarketData, MarketState},
562        types::BlockInfo,
563    };
564
565    /// Drains all currently-pending events from a broadcast receiver into a Vec.
566    fn drain_events(rx: &mut broadcast::Receiver<DerivedDataEvent>) -> Vec<DerivedDataEvent> {
567        let mut events = vec![];
568        loop {
569            match rx.try_recv() {
570                Ok(e) => events.push(e),
571                Err(broadcast::error::TryRecvError::Empty) => break,
572                Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
573                Err(broadcast::error::TryRecvError::Closed) => break,
574            }
575        }
576        events
577    }
578
579    // --- build_schedule: dependency staging (pure) --------------------------------
580
581    #[test]
582    fn schedule_empty_has_no_stages() {
583        let schedule = build_schedule(&[]);
584        assert!(schedule.stages.is_empty());
585        assert!(schedule.unscheduled.is_empty());
586    }
587
588    #[test]
589    fn schedule_single_root_is_one_stage() {
590        let schedule = build_schedule(&[("a", ComputationRequirements::none())]);
591        assert_eq!(schedule.stages, vec![vec![0]]);
592        assert!(schedule.unscheduled.is_empty());
593    }
594
595    #[test]
596    fn schedule_independent_roots_share_one_stage() {
597        let schedule = build_schedule(&[
598            ("a", ComputationRequirements::none()),
599            ("b", ComputationRequirements::none()),
600        ]);
601        assert_eq!(schedule.stages, vec![vec![0, 1]]);
602        assert!(schedule.unscheduled.is_empty());
603    }
604
605    #[test]
606    fn schedule_chain_orders_into_successive_stages() {
607        // a <- b <- c
608        let schedule = build_schedule(&[
609            ("a", ComputationRequirements::none()),
610            ("b", ComputationRequirements::fresh(["a"])),
611            ("c", ComputationRequirements::fresh(["b"])),
612        ]);
613        assert_eq!(schedule.stages, vec![vec![0], vec![1], vec![2]]);
614        assert!(schedule.unscheduled.is_empty());
615    }
616
617    #[test]
618    fn schedule_diamond_places_join_after_both_parents() {
619        // a <- {b, c} <- d; mirrors fynd's spot -> {token, pool} fan-out.
620        let schedule = build_schedule(&[
621            ("a", ComputationRequirements::none()),
622            ("b", ComputationRequirements::fresh(["a"])),
623            ("c", ComputationRequirements::fresh(["a"])),
624            ("d", ComputationRequirements::fresh(["b", "c"])),
625        ]);
626        assert_eq!(schedule.stages, vec![vec![0], vec![1, 2], vec![3]]);
627        assert!(schedule.unscheduled.is_empty());
628    }
629
630    #[test]
631    fn schedule_preserves_input_order_within_a_stage() {
632        let schedule = build_schedule(&[
633            ("a", ComputationRequirements::none()),
634            ("b", ComputationRequirements::fresh(["a"])),
635            ("c", ComputationRequirements::fresh(["a"])),
636        ]);
637        // b registered before c, so it comes first in the shared stage.
638        assert_eq!(schedule.stages, vec![vec![0], vec![1, 2]]);
639    }
640
641    #[test]
642    fn schedule_stale_requirement_orders_after_its_producer() {
643        let schedule = build_schedule(&[
644            ("a", ComputationRequirements::none()),
645            ("b", ComputationRequirements::stale(["a"])),
646        ]);
647        assert_eq!(schedule.stages, vec![vec![0], vec![1]]);
648    }
649
650    #[test]
651    fn schedule_requirement_on_unregistered_id_does_not_affect_ordering() {
652        // "ghost" is not registered, so "a" is treated as a root.
653        let schedule = build_schedule(&[("a", ComputationRequirements::fresh(["ghost"]))]);
654        assert_eq!(schedule.stages, vec![vec![0]]);
655        assert!(schedule.unscheduled.is_empty());
656    }
657
658    #[test]
659    fn schedule_two_node_cycle_is_unscheduled() {
660        let schedule = build_schedule(&[
661            ("a", ComputationRequirements::fresh(["b"])),
662            ("b", ComputationRequirements::fresh(["a"])),
663        ]);
664        assert!(schedule.stages.is_empty());
665        assert_eq!(schedule.unscheduled, vec![0, 1]);
666    }
667
668    #[test]
669    fn schedule_isolates_cycle_from_schedulable_nodes() {
670        // "root" schedules normally; "x" and "y" form a cycle and are unscheduled.
671        let schedule = build_schedule(&[
672            ("root", ComputationRequirements::none()),
673            ("x", ComputationRequirements::fresh(["y"])),
674            ("y", ComputationRequirements::fresh(["x"])),
675        ]);
676        assert_eq!(schedule.stages, vec![vec![0]]);
677        assert_eq!(schedule.unscheduled, vec![1, 2]);
678    }
679
680    #[test]
681    fn invalid_slippage_threshold_returns_error() {
682        let (market, _) = setup_market_weighted(vec![]);
683        let config = ComputationManagerConfig::new().with_depth_slippage_threshold(1.5);
684
685        let result = ComputationManager::new(config, market);
686        assert!(matches!(result, Err(ComputationError::InvalidConfiguration(_))));
687    }
688
689    #[tokio::test]
690    async fn handle_event_runs_computations_on_market_update() {
691        let eth = token(1, "ETH");
692        let usdc = token(2, "USDC");
693
694        let (market, _) = setup_market_weighted(vec![(
695            "eth_usdc",
696            &eth,
697            &usdc,
698            MockProtocolSim::new(2000.0).with_gas(0),
699        )]);
700
701        let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
702        let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
703
704        let event = MarketEvent::MarketUpdated {
705            added_components: HashMap::from([(
706                "eth_usdc".to_string(),
707                vec![eth.address.clone(), usdc.address.clone()],
708            )]),
709            removed_components: vec![],
710            updated_components: vec![],
711        };
712
713        manager
714            .handle_event(&event)
715            .await
716            .unwrap();
717
718        let store = manager.store();
719        let guard = store.read().await;
720        assert!(guard.token_prices().is_some());
721        assert!(guard.spot_prices().is_some());
722    }
723
724    #[tokio::test]
725    async fn handle_event_skips_empty_update() {
726        let (market, _) = setup_market_weighted(vec![]);
727        let config = ComputationManagerConfig::new();
728        let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
729
730        let event = MarketEvent::MarketUpdated {
731            added_components: HashMap::new(),
732            removed_components: vec![],
733            updated_components: vec![],
734        };
735
736        manager
737            .handle_event(&event)
738            .await
739            .unwrap();
740
741        let store = manager.store();
742        let guard = store.read().await;
743        assert!(guard.token_prices().is_none());
744    }
745
746    #[tokio::test]
747    async fn run_shuts_down_on_signal() {
748        let (market, _) = setup_market_weighted(vec![]);
749        let config = ComputationManagerConfig::new();
750        let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
751
752        let (_event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
753        let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
754
755        let handle = tokio::spawn(async move {
756            manager.run(event_rx, shutdown_rx).await;
757        });
758
759        shutdown_tx.send(()).unwrap();
760
761        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
762            .await
763            .expect("manager should shutdown")
764            .expect("task should complete successfully");
765    }
766
767    // --- registry seam: custom computations driven through the manager -------------
768
769    #[derive(Clone, Debug, PartialEq)]
770    struct CounterOutput(u32);
771
772    /// A minimal computation that ignores market data and uses the default `persist`
773    /// (the path a downstream computation takes: store into the generic slot).
774    struct CounterComputation;
775
776    #[async_trait::async_trait]
777    impl DerivedComputation for CounterComputation {
778        type Output = CounterOutput;
779        const ID: ComputationId = "counter";
780
781        async fn compute(
782            &self,
783            _market: &MarketData,
784            _store: &SharedDerivedDataRef,
785            _changed: &ChangedComponents,
786        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
787            Ok(ComputationOutput::success(CounterOutput(7)))
788        }
789    }
790
791    /// Builds a market carrying a `last_updated` block so `compute_all` runs.
792    fn market_with_block() -> MarketData {
793        let eth = token(1, "ETH");
794        let usdc = token(2, "USDC");
795        let (market, _) = setup_market_weighted(vec![(
796            "eth_usdc",
797            &eth,
798            &usdc,
799            MockProtocolSim::new(2000.0).with_gas(0),
800        )]);
801        market
802    }
803
804    #[tokio::test]
805    async fn registered_custom_computation_runs_and_persists_via_default_slot() {
806        let (mut manager, mut event_rx) = ComputationManager::empty(market_with_block());
807        manager
808            .register(CounterComputation)
809            .unwrap();
810
811        manager
812            .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
813            .await;
814
815        let store = manager.store();
816        let guard = store.read().await;
817        assert_eq!(
818            guard.output::<CounterOutput>(CounterComputation::ID),
819            Some(&CounterOutput(7)),
820            "default persist should write the output into the generic slot"
821        );
822        assert!(guard
823            .output_block(CounterComputation::ID)
824            .is_some());
825
826        let events = drain_events(&mut event_rx);
827        assert!(
828            events.iter().any(|e| matches!(
829                e,
830                DerivedDataEvent::ComputationComplete { computation_id: "counter", .. }
831            )),
832            "expected ComputationComplete(counter), got: {events:?}"
833        );
834    }
835
836    #[test]
837    fn registering_duplicate_id_is_rejected() {
838        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
839        manager
840            .register(CounterComputation)
841            .unwrap();
842
843        let result = manager.register(CounterComputation);
844
845        assert!(matches!(result, Err(ComputationError::DuplicateComputationId("counter"))));
846    }
847
848    // --- exact event sequences (characterization) ---------------------------------
849
850    /// Reduces an event stream to `(kind, computation_id)` pairs for exact comparison.
851    fn event_summary(events: &[DerivedDataEvent]) -> Vec<(&'static str, &'static str)> {
852        events
853            .iter()
854            .map(|event| match event {
855                DerivedDataEvent::NewBlock { .. } => ("new_block", ""),
856                DerivedDataEvent::ComputationComplete { computation_id, .. } => {
857                    ("complete", *computation_id)
858                }
859                DerivedDataEvent::ComputationFailed { computation_id, .. } => {
860                    ("failed", *computation_id)
861                }
862            })
863            .collect()
864    }
865
866    /// Subscribes, runs one full-recompute pass, and returns the events it emitted.
867    async fn run_full_recompute(manager: &ComputationManager) -> Vec<DerivedDataEvent> {
868        let mut event_rx = manager.event_sender().subscribe();
869        manager
870            .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
871            .await;
872        drain_events(&mut event_rx)
873    }
874
875    /// Defines a market-independent test computation with a fixed id, requirements, result.
876    macro_rules! test_computation {
877        ($name:ident, $id:literal, $reqs:expr, $result:expr) => {
878            struct $name;
879
880            #[async_trait::async_trait]
881            impl DerivedComputation for $name {
882                type Output = ();
883                const ID: ComputationId = $id;
884
885                fn requirements(&self) -> ComputationRequirements {
886                    $reqs
887                }
888
889                async fn compute(
890                    &self,
891                    _market: &MarketData,
892                    _store: &SharedDerivedDataRef,
893                    _changed: &ChangedComponents,
894                ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
895                    $result
896                }
897            }
898        };
899    }
900
901    test_computation!(
902        RootOk,
903        "root",
904        ComputationRequirements::none(),
905        Ok(ComputationOutput::success(()))
906    );
907    test_computation!(
908        DepOnRoot,
909        "dep",
910        ComputationRequirements::fresh(["root"]),
911        Ok(ComputationOutput::success(()))
912    );
913    test_computation!(
914        SecondDepOnRoot,
915        "dep2",
916        ComputationRequirements::fresh(["root"]),
917        Ok(ComputationOutput::success(()))
918    );
919    test_computation!(
920        RootErr,
921        "boom",
922        ComputationRequirements::none(),
923        Err(ComputationError::InvalidConfiguration("boom".to_string()))
924    );
925    test_computation!(
926        DepOnBoom,
927        "dep_boom",
928        ComputationRequirements::fresh(["boom"]),
929        Ok(ComputationOutput::success(()))
930    );
931    test_computation!(
932        ThirdOnBoom,
933        "third",
934        ComputationRequirements::fresh(["dep_boom"]),
935        Ok(ComputationOutput::success(()))
936    );
937    test_computation!(
938        StaleDepOnFlaky,
939        "stale_dep",
940        ComputationRequirements::stale(["flaky"]),
941        Ok(ComputationOutput::success(()))
942    );
943    test_computation!(
944        GhostDependent,
945        "needs_ghost",
946        ComputationRequirements::fresh(["ghost"]),
947        Ok(ComputationOutput::success(()))
948    );
949    test_computation!(
950        PartialProducer,
951        "partial",
952        ComputationRequirements::none(),
953        Ok(ComputationOutput::with_failures(
954            (),
955            vec![FailedItem { key: "x".to_string(), error: FailedItemError::MissingSpotPrice }]
956        ))
957    );
958    test_computation!(
959        DepOnPartial,
960        "dep_partial",
961        ComputationRequirements::fresh(["partial"]),
962        Ok(ComputationOutput::success(()))
963    );
964
965    /// A producer that succeeds while its flag is set and fails once it is cleared, so a
966    /// later block can exercise the stale-dependency path (producer failed this block, but
967    /// a prior-block value is still in the store).
968    struct FlakyProducer {
969        succeed: Arc<AtomicBool>,
970    }
971
972    #[async_trait::async_trait]
973    impl DerivedComputation for FlakyProducer {
974        type Output = ();
975        const ID: ComputationId = "flaky";
976
977        async fn compute(
978            &self,
979            _market: &MarketData,
980            _store: &SharedDerivedDataRef,
981            _changed: &ChangedComponents,
982        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
983            if self.succeed.load(Ordering::SeqCst) {
984                Ok(ComputationOutput::success(()))
985            } else {
986                Err(ComputationError::InvalidConfiguration("flaky".to_string()))
987            }
988        }
989    }
990
991    #[tokio::test]
992    async fn events_follow_dependency_order_across_stages() {
993        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
994        manager.register(RootOk).unwrap();
995        manager.register(DepOnRoot).unwrap();
996
997        let events = run_full_recompute(&manager).await;
998
999        assert_eq!(
1000            event_summary(&events),
1001            vec![("new_block", ""), ("complete", "root"), ("complete", "dep")]
1002        );
1003    }
1004
1005    #[tokio::test]
1006    async fn events_preserve_registration_order_within_a_stage() {
1007        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1008        manager.register(RootOk).unwrap();
1009        manager.register(DepOnRoot).unwrap();
1010        manager
1011            .register(SecondDepOnRoot)
1012            .unwrap();
1013
1014        let events = run_full_recompute(&manager).await;
1015
1016        // root runs in stage 0; dep then dep2 share stage 1 in registration order.
1017        assert_eq!(
1018            event_summary(&events),
1019            vec![
1020                ("new_block", ""),
1021                ("complete", "root"),
1022                ("complete", "dep"),
1023                ("complete", "dep2"),
1024            ]
1025        );
1026    }
1027
1028    #[tokio::test]
1029    async fn failed_dependency_cascades_to_dependents() {
1030        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1031        manager.register(RootErr).unwrap();
1032        manager.register(DepOnBoom).unwrap();
1033
1034        let events = run_full_recompute(&manager).await;
1035
1036        // boom fails in stage 0; its dependent is skipped and reported failed.
1037        assert_eq!(
1038            event_summary(&events),
1039            vec![("new_block", ""), ("failed", "boom"), ("failed", "dep_boom")]
1040        );
1041    }
1042
1043    #[tokio::test]
1044    async fn computation_with_unregistered_requirement_is_skipped() {
1045        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1046        manager
1047            .register(GhostDependent)
1048            .unwrap();
1049
1050        let events = run_full_recompute(&manager).await;
1051
1052        // "ghost" is never registered, so its fresh dependent never runs.
1053        assert_eq!(event_summary(&events), vec![("new_block", ""), ("failed", "needs_ghost")]);
1054    }
1055
1056    #[tokio::test]
1057    async fn fresh_dependent_runs_when_producer_succeeds_partially() {
1058        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1059        manager
1060            .register(PartialProducer)
1061            .unwrap();
1062        manager.register(DepOnPartial).unwrap();
1063
1064        let events = run_full_recompute(&manager).await;
1065
1066        // A partial success (Ok with failed_items) still counts as succeeded, so the fresh
1067        // dependent runs -- the compatibility invariant with the old hardcoded flow.
1068        assert_eq!(
1069            event_summary(&events),
1070            vec![("new_block", ""), ("complete", "partial"), ("complete", "dep_partial"),]
1071        );
1072    }
1073
1074    #[tokio::test]
1075    async fn failure_cascade_propagates_through_three_levels() {
1076        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1077        manager.register(RootErr).unwrap();
1078        manager.register(DepOnBoom).unwrap();
1079        manager.register(ThirdOnBoom).unwrap();
1080
1081        let events = run_full_recompute(&manager).await;
1082
1083        // boom fails; dep_boom is skipped; third (needs dep_boom) is skipped transitively.
1084        assert_eq!(
1085            event_summary(&events),
1086            vec![
1087                ("new_block", ""),
1088                ("failed", "boom"),
1089                ("failed", "dep_boom"),
1090                ("failed", "third"),
1091            ]
1092        );
1093    }
1094
1095    #[tokio::test]
1096    async fn stale_dependency_runs_on_prior_value_after_producer_fails() {
1097        let succeed = Arc::new(AtomicBool::new(true));
1098        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1099        manager
1100            .register(FlakyProducer { succeed: Arc::clone(&succeed) })
1101            .unwrap();
1102        manager
1103            .register(StaleDepOnFlaky)
1104            .unwrap();
1105
1106        // Block 1: producer succeeds and its value is stored.
1107        let first = run_full_recompute(&manager).await;
1108        assert_eq!(
1109            event_summary(&first),
1110            vec![("new_block", ""), ("complete", "flaky"), ("complete", "stale_dep")]
1111        );
1112
1113        // Block 2: producer fails, but its prior-block value remains, so the stale
1114        // dependent still runs.
1115        succeed.store(false, Ordering::SeqCst);
1116        let second = run_full_recompute(&manager).await;
1117        assert_eq!(
1118            event_summary(&second),
1119            vec![("new_block", ""), ("failed", "flaky"), ("complete", "stale_dep")]
1120        );
1121    }
1122
1123    #[tokio::test]
1124    async fn default_computations_cascade_failure_in_registration_order() {
1125        // Real fynd flow: a full recompute with no sim state makes spot prices fail
1126        // outright, cascading ComputationFailed to every dependent in registration order.
1127        let (manager, _event_rx) = ComputationManager::new(
1128            ComputationManagerConfig::new(),
1129            market_with_component_no_sim_state(),
1130        )
1131        .unwrap();
1132
1133        let events = run_full_recompute(&manager).await;
1134
1135        assert_eq!(
1136            event_summary(&events),
1137            vec![
1138                ("new_block", ""),
1139                ("failed", "spot_prices"),
1140                ("failed", "token_prices"),
1141                ("failed", "pool_depths"),
1142            ]
1143        );
1144    }
1145
1146    /// Creates a market with a component in topology but WITHOUT simulation state.
1147    ///
1148    /// Used to trigger `TotalFailure` in spot_price computation (full recompute with
1149    /// all components missing sim_state → succeeded == 0 → failure).
1150    fn market_with_component_no_sim_state() -> MarketData {
1151        let eth = token(1, "ETH");
1152        let usdc = token(2, "USDC");
1153        let pool = component("pool", &[eth.clone(), usdc.clone()]);
1154
1155        let mut market = MarketState::new();
1156        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1157        market.upsert_components(std::iter::once(pool));
1158        // Note: no update_states() — simulation state is intentionally absent
1159        market.upsert_tokens([eth, usdc]);
1160        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1161    }
1162
1163    /// Creates a market with two pools: one with sim state (pool succeeds) and one without (pool
1164    /// fails). Used to trigger partial spot price failure.
1165    fn market_with_mixed_sim_states() -> MarketData {
1166        let eth = token(1, "ETH");
1167        let usdc = token(2, "USDC");
1168        let dai = token(3, "DAI");
1169
1170        let pool1 = component("eth_usdc", &[eth.clone(), usdc.clone()]);
1171        let pool2 = component("eth_dai", &[eth.clone(), dai.clone()]);
1172
1173        let mut market = MarketState::new();
1174        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1175        market.upsert_components([pool1, pool2]);
1176        // Only pool1 has simulation state; pool2 intentionally has none
1177        market
1178            .update_states([("eth_usdc".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
1179        market.upsert_tokens([eth, usdc, dai]);
1180        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1181    }
1182
1183    /// Creates a market WITH sim_state but WITHOUT gas_price.
1184    ///
1185    /// Spot price computation succeeds (MockProtocolSim works), but token_price
1186    /// computation fails with `MissingDependency("gas_price")`.
1187    fn market_with_sim_state_no_gas_price() -> MarketData {
1188        let eth = token(1, "ETH");
1189        let usdc = token(2, "USDC");
1190        let pool = component("pool", &[eth.clone(), usdc.clone()]);
1191
1192        let mut market = MarketState::new();
1193        // Note: no update_gas_price() — gas price is intentionally absent
1194        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1195        market.upsert_components(std::iter::once(pool));
1196        market.update_states([("pool".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
1197        market.upsert_tokens([eth, usdc]);
1198        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1199    }
1200
1201    #[tokio::test]
1202    async fn test_spot_price_failure_broadcasts_computation_failed() {
1203        let market = market_with_component_no_sim_state();
1204        let config = ComputationManagerConfig::new();
1205        let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1206
1207        // Full recompute with components that have no sim_state → TotalFailure
1208        let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1209        manager.compute_all(&changed).await;
1210
1211        let events = drain_events(&mut event_rx);
1212
1213        assert!(
1214            events.iter().any(|e| matches!(
1215                e,
1216                DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1217            )),
1218            "expected ComputationFailed(spot_prices) in events: {events:?}"
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn test_token_price_failure_broadcasts_computation_failed() {
1224        let eth = token(1, "ETH");
1225        let usdc = token(2, "USDC");
1226        let market = market_with_sim_state_no_gas_price();
1227        let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
1228        let (mut manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1229
1230        // handle_event with added components — spot_price succeeds, token_price fails
1231        let event = MarketEvent::MarketUpdated {
1232            added_components: HashMap::from([(
1233                "pool".to_string(),
1234                vec![eth.address.clone(), usdc.address.clone()],
1235            )]),
1236            removed_components: vec![],
1237            updated_components: vec![],
1238        };
1239        manager
1240            .handle_event(&event)
1241            .await
1242            .unwrap();
1243
1244        let events = drain_events(&mut event_rx);
1245        assert!(
1246            events.iter().any(|e| matches!(
1247                e,
1248                DerivedDataEvent::ComputationFailed { computation_id: "token_prices", .. }
1249            )),
1250            "expected ComputationFailed(token_prices) in events: {events:?}"
1251        );
1252    }
1253
1254    #[tokio::test]
1255    async fn run_shuts_down_on_channel_close() {
1256        let (market, _) = setup_market_weighted(vec![]);
1257        let config = ComputationManagerConfig::new();
1258        let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
1259
1260        let (event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
1261        let (_shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
1262
1263        let handle = tokio::spawn(async move {
1264            manager.run(event_rx, shutdown_rx).await;
1265        });
1266
1267        drop(event_tx);
1268
1269        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
1270            .await
1271            .expect("manager should shutdown on channel close")
1272            .expect("task should complete successfully");
1273    }
1274
1275    #[tokio::test]
1276    async fn partial_spot_price_failure_broadcasts_computation_complete() {
1277        // market_with_mixed_sim_states has pool1 (with sim state) and pool2 (without)
1278        // → spot price computation partially succeeds → ComputationComplete with failed_items
1279        let market = market_with_mixed_sim_states();
1280        let config = ComputationManagerConfig::new();
1281        let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1282
1283        let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1284        manager.compute_all(&changed).await;
1285
1286        let events = drain_events(&mut event_rx);
1287
1288        // Should broadcast ComputationComplete (not ComputationFailed) because pool1 succeeds
1289        assert!(
1290            events.iter().any(|e| matches!(
1291                e,
1292                DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. }
1293            )),
1294            "expected ComputationComplete(spot_prices), got: {events:?}"
1295        );
1296        assert!(
1297            !events.iter().any(|e| matches!(
1298                e,
1299                DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1300            )),
1301            "should not broadcast ComputationFailed for partial failure"
1302        );
1303
1304        // The ComputationComplete event should carry the failed item for pool2
1305        let complete = events.iter().find(|e| {
1306            matches!(e, DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. })
1307        });
1308        if let Some(DerivedDataEvent::ComputationComplete { failed_items, .. }) = complete {
1309            assert!(
1310                !failed_items.is_empty(),
1311                "ComputationComplete should carry failed_items for pool2"
1312            );
1313        }
1314
1315        // The store should persist the failure reason for the failed pool.
1316        // market_with_mixed_sim_states uses token(1, "ETH") and token(3, "DAI") for pool2.
1317        let eth = token(1, "ETH");
1318        let dai = token(3, "DAI");
1319        let store = manager.store();
1320        let guard = store.read().await;
1321        let key_eth_dai = ("eth_dai".to_string(), eth.address.clone(), dai.address.clone());
1322        let key_dai_eth = ("eth_dai".to_string(), dai.address.clone(), eth.address.clone());
1323        assert!(
1324            guard
1325                .spot_price_failure(&key_eth_dai)
1326                .is_some() ||
1327                guard
1328                    .spot_price_failure(&key_dai_eth)
1329                    .is_some(),
1330            "store should persist failure reason for eth_dai (missing sim state)"
1331        );
1332    }
1333
1334    // --- metrics ---------------------------------------------------------------------
1335
1336    /// Mirrors `CounterComputation`, but always fails, to exercise the failure-counter path.
1337    struct FailingComputation;
1338
1339    #[async_trait::async_trait]
1340    impl DerivedComputation for FailingComputation {
1341        type Output = ();
1342        const ID: ComputationId = "failing";
1343
1344        async fn compute(
1345            &self,
1346            _market: &MarketData,
1347            _store: &SharedDerivedDataRef,
1348            _changed: &ChangedComponents,
1349        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1350            Err(ComputationError::InvalidConfiguration("always fails".to_string()))
1351        }
1352    }
1353
1354    /// Finds the debug value recorded for `name` carrying every label in `labels`.
1355    fn find_metric<'a>(
1356        recorded: &'a [(
1357            metrics_util::CompositeKey,
1358            Option<metrics::Unit>,
1359            Option<metrics::SharedString>,
1360            metrics_util::debugging::DebugValue,
1361        )],
1362        name: &str,
1363        labels: &[(&str, &str)],
1364    ) -> &'a metrics_util::debugging::DebugValue {
1365        recorded
1366            .iter()
1367            .find(|(key, _, _, _)| {
1368                key.key().name() == name &&
1369                    labels
1370                        .iter()
1371                        .all(|(label_key, label_value)| {
1372                            key.key()
1373                                .labels()
1374                                .any(|l| l.key() == *label_key && l.value() == *label_value)
1375                        })
1376            })
1377            .map(|(_, _, _, value)| value)
1378            .unwrap_or_else(|| panic!("missing {name}{labels:?}, got {recorded:?}"))
1379    }
1380
1381    #[test]
1382    fn compute_all_records_derived_metrics() {
1383        use metrics_util::debugging::DebugValue;
1384
1385        let recorder = metrics_util::debugging::DebuggingRecorder::new();
1386        let snapshotter = recorder.snapshotter();
1387        let rt = tokio::runtime::Builder::new_current_thread()
1388            .enable_all()
1389            .build()
1390            .expect("runtime builds");
1391
1392        metrics::with_local_recorder(&recorder, || {
1393            rt.block_on(async {
1394                let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1395                manager
1396                    .register(CounterComputation)
1397                    .unwrap();
1398                manager
1399                    .compute_all(&ChangedComponents {
1400                        is_full_recompute: true,
1401                        ..Default::default()
1402                    })
1403                    .await;
1404            })
1405        });
1406
1407        let recorded = snapshotter.snapshot().into_vec();
1408        let recorded_names: Vec<(String, Vec<String>)> = recorded
1409            .iter()
1410            .map(|(key, _, _, _)| {
1411                (
1412                    key.key().name().to_string(),
1413                    key.key()
1414                        .labels()
1415                        .map(|l| format!("{}={}", l.key(), l.value()))
1416                        .collect(),
1417                )
1418            })
1419            .collect();
1420        for expected in
1421            ["derived_computation_duration_seconds", "derived_last_success_timestamp_seconds"]
1422        {
1423            assert!(
1424                recorded_names
1425                    .iter()
1426                    .any(|(name, labels)| name == expected &&
1427                        labels.contains(&"computation=counter".to_string())),
1428                "missing {expected}{{computation=counter}}, got {recorded_names:?}"
1429            );
1430        }
1431
1432        match find_metric(
1433            &recorded,
1434            "derived_last_success_timestamp_seconds",
1435            &[("computation", "counter")],
1436        ) {
1437            DebugValue::Gauge(value) => {
1438                assert!(value.0 > 1.7e9, "gauge value {} not a sane unix timestamp", value.0);
1439            }
1440            other => panic!("derived_last_success_timestamp_seconds is not a gauge: {other:?}"),
1441        }
1442
1443        match find_metric(
1444            &recorded,
1445            "derived_computation_duration_seconds",
1446            &[("computation", "counter")],
1447        ) {
1448            DebugValue::Histogram(samples) => {
1449                assert!(!samples.is_empty(), "expected at least one recorded duration sample");
1450            }
1451            other => panic!("derived_computation_duration_seconds is not a histogram: {other:?}"),
1452        }
1453    }
1454
1455    #[test]
1456    fn compute_all_records_failure_metric() {
1457        use metrics_util::debugging::DebugValue;
1458
1459        let recorder = metrics_util::debugging::DebuggingRecorder::new();
1460        let snapshotter = recorder.snapshotter();
1461        let rt = tokio::runtime::Builder::new_current_thread()
1462            .enable_all()
1463            .build()
1464            .expect("runtime builds");
1465
1466        metrics::with_local_recorder(&recorder, || {
1467            rt.block_on(async {
1468                let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1469                manager
1470                    .register(FailingComputation)
1471                    .unwrap();
1472                manager
1473                    .compute_all(&ChangedComponents {
1474                        is_full_recompute: true,
1475                        ..Default::default()
1476                    })
1477                    .await;
1478            })
1479        });
1480
1481        let recorded = snapshotter.snapshot().into_vec();
1482        match find_metric(
1483            &recorded,
1484            "derived_computation_failures_total",
1485            &[("computation", "failing"), ("reason", "error")],
1486        ) {
1487            DebugValue::Counter(value) => {
1488                assert!(*value >= 1, "expected failure counter >= 1, got {value}");
1489            }
1490            other => panic!("derived_computation_failures_total is not a counter: {other:?}"),
1491        }
1492    }
1493}