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