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, component depths)
6//! - Updates DerivedDataStore (exclusive write access)
7//! - Provides read access to workers via shared store reference
8
9use 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/// 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: FxHashMap<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    /// Returns true if this update changes the graph topology (adds or removes components).
42    pub fn is_topology_change(&self) -> bool {
43        !self.added.is_empty() || !self.removed.is_empty()
44    }
45
46    /// Returns a HashSet of all changed component IDs.
47    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
56/// Coalesces a drained batch of [`MarketEvent`]s into a single incremental
57/// [`ChangedComponents`], applying net semantics: a component that is added
58/// then removed within the batch nets to removed; an add supersedes a prior
59/// update; a remove supersedes a prior add/update.
60///
61/// Returns `None` when the batch carries no net changes. The result always has
62/// `is_full_recompute: false` — this is the bounded lag-recovery path, never a
63/// whole-topology recompute.
64fn 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
119/// Thread-safe handle to shared derived data store.
120pub type SharedDerivedDataRef = Arc<RwLock<DerivedData>>;
121
122/// Configuration for the default computation set built by [`ComputationManager::new`].
123#[derive(Debug, Clone)]
124pub struct ComputationManagerConfig {
125    /// Gas token address (e.g., WETH) for token price computation.
126    gas_token: Address,
127    /// Max hop count for token gas price computation.
128    max_hop: usize,
129    /// Slippage threshold for component depth computation (0.0 < threshold < 1.0).
130    depth_slippage_threshold: f64,
131}
132
133impl ComputationManagerConfig {
134    /// Creates a new configuration with the given gas token.
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Sets the slippage threshold for component depth computation.
140    pub fn with_depth_slippage_threshold(mut self, threshold: f64) -> Self {
141        self.depth_slippage_threshold = threshold;
142        self
143    }
144
145    /// Sets the max hop count for token gas price computation.
146    pub fn with_max_hop(mut self, hop_count: usize) -> Self {
147        self.max_hop = hop_count;
148        self
149    }
150
151    /// Sets the gas token address.
152    pub fn with_gas_token(mut self, gas_token: Address) -> Self {
153        self.gas_token = gas_token;
154        self
155    }
156
157    /// Returns the gas token address.
158    pub fn gas_token(&self) -> &Address {
159        &self.gas_token
160    }
161
162    /// Returns the max hop count.
163    pub fn max_hop(&self) -> usize {
164        self.max_hop
165    }
166
167    /// Returns the depth slippage threshold.
168    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
179/// Manages derived data computations triggered by market events.
180pub struct ComputationManager {
181    /// Reference to shared market data (read access).
182    market_data: MarketData,
183    /// Shared derived data store (write access).
184    store: SharedDerivedDataRef,
185    /// Registered computations, driven in dependency-stage order each block.
186    computations: Vec<Box<dyn ErasedComputation>>,
187    /// Event broadcaster for derived data updates.
188    event_tx: broadcast::Sender<DerivedDataEvent>,
189}
190
191/// A dependency-ordered execution plan for the registered computations.
192struct ComputationSchedule {
193    /// Indices into `ComputationManager::computations`, grouped into stages run in order.
194    stages: Vec<Vec<usize>>,
195    /// Indices that could not be ordered because of a requirement cycle.
196    unscheduled: Vec<usize>,
197}
198
199impl ComputationManager {
200    /// Creates a new ComputationManager.
201    ///
202    /// Returns the manager and a receiver for derived data events.
203    /// Workers can subscribe to the event sender via `event_sender()` to track
204    /// computation readiness.
205    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    /// Creates a manager with no computations registered.
221    ///
222    /// [`new`](Self::new) builds on this to assemble the default computation set, and
223    /// tests drive a custom set through [`register`](Self::register).
224    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    /// Registers a computation to be driven each block.
238    ///
239    /// Registration order is preserved within a dependency stage; cross-stage order is
240    /// derived from each computation's
241    /// [`requirements`](crate::derived::computation::DerivedComputation::requirements).
242    ///
243    /// # Errors
244    ///
245    /// Returns [`ComputationError::DuplicateComputationId`] if a computation with the same
246    /// [`ID`](DerivedComputation::ID) is already registered.
247    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    /// Returns a reference to the shared derived data store.
264    pub fn store(&self) -> SharedDerivedDataRef {
265        Arc::clone(&self.store)
266    }
267
268    /// Returns the event sender for workers to subscribe.
269    pub fn event_sender(&self) -> broadcast::Sender<DerivedDataEvent> {
270        self.event_tx.clone()
271    }
272
273    /// Runs the main loop until shutdown or channel close.
274    ///
275    /// **Note:** Consumes `self`. Call [`store()`](Self::store) before `run()` to retain access.
276    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    /// Runs all registered computations for the current block and updates the store.
321    ///
322    /// Computations run in dependency stages derived from their
323    /// [`requirements`](crate::derived::computation::DerivedComputation::requirements):
324    /// a stage runs concurrently and is written before the next stage starts, and a
325    /// computation whose requirement did not succeed this block is skipped and reported
326    /// as failed. Broadcasts a `DerivedDataEvent` per computation.
327    async fn compute_all(&self, changed: &ChangedComponents) {
328        let total_start = Instant::now();
329
330        // Get block info for tracking
331        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        // Broadcast new block event
343        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            // Split the stage into runnable computations and ones whose requirements did
370            // not hold this block; the latter are skipped and reported as failed.
371            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            // Run this stage's computations concurrently; they read the store as needed.
406            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            // Persist and report in stage order, taking the write lock once for the stage.
416            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    ////// Recovers from a broadcast lag without a full-topology recompute.
476    ///
477    /// Drains the events still buffered in `event_rx` (returning the receiver to the live tail so
478    /// it cannot immediately re-lag), coalesces them into one incremental `ChangedComponents`,
479    /// and recomputes just that union.
480    ///
481    /// Components lost in the dropped window are not recomputed. Added and updated ones
482    /// self-correct on their next `MarketUpdated`; removed ones never reappear, leaving stale
483    /// `spot_prices`/`pool_depths` entries for the life of the process. Routing is unaffected:
484    /// derived data is only read per graph edge, and a removed component has no edges.
485    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
504/// Seconds since the Unix epoch, for freshness gauges consumed as `time() - <gauge>`.
505fn 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
512/// Computes the dependency-ordered execution plan for `nodes` (id paired with its
513/// requirements).
514///
515/// Each node lands in a later stage than the `nodes` it requires; input order is
516/// preserved within a stage. Nodes caught in a requirement cycle cannot be ordered and
517/// are returned as `unscheduled`. A requirement naming an id absent from `nodes` does
518/// not affect ordering (it is left to the runtime readiness check).
519fn 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    /// Drains all currently-pending events from a broadcast receiver into a Vec.
630    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    // --- coalesce_market_events: net semantics over a drained batch (pure) ---------
644
645    #[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        // eth_usdc was added, so it stays in `added` (not double-counted in `updated`)
670        assert!(c.added.contains_key("eth_usdc"));
671        assert!(!c
672            .updated
673            .contains(&"eth_usdc".to_string()));
674        // dai_usdc only ever appeared as updated
675        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            &eth,
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        // Capacity-2 input channel; send 5 without reading to force Lagged on recv.
721        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        // Recovery recomputed the coalesced change incrementally...
742        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        // ...and the receiver is back at the live tail (buffer drained).
748        assert!(matches!(rx.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
749    }
750
751    // --- build_schedule: dependency staging (pure) --------------------------------
752
753    #[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        // a <- b <- c
780        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        // a <- {b, c} <- d; mirrors fynd's spot -> {token, component} fan-out.
792        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        // b registered before c, so it comes first in the shared stage.
810        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        // "ghost" is not registered, so "a" is treated as a root.
825        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        // "root" schedules normally; "x" and "y" form a cycle and are unscheduled.
843        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            &eth,
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    // --- registry seam: custom computations driven through the manager -------------
940
941    #[derive(Clone, Debug, PartialEq)]
942    struct CounterOutput(u32);
943
944    /// A minimal computation that ignores market data and uses the default `persist`
945    /// (the path a downstream computation takes: store into the generic slot).
946    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    /// Builds a market carrying a `last_updated` block so `compute_all` runs.
964    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            &eth,
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    // --- exact event sequences (characterization) ---------------------------------
1021
1022    /// Reduces an event stream to `(kind, computation_id)` pairs for exact comparison.
1023    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    /// Subscribes, runs one full-recompute pass, and returns the events it emitted.
1039    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    /// Defines a market-independent test computation with a fixed id, requirements, result.
1048    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    /// A producer that succeeds while its flag is set and fails once it is cleared, so a
1138    /// later block can exercise the stale-dependency path (producer failed this block, but
1139    /// a prior-block value is still in the store).
1140    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        // root runs in stage 0; dep then dep2 share stage 1 in registration order.
1189        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        // boom fails in stage 0; its dependent is skipped and reported failed.
1209        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        // "ghost" is never registered, so its fresh dependent never runs.
1225        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        // A partial success (Ok with failed_items) still counts as succeeded, so the fresh
1239        // dependent runs -- the compatibility invariant with the old hardcoded flow.
1240        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        // boom fails; dep_boom is skipped; third (needs dep_boom) is skipped transitively.
1256        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        // Block 1: producer succeeds and its value is stored.
1279        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        // Block 2: producer fails, but its prior-block value remains, so the stale
1286        // dependent still runs.
1287        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        // Real fynd flow: a full recompute with no sim state makes spot prices fail
1298        // outright, cascading ComputationFailed to every dependent in registration order.
1299        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    /// Creates a market with a component in topology but WITHOUT simulation state.
1319    ///
1320    /// Used to trigger `TotalFailure` in spot_price computation (full recompute with
1321    /// all components missing sim_state → succeeded == 0 → failure).
1322    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        // Note: no update_states() — simulation state is intentionally absent
1331        market.upsert_tokens([eth, usdc]);
1332        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1333    }
1334
1335    /// Creates a market with two components: one with sim state (component succeeds) and one
1336    /// without (component fails). Used to trigger partial spot price failure.
1337    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        // Only component1 has simulation state; component2 intentionally has none
1349        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    /// Creates a market WITH sim_state but WITHOUT gas_price.
1356    ///
1357    /// Spot price computation succeeds (MockProtocolSim works), but token_price
1358    /// computation fails with `MissingDependency("gas_price")`.
1359    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        // Note: no update_gas_price() — gas price is intentionally absent
1366        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        // Full recompute with components that have no sim_state → TotalFailure
1383        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        // handle_event with added components — spot_price succeeds, token_price fails
1406        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        // market_with_mixed_sim_states has component1 (with sim state) and component2 (without)
1453        // → spot price computation partially succeeds → ComputationComplete with failed_items
1454        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        // Should broadcast ComputationComplete (not ComputationFailed) because component1 succeeds
1464        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        // The ComputationComplete event should carry the failed item for component2
1480        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        // The store should persist the failure reason for the failed component.
1491        // market_with_mixed_sim_states uses token(1, "ETH") and token(3, "DAI") for component2.
1492        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    // --- metrics ---------------------------------------------------------------------
1510
1511    /// Mirrors `CounterComputation`, but always fails, to exercise the failure-counter path.
1512    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    /// Finds the debug value recorded for `name` carrying every label in `labels`.
1530    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}