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    collections::{HashMap, HashSet},
11    sync::Arc,
12    time::{Instant, SystemTime, UNIX_EPOCH},
13};
14
15use async_trait::async_trait;
16use futures::future::join_all;
17use metrics::{counter, gauge, histogram};
18use tokio::sync::{broadcast, RwLock};
19use tracing::{error, info, trace, warn};
20use tycho_simulation::tycho_common::models::Address;
21
22use crate::types::ComponentId;
23
24/// Information about which components changed in a market update.
25///
26/// Used to enable incremental computation - only recomputing derived data
27/// for components that actually changed.
28#[derive(Debug, Clone, Default)]
29pub struct ChangedComponents {
30    /// Newly added components with their token addresses.
31    pub added: HashMap<ComponentId, Vec<Address>>,
32    /// Components that were removed.
33    pub removed: Vec<ComponentId>,
34    /// Components whose state was updated (but not added/removed).
35    pub updated: Vec<ComponentId>,
36    /// If true, this represents a full recompute (startup/lag recovery).
37    pub is_full_recompute: bool,
38}
39
40impl ChangedComponents {
41    /// 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) -> HashSet<ComponentId> {
48        let mut all = HashSet::new();
49        all.extend(self.added.keys().cloned());
50        all.extend(self.removed.iter().cloned());
51        all.extend(self.updated.iter().cloned());
52        all
53    }
54}
55
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: HashMap<ComponentId, Vec<Address>> = HashMap::new();
66    let mut removed: HashSet<ComponentId> = HashSet::new();
67    let mut updated: HashSet<ComponentId> = HashSet::new();
68
69    for event in events {
70        match event {
71            MarketEvent::MarketUpdated {
72                added_components,
73                removed_components,
74                updated_components,
75            } => {
76                for (id, tokens) in added_components {
77                    removed.remove(id);
78                    updated.remove(id);
79                    added.insert(id.clone(), tokens.clone());
80                }
81                for id in removed_components {
82                    added.remove(id);
83                    updated.remove(id);
84                    removed.insert(id.clone());
85                }
86                for id in updated_components {
87                    if !added.contains_key(id) && !removed.contains(id) {
88                        updated.insert(id.clone());
89                    }
90                }
91            }
92        }
93    }
94
95    if added.is_empty() && removed.is_empty() && updated.is_empty() {
96        return None;
97    }
98    Some(ChangedComponents {
99        added,
100        removed: removed.into_iter().collect(),
101        updated: updated.into_iter().collect(),
102        is_full_recompute: false,
103    })
104}
105
106use super::{
107    computation::{ComputationId, ComputationRequirements, DerivedComputation},
108    computations::{ComponentDepthComputation, SpotPriceComputation, TokenGasPriceComputation},
109    error::ComputationError,
110    events::DerivedDataEvent,
111    registry::ErasedComputation,
112    store::DerivedData,
113};
114use crate::feed::{
115    events::{EventError, MarketEvent, MarketEventHandler},
116    market_data::MarketData,
117};
118
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: HashSet<ComputationId> = HashSet::new();
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::{
615        collections::HashMap,
616        sync::{
617            atomic::{AtomicBool, Ordering},
618            Arc,
619        },
620    };
621
622    use tokio::sync::broadcast;
623
624    use super::*;
625    use crate::{
626        algorithm::test_utils::{component, setup_market_weighted, token, MockProtocolSim},
627        derived::computation::{ComputationOutput, FailedItem, FailedItemError},
628        feed::market_data::{MarketData, MarketState},
629        types::BlockInfo,
630    };
631
632    /// Drains all currently-pending events from a broadcast receiver into a Vec.
633    fn drain_events(rx: &mut broadcast::Receiver<DerivedDataEvent>) -> Vec<DerivedDataEvent> {
634        let mut events = vec![];
635        loop {
636            match rx.try_recv() {
637                Ok(e) => events.push(e),
638                Err(broadcast::error::TryRecvError::Empty) => break,
639                Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
640                Err(broadcast::error::TryRecvError::Closed) => break,
641            }
642        }
643        events
644    }
645
646    // --- coalesce_market_events: net semantics over a drained batch (pure) ---------
647
648    #[test]
649    fn coalesce_empty_batch_returns_none() {
650        assert!(coalesce_market_events(&[]).is_none());
651    }
652
653    #[test]
654    fn coalesce_unions_added_and_updated_across_events() {
655        let eth = token(1, "ETH");
656        let usdc = token(2, "USDC");
657        let e1 = MarketEvent::MarketUpdated {
658            added_components: HashMap::from([(
659                "eth_usdc".to_string(),
660                vec![eth.address.clone(), usdc.address.clone()],
661            )]),
662            removed_components: vec![],
663            updated_components: vec![],
664        };
665        let e2 = MarketEvent::MarketUpdated {
666            added_components: HashMap::new(),
667            removed_components: vec![],
668            updated_components: vec!["eth_usdc".to_string(), "dai_usdc".to_string()],
669        };
670        let c = coalesce_market_events(&[e1, e2]).expect("net changes present");
671        assert!(!c.is_full_recompute);
672        // eth_usdc was added, so it stays in `added` (not double-counted in `updated`)
673        assert!(c.added.contains_key("eth_usdc"));
674        assert!(!c
675            .updated
676            .contains(&"eth_usdc".to_string()));
677        // dai_usdc only ever appeared as updated
678        assert!(c
679            .updated
680            .contains(&"dai_usdc".to_string()));
681    }
682
683    #[test]
684    fn coalesce_add_then_remove_nets_to_removed() {
685        let eth = token(1, "ETH");
686        let usdc = token(2, "USDC");
687        let add = MarketEvent::MarketUpdated {
688            added_components: HashMap::from([(
689                "eth_usdc".to_string(),
690                vec![eth.address.clone(), usdc.address.clone()],
691            )]),
692            removed_components: vec![],
693            updated_components: vec![],
694        };
695        let remove = MarketEvent::MarketUpdated {
696            added_components: HashMap::new(),
697            removed_components: vec!["eth_usdc".to_string()],
698            updated_components: vec![],
699        };
700        let c = coalesce_market_events(&[add, remove]).expect("net removal present");
701        assert!(!c.added.contains_key("eth_usdc"));
702        assert!(c
703            .removed
704            .contains(&"eth_usdc".to_string()));
705        assert!(!c
706            .updated
707            .contains(&"eth_usdc".to_string()));
708    }
709
710    #[tokio::test]
711    async fn lag_recovery_recomputes_incrementally_and_drains_to_tail() {
712        let eth = token(1, "ETH");
713        let usdc = token(2, "USDC");
714        let (market, _) = setup_market_weighted(vec![(
715            "eth_usdc",
716            &eth,
717            &usdc,
718            MockProtocolSim::new(2000.0).with_gas(0),
719        )]);
720        let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
721        let (manager, _out_rx) = ComputationManager::new(config, market).unwrap();
722
723        // Capacity-2 input channel; send 5 without reading to force Lagged on recv.
724        let (tx, mut rx) = broadcast::channel::<MarketEvent>(2);
725        for _ in 0..5 {
726            tx.send(MarketEvent::MarketUpdated {
727                added_components: HashMap::from([(
728                    "eth_usdc".to_string(),
729                    vec![eth.address.clone(), usdc.address.clone()],
730                )]),
731                removed_components: vec![],
732                updated_components: vec![],
733            })
734            .unwrap();
735        }
736        let err = rx
737            .recv()
738            .await
739            .expect_err("receiver must have lagged");
740        assert!(matches!(err, broadcast::error::RecvError::Lagged(_)));
741
742        manager.recover_from_lag(&mut rx).await;
743
744        // Recovery recomputed the coalesced change incrementally...
745        let store = manager.store();
746        let guard = store.read().await;
747        assert!(guard.spot_prices().is_some());
748        assert!(guard.token_prices().is_some());
749        drop(guard);
750        // ...and the receiver is back at the live tail (buffer drained).
751        assert!(matches!(rx.try_recv(), Err(broadcast::error::TryRecvError::Empty)));
752    }
753
754    // --- build_schedule: dependency staging (pure) --------------------------------
755
756    #[test]
757    fn schedule_empty_has_no_stages() {
758        let schedule = build_schedule(&[]);
759        assert!(schedule.stages.is_empty());
760        assert!(schedule.unscheduled.is_empty());
761    }
762
763    #[test]
764    fn schedule_single_root_is_one_stage() {
765        let schedule = build_schedule(&[("a", ComputationRequirements::none())]);
766        assert_eq!(schedule.stages, vec![vec![0]]);
767        assert!(schedule.unscheduled.is_empty());
768    }
769
770    #[test]
771    fn schedule_independent_roots_share_one_stage() {
772        let schedule = build_schedule(&[
773            ("a", ComputationRequirements::none()),
774            ("b", ComputationRequirements::none()),
775        ]);
776        assert_eq!(schedule.stages, vec![vec![0, 1]]);
777        assert!(schedule.unscheduled.is_empty());
778    }
779
780    #[test]
781    fn schedule_chain_orders_into_successive_stages() {
782        // a <- b <- c
783        let schedule = build_schedule(&[
784            ("a", ComputationRequirements::none()),
785            ("b", ComputationRequirements::fresh(["a"])),
786            ("c", ComputationRequirements::fresh(["b"])),
787        ]);
788        assert_eq!(schedule.stages, vec![vec![0], vec![1], vec![2]]);
789        assert!(schedule.unscheduled.is_empty());
790    }
791
792    #[test]
793    fn schedule_diamond_places_join_after_both_parents() {
794        // a <- {b, c} <- d; mirrors fynd's spot -> {token, component} fan-out.
795        let schedule = build_schedule(&[
796            ("a", ComputationRequirements::none()),
797            ("b", ComputationRequirements::fresh(["a"])),
798            ("c", ComputationRequirements::fresh(["a"])),
799            ("d", ComputationRequirements::fresh(["b", "c"])),
800        ]);
801        assert_eq!(schedule.stages, vec![vec![0], vec![1, 2], vec![3]]);
802        assert!(schedule.unscheduled.is_empty());
803    }
804
805    #[test]
806    fn schedule_preserves_input_order_within_a_stage() {
807        let schedule = build_schedule(&[
808            ("a", ComputationRequirements::none()),
809            ("b", ComputationRequirements::fresh(["a"])),
810            ("c", ComputationRequirements::fresh(["a"])),
811        ]);
812        // b registered before c, so it comes first in the shared stage.
813        assert_eq!(schedule.stages, vec![vec![0], vec![1, 2]]);
814    }
815
816    #[test]
817    fn schedule_stale_requirement_orders_after_its_producer() {
818        let schedule = build_schedule(&[
819            ("a", ComputationRequirements::none()),
820            ("b", ComputationRequirements::stale(["a"])),
821        ]);
822        assert_eq!(schedule.stages, vec![vec![0], vec![1]]);
823    }
824
825    #[test]
826    fn schedule_requirement_on_unregistered_id_does_not_affect_ordering() {
827        // "ghost" is not registered, so "a" is treated as a root.
828        let schedule = build_schedule(&[("a", ComputationRequirements::fresh(["ghost"]))]);
829        assert_eq!(schedule.stages, vec![vec![0]]);
830        assert!(schedule.unscheduled.is_empty());
831    }
832
833    #[test]
834    fn schedule_two_node_cycle_is_unscheduled() {
835        let schedule = build_schedule(&[
836            ("a", ComputationRequirements::fresh(["b"])),
837            ("b", ComputationRequirements::fresh(["a"])),
838        ]);
839        assert!(schedule.stages.is_empty());
840        assert_eq!(schedule.unscheduled, vec![0, 1]);
841    }
842
843    #[test]
844    fn schedule_isolates_cycle_from_schedulable_nodes() {
845        // "root" schedules normally; "x" and "y" form a cycle and are unscheduled.
846        let schedule = build_schedule(&[
847            ("root", ComputationRequirements::none()),
848            ("x", ComputationRequirements::fresh(["y"])),
849            ("y", ComputationRequirements::fresh(["x"])),
850        ]);
851        assert_eq!(schedule.stages, vec![vec![0]]);
852        assert_eq!(schedule.unscheduled, vec![1, 2]);
853    }
854
855    #[test]
856    fn invalid_slippage_threshold_returns_error() {
857        let (market, _) = setup_market_weighted(vec![]);
858        let config = ComputationManagerConfig::new().with_depth_slippage_threshold(1.5);
859
860        let result = ComputationManager::new(config, market);
861        assert!(matches!(result, Err(ComputationError::InvalidConfiguration(_))));
862    }
863
864    #[tokio::test]
865    async fn handle_event_runs_computations_on_market_update() {
866        let eth = token(1, "ETH");
867        let usdc = token(2, "USDC");
868
869        let (market, _) = setup_market_weighted(vec![(
870            "eth_usdc",
871            &eth,
872            &usdc,
873            MockProtocolSim::new(2000.0).with_gas(0),
874        )]);
875
876        let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
877        let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
878
879        let event = MarketEvent::MarketUpdated {
880            added_components: HashMap::from([(
881                "eth_usdc".to_string(),
882                vec![eth.address.clone(), usdc.address.clone()],
883            )]),
884            removed_components: vec![],
885            updated_components: vec![],
886        };
887
888        manager
889            .handle_event(&event)
890            .await
891            .unwrap();
892
893        let store = manager.store();
894        let guard = store.read().await;
895        assert!(guard.token_prices().is_some());
896        assert!(guard.spot_prices().is_some());
897    }
898
899    #[tokio::test]
900    async fn handle_event_skips_empty_update() {
901        let (market, _) = setup_market_weighted(vec![]);
902        let config = ComputationManagerConfig::new();
903        let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
904
905        let event = MarketEvent::MarketUpdated {
906            added_components: HashMap::new(),
907            removed_components: vec![],
908            updated_components: vec![],
909        };
910
911        manager
912            .handle_event(&event)
913            .await
914            .unwrap();
915
916        let store = manager.store();
917        let guard = store.read().await;
918        assert!(guard.token_prices().is_none());
919    }
920
921    #[tokio::test]
922    async fn run_shuts_down_on_signal() {
923        let (market, _) = setup_market_weighted(vec![]);
924        let config = ComputationManagerConfig::new();
925        let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
926
927        let (_event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
928        let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
929
930        let handle = tokio::spawn(async move {
931            manager.run(event_rx, shutdown_rx).await;
932        });
933
934        shutdown_tx.send(()).unwrap();
935
936        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
937            .await
938            .expect("manager should shutdown")
939            .expect("task should complete successfully");
940    }
941
942    // --- registry seam: custom computations driven through the manager -------------
943
944    #[derive(Clone, Debug, PartialEq)]
945    struct CounterOutput(u32);
946
947    /// A minimal computation that ignores market data and uses the default `persist`
948    /// (the path a downstream computation takes: store into the generic slot).
949    struct CounterComputation;
950
951    #[async_trait::async_trait]
952    impl DerivedComputation for CounterComputation {
953        type Output = CounterOutput;
954        const ID: ComputationId = "counter";
955
956        async fn compute(
957            &self,
958            _market: &MarketData,
959            _store: &SharedDerivedDataRef,
960            _changed: &ChangedComponents,
961        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
962            Ok(ComputationOutput::success(CounterOutput(7)))
963        }
964    }
965
966    /// Builds a market carrying a `last_updated` block so `compute_all` runs.
967    fn market_with_block() -> MarketData {
968        let eth = token(1, "ETH");
969        let usdc = token(2, "USDC");
970        let (market, _) = setup_market_weighted(vec![(
971            "eth_usdc",
972            &eth,
973            &usdc,
974            MockProtocolSim::new(2000.0).with_gas(0),
975        )]);
976        market
977    }
978
979    #[tokio::test]
980    async fn registered_custom_computation_runs_and_persists_via_default_slot() {
981        let (mut manager, mut event_rx) = ComputationManager::empty(market_with_block());
982        manager
983            .register(CounterComputation)
984            .unwrap();
985
986        manager
987            .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
988            .await;
989
990        let store = manager.store();
991        let guard = store.read().await;
992        assert_eq!(
993            guard.output::<CounterOutput>(CounterComputation::ID),
994            Some(&CounterOutput(7)),
995            "default persist should write the output into the generic slot"
996        );
997        assert!(guard
998            .output_block(CounterComputation::ID)
999            .is_some());
1000
1001        let events = drain_events(&mut event_rx);
1002        assert!(
1003            events.iter().any(|e| matches!(
1004                e,
1005                DerivedDataEvent::ComputationComplete { computation_id: "counter", .. }
1006            )),
1007            "expected ComputationComplete(counter), got: {events:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn registering_duplicate_id_is_rejected() {
1013        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1014        manager
1015            .register(CounterComputation)
1016            .unwrap();
1017
1018        let result = manager.register(CounterComputation);
1019
1020        assert!(matches!(result, Err(ComputationError::DuplicateComputationId("counter"))));
1021    }
1022
1023    // --- exact event sequences (characterization) ---------------------------------
1024
1025    /// Reduces an event stream to `(kind, computation_id)` pairs for exact comparison.
1026    fn event_summary(events: &[DerivedDataEvent]) -> Vec<(&'static str, &'static str)> {
1027        events
1028            .iter()
1029            .map(|event| match event {
1030                DerivedDataEvent::NewBlock { .. } => ("new_block", ""),
1031                DerivedDataEvent::ComputationComplete { computation_id, .. } => {
1032                    ("complete", *computation_id)
1033                }
1034                DerivedDataEvent::ComputationFailed { computation_id, .. } => {
1035                    ("failed", *computation_id)
1036                }
1037            })
1038            .collect()
1039    }
1040
1041    /// Subscribes, runs one full-recompute pass, and returns the events it emitted.
1042    async fn run_full_recompute(manager: &ComputationManager) -> Vec<DerivedDataEvent> {
1043        let mut event_rx = manager.event_sender().subscribe();
1044        manager
1045            .compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
1046            .await;
1047        drain_events(&mut event_rx)
1048    }
1049
1050    /// Defines a market-independent test computation with a fixed id, requirements, result.
1051    macro_rules! test_computation {
1052        ($name:ident, $id:literal, $reqs:expr, $result:expr) => {
1053            struct $name;
1054
1055            #[async_trait::async_trait]
1056            impl DerivedComputation for $name {
1057                type Output = ();
1058                const ID: ComputationId = $id;
1059
1060                fn requirements(&self) -> ComputationRequirements {
1061                    $reqs
1062                }
1063
1064                async fn compute(
1065                    &self,
1066                    _market: &MarketData,
1067                    _store: &SharedDerivedDataRef,
1068                    _changed: &ChangedComponents,
1069                ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1070                    $result
1071                }
1072            }
1073        };
1074    }
1075
1076    test_computation!(
1077        RootOk,
1078        "root",
1079        ComputationRequirements::none(),
1080        Ok(ComputationOutput::success(()))
1081    );
1082    test_computation!(
1083        DepOnRoot,
1084        "dep",
1085        ComputationRequirements::fresh(["root"]),
1086        Ok(ComputationOutput::success(()))
1087    );
1088    test_computation!(
1089        SecondDepOnRoot,
1090        "dep2",
1091        ComputationRequirements::fresh(["root"]),
1092        Ok(ComputationOutput::success(()))
1093    );
1094    test_computation!(
1095        RootErr,
1096        "boom",
1097        ComputationRequirements::none(),
1098        Err(ComputationError::InvalidConfiguration("boom".to_string()))
1099    );
1100    test_computation!(
1101        DepOnBoom,
1102        "dep_boom",
1103        ComputationRequirements::fresh(["boom"]),
1104        Ok(ComputationOutput::success(()))
1105    );
1106    test_computation!(
1107        ThirdOnBoom,
1108        "third",
1109        ComputationRequirements::fresh(["dep_boom"]),
1110        Ok(ComputationOutput::success(()))
1111    );
1112    test_computation!(
1113        StaleDepOnFlaky,
1114        "stale_dep",
1115        ComputationRequirements::stale(["flaky"]),
1116        Ok(ComputationOutput::success(()))
1117    );
1118    test_computation!(
1119        GhostDependent,
1120        "needs_ghost",
1121        ComputationRequirements::fresh(["ghost"]),
1122        Ok(ComputationOutput::success(()))
1123    );
1124    test_computation!(
1125        PartialProducer,
1126        "partial",
1127        ComputationRequirements::none(),
1128        Ok(ComputationOutput::with_failures(
1129            (),
1130            vec![FailedItem { key: "x".to_string(), error: FailedItemError::MissingSpotPrice }]
1131        ))
1132    );
1133    test_computation!(
1134        DepOnPartial,
1135        "dep_partial",
1136        ComputationRequirements::fresh(["partial"]),
1137        Ok(ComputationOutput::success(()))
1138    );
1139
1140    /// A producer that succeeds while its flag is set and fails once it is cleared, so a
1141    /// later block can exercise the stale-dependency path (producer failed this block, but
1142    /// a prior-block value is still in the store).
1143    struct FlakyProducer {
1144        succeed: Arc<AtomicBool>,
1145    }
1146
1147    #[async_trait::async_trait]
1148    impl DerivedComputation for FlakyProducer {
1149        type Output = ();
1150        const ID: ComputationId = "flaky";
1151
1152        async fn compute(
1153            &self,
1154            _market: &MarketData,
1155            _store: &SharedDerivedDataRef,
1156            _changed: &ChangedComponents,
1157        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1158            if self.succeed.load(Ordering::SeqCst) {
1159                Ok(ComputationOutput::success(()))
1160            } else {
1161                Err(ComputationError::InvalidConfiguration("flaky".to_string()))
1162            }
1163        }
1164    }
1165
1166    #[tokio::test]
1167    async fn events_follow_dependency_order_across_stages() {
1168        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1169        manager.register(RootOk).unwrap();
1170        manager.register(DepOnRoot).unwrap();
1171
1172        let events = run_full_recompute(&manager).await;
1173
1174        assert_eq!(
1175            event_summary(&events),
1176            vec![("new_block", ""), ("complete", "root"), ("complete", "dep")]
1177        );
1178    }
1179
1180    #[tokio::test]
1181    async fn events_preserve_registration_order_within_a_stage() {
1182        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1183        manager.register(RootOk).unwrap();
1184        manager.register(DepOnRoot).unwrap();
1185        manager
1186            .register(SecondDepOnRoot)
1187            .unwrap();
1188
1189        let events = run_full_recompute(&manager).await;
1190
1191        // root runs in stage 0; dep then dep2 share stage 1 in registration order.
1192        assert_eq!(
1193            event_summary(&events),
1194            vec![
1195                ("new_block", ""),
1196                ("complete", "root"),
1197                ("complete", "dep"),
1198                ("complete", "dep2"),
1199            ]
1200        );
1201    }
1202
1203    #[tokio::test]
1204    async fn failed_dependency_cascades_to_dependents() {
1205        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1206        manager.register(RootErr).unwrap();
1207        manager.register(DepOnBoom).unwrap();
1208
1209        let events = run_full_recompute(&manager).await;
1210
1211        // boom fails in stage 0; its dependent is skipped and reported failed.
1212        assert_eq!(
1213            event_summary(&events),
1214            vec![("new_block", ""), ("failed", "boom"), ("failed", "dep_boom")]
1215        );
1216    }
1217
1218    #[tokio::test]
1219    async fn computation_with_unregistered_requirement_is_skipped() {
1220        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1221        manager
1222            .register(GhostDependent)
1223            .unwrap();
1224
1225        let events = run_full_recompute(&manager).await;
1226
1227        // "ghost" is never registered, so its fresh dependent never runs.
1228        assert_eq!(event_summary(&events), vec![("new_block", ""), ("failed", "needs_ghost")]);
1229    }
1230
1231    #[tokio::test]
1232    async fn fresh_dependent_runs_when_producer_succeeds_partially() {
1233        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1234        manager
1235            .register(PartialProducer)
1236            .unwrap();
1237        manager.register(DepOnPartial).unwrap();
1238
1239        let events = run_full_recompute(&manager).await;
1240
1241        // A partial success (Ok with failed_items) still counts as succeeded, so the fresh
1242        // dependent runs -- the compatibility invariant with the old hardcoded flow.
1243        assert_eq!(
1244            event_summary(&events),
1245            vec![("new_block", ""), ("complete", "partial"), ("complete", "dep_partial"),]
1246        );
1247    }
1248
1249    #[tokio::test]
1250    async fn failure_cascade_propagates_through_three_levels() {
1251        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1252        manager.register(RootErr).unwrap();
1253        manager.register(DepOnBoom).unwrap();
1254        manager.register(ThirdOnBoom).unwrap();
1255
1256        let events = run_full_recompute(&manager).await;
1257
1258        // boom fails; dep_boom is skipped; third (needs dep_boom) is skipped transitively.
1259        assert_eq!(
1260            event_summary(&events),
1261            vec![
1262                ("new_block", ""),
1263                ("failed", "boom"),
1264                ("failed", "dep_boom"),
1265                ("failed", "third"),
1266            ]
1267        );
1268    }
1269
1270    #[tokio::test]
1271    async fn stale_dependency_runs_on_prior_value_after_producer_fails() {
1272        let succeed = Arc::new(AtomicBool::new(true));
1273        let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1274        manager
1275            .register(FlakyProducer { succeed: Arc::clone(&succeed) })
1276            .unwrap();
1277        manager
1278            .register(StaleDepOnFlaky)
1279            .unwrap();
1280
1281        // Block 1: producer succeeds and its value is stored.
1282        let first = run_full_recompute(&manager).await;
1283        assert_eq!(
1284            event_summary(&first),
1285            vec![("new_block", ""), ("complete", "flaky"), ("complete", "stale_dep")]
1286        );
1287
1288        // Block 2: producer fails, but its prior-block value remains, so the stale
1289        // dependent still runs.
1290        succeed.store(false, Ordering::SeqCst);
1291        let second = run_full_recompute(&manager).await;
1292        assert_eq!(
1293            event_summary(&second),
1294            vec![("new_block", ""), ("failed", "flaky"), ("complete", "stale_dep")]
1295        );
1296    }
1297
1298    #[tokio::test]
1299    async fn default_computations_cascade_failure_in_registration_order() {
1300        // Real fynd flow: a full recompute with no sim state makes spot prices fail
1301        // outright, cascading ComputationFailed to every dependent in registration order.
1302        let (manager, _event_rx) = ComputationManager::new(
1303            ComputationManagerConfig::new(),
1304            market_with_component_no_sim_state(),
1305        )
1306        .unwrap();
1307
1308        let events = run_full_recompute(&manager).await;
1309
1310        assert_eq!(
1311            event_summary(&events),
1312            vec![
1313                ("new_block", ""),
1314                ("failed", "spot_prices"),
1315                ("failed", "token_prices"),
1316                ("failed", "pool_depths"),
1317            ]
1318        );
1319    }
1320
1321    /// Creates a market with a component in topology but WITHOUT simulation state.
1322    ///
1323    /// Used to trigger `TotalFailure` in spot_price computation (full recompute with
1324    /// all components missing sim_state → succeeded == 0 → failure).
1325    fn market_with_component_no_sim_state() -> MarketData {
1326        let eth = token(1, "ETH");
1327        let usdc = token(2, "USDC");
1328        let component = component("component", &[eth.clone(), usdc.clone()]);
1329
1330        let mut market = MarketState::new();
1331        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1332        market.upsert_components(std::iter::once(component));
1333        // Note: no update_states() — simulation state is intentionally absent
1334        market.upsert_tokens([eth, usdc]);
1335        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1336    }
1337
1338    /// Creates a market with two components: one with sim state (component succeeds) and one
1339    /// without (component fails). Used to trigger partial spot price failure.
1340    fn market_with_mixed_sim_states() -> MarketData {
1341        let eth = token(1, "ETH");
1342        let usdc = token(2, "USDC");
1343        let dai = token(3, "DAI");
1344
1345        let component1 = component("eth_usdc", &[eth.clone(), usdc.clone()]);
1346        let component2 = component("eth_dai", &[eth.clone(), dai.clone()]);
1347
1348        let mut market = MarketState::new();
1349        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1350        market.upsert_components([component1, component2]);
1351        // Only component1 has simulation state; component2 intentionally has none
1352        market
1353            .update_states([("eth_usdc".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
1354        market.upsert_tokens([eth, usdc, dai]);
1355        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1356    }
1357
1358    /// Creates a market WITH sim_state but WITHOUT gas_price.
1359    ///
1360    /// Spot price computation succeeds (MockProtocolSim works), but token_price
1361    /// computation fails with `MissingDependency("gas_price")`.
1362    fn market_with_sim_state_no_gas_price() -> MarketData {
1363        let eth = token(1, "ETH");
1364        let usdc = token(2, "USDC");
1365        let component = component("component", &[eth.clone(), usdc.clone()]);
1366
1367        let mut market = MarketState::new();
1368        // Note: no update_gas_price() — gas price is intentionally absent
1369        market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
1370        market.upsert_components(std::iter::once(component));
1371        market.update_states([(
1372            "component".to_string(),
1373            Box::new(MockProtocolSim::new(2000.0)) as _,
1374        )]);
1375        market.upsert_tokens([eth, usdc]);
1376        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1377    }
1378
1379    #[tokio::test]
1380    async fn test_spot_price_failure_broadcasts_computation_failed() {
1381        let market = market_with_component_no_sim_state();
1382        let config = ComputationManagerConfig::new();
1383        let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1384
1385        // Full recompute with components that have no sim_state → TotalFailure
1386        let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1387        manager.compute_all(&changed).await;
1388
1389        let events = drain_events(&mut event_rx);
1390
1391        assert!(
1392            events.iter().any(|e| matches!(
1393                e,
1394                DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1395            )),
1396            "expected ComputationFailed(spot_prices) in events: {events:?}"
1397        );
1398    }
1399
1400    #[tokio::test]
1401    async fn test_token_price_failure_broadcasts_computation_failed() {
1402        let eth = token(1, "ETH");
1403        let usdc = token(2, "USDC");
1404        let market = market_with_sim_state_no_gas_price();
1405        let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
1406        let (mut manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1407
1408        // handle_event with added components — spot_price succeeds, token_price fails
1409        let event = MarketEvent::MarketUpdated {
1410            added_components: HashMap::from([(
1411                "component".to_string(),
1412                vec![eth.address.clone(), usdc.address.clone()],
1413            )]),
1414            removed_components: vec![],
1415            updated_components: vec![],
1416        };
1417        manager
1418            .handle_event(&event)
1419            .await
1420            .unwrap();
1421
1422        let events = drain_events(&mut event_rx);
1423        assert!(
1424            events.iter().any(|e| matches!(
1425                e,
1426                DerivedDataEvent::ComputationFailed { computation_id: "token_prices", .. }
1427            )),
1428            "expected ComputationFailed(token_prices) in events: {events:?}"
1429        );
1430    }
1431
1432    #[tokio::test]
1433    async fn run_shuts_down_on_channel_close() {
1434        let (market, _) = setup_market_weighted(vec![]);
1435        let config = ComputationManagerConfig::new();
1436        let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
1437
1438        let (event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
1439        let (_shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
1440
1441        let handle = tokio::spawn(async move {
1442            manager.run(event_rx, shutdown_rx).await;
1443        });
1444
1445        drop(event_tx);
1446
1447        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
1448            .await
1449            .expect("manager should shutdown on channel close")
1450            .expect("task should complete successfully");
1451    }
1452
1453    #[tokio::test]
1454    async fn partial_spot_price_failure_broadcasts_computation_complete() {
1455        // market_with_mixed_sim_states has component1 (with sim state) and component2 (without)
1456        // → spot price computation partially succeeds → ComputationComplete with failed_items
1457        let market = market_with_mixed_sim_states();
1458        let config = ComputationManagerConfig::new();
1459        let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
1460
1461        let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
1462        manager.compute_all(&changed).await;
1463
1464        let events = drain_events(&mut event_rx);
1465
1466        // Should broadcast ComputationComplete (not ComputationFailed) because component1 succeeds
1467        assert!(
1468            events.iter().any(|e| matches!(
1469                e,
1470                DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. }
1471            )),
1472            "expected ComputationComplete(spot_prices), got: {events:?}"
1473        );
1474        assert!(
1475            !events.iter().any(|e| matches!(
1476                e,
1477                DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
1478            )),
1479            "should not broadcast ComputationFailed for partial failure"
1480        );
1481
1482        // The ComputationComplete event should carry the failed item for component2
1483        let complete = events.iter().find(|e| {
1484            matches!(e, DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. })
1485        });
1486        if let Some(DerivedDataEvent::ComputationComplete { failed_items, .. }) = complete {
1487            assert!(
1488                !failed_items.is_empty(),
1489                "ComputationComplete should carry failed_items for component2"
1490            );
1491        }
1492
1493        // The store should persist the failure reason for the failed component.
1494        // market_with_mixed_sim_states uses token(1, "ETH") and token(3, "DAI") for component2.
1495        let eth = token(1, "ETH");
1496        let dai = token(3, "DAI");
1497        let store = manager.store();
1498        let guard = store.read().await;
1499        let key_eth_dai = ("eth_dai".to_string(), eth.address.clone(), dai.address.clone());
1500        let key_dai_eth = ("eth_dai".to_string(), dai.address.clone(), eth.address.clone());
1501        assert!(
1502            guard
1503                .spot_price_failure(&key_eth_dai)
1504                .is_some() ||
1505                guard
1506                    .spot_price_failure(&key_dai_eth)
1507                    .is_some(),
1508            "store should persist failure reason for eth_dai (missing sim state)"
1509        );
1510    }
1511
1512    // --- metrics ---------------------------------------------------------------------
1513
1514    /// Mirrors `CounterComputation`, but always fails, to exercise the failure-counter path.
1515    struct FailingComputation;
1516
1517    #[async_trait::async_trait]
1518    impl DerivedComputation for FailingComputation {
1519        type Output = ();
1520        const ID: ComputationId = "failing";
1521
1522        async fn compute(
1523            &self,
1524            _market: &MarketData,
1525            _store: &SharedDerivedDataRef,
1526            _changed: &ChangedComponents,
1527        ) -> Result<ComputationOutput<Self::Output>, ComputationError> {
1528            Err(ComputationError::InvalidConfiguration("always fails".to_string()))
1529        }
1530    }
1531
1532    /// Finds the debug value recorded for `name` carrying every label in `labels`.
1533    fn find_metric<'a>(
1534        recorded: &'a [(
1535            metrics_util::CompositeKey,
1536            Option<metrics::Unit>,
1537            Option<metrics::SharedString>,
1538            metrics_util::debugging::DebugValue,
1539        )],
1540        name: &str,
1541        labels: &[(&str, &str)],
1542    ) -> &'a metrics_util::debugging::DebugValue {
1543        recorded
1544            .iter()
1545            .find(|(key, _, _, _)| {
1546                key.key().name() == name &&
1547                    labels
1548                        .iter()
1549                        .all(|(label_key, label_value)| {
1550                            key.key()
1551                                .labels()
1552                                .any(|l| l.key() == *label_key && l.value() == *label_value)
1553                        })
1554            })
1555            .map(|(_, _, _, value)| value)
1556            .unwrap_or_else(|| panic!("missing {name}{labels:?}, got {recorded:?}"))
1557    }
1558
1559    #[test]
1560    fn compute_all_records_derived_metrics() {
1561        use metrics_util::debugging::DebugValue;
1562
1563        let recorder = metrics_util::debugging::DebuggingRecorder::new();
1564        let snapshotter = recorder.snapshotter();
1565        let rt = tokio::runtime::Builder::new_current_thread()
1566            .enable_all()
1567            .build()
1568            .expect("runtime builds");
1569
1570        metrics::with_local_recorder(&recorder, || {
1571            rt.block_on(async {
1572                let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1573                manager
1574                    .register(CounterComputation)
1575                    .unwrap();
1576                manager
1577                    .compute_all(&ChangedComponents {
1578                        is_full_recompute: true,
1579                        ..Default::default()
1580                    })
1581                    .await;
1582            })
1583        });
1584
1585        let recorded = snapshotter.snapshot().into_vec();
1586        let recorded_names: Vec<(String, Vec<String>)> = recorded
1587            .iter()
1588            .map(|(key, _, _, _)| {
1589                (
1590                    key.key().name().to_string(),
1591                    key.key()
1592                        .labels()
1593                        .map(|l| format!("{}={}", l.key(), l.value()))
1594                        .collect(),
1595                )
1596            })
1597            .collect();
1598        for expected in
1599            ["derived_computation_duration_seconds", "derived_last_success_timestamp_seconds"]
1600        {
1601            assert!(
1602                recorded_names
1603                    .iter()
1604                    .any(|(name, labels)| name == expected &&
1605                        labels.contains(&"computation=counter".to_string())),
1606                "missing {expected}{{computation=counter}}, got {recorded_names:?}"
1607            );
1608        }
1609
1610        match find_metric(
1611            &recorded,
1612            "derived_last_success_timestamp_seconds",
1613            &[("computation", "counter")],
1614        ) {
1615            DebugValue::Gauge(value) => {
1616                assert!(value.0 > 1.7e9, "gauge value {} not a sane unix timestamp", value.0);
1617            }
1618            other => panic!("derived_last_success_timestamp_seconds is not a gauge: {other:?}"),
1619        }
1620
1621        match find_metric(
1622            &recorded,
1623            "derived_computation_duration_seconds",
1624            &[("computation", "counter")],
1625        ) {
1626            DebugValue::Histogram(samples) => {
1627                assert!(!samples.is_empty(), "expected at least one recorded duration sample");
1628            }
1629            other => panic!("derived_computation_duration_seconds is not a histogram: {other:?}"),
1630        }
1631    }
1632
1633    #[test]
1634    fn compute_all_records_failure_metric() {
1635        use metrics_util::debugging::DebugValue;
1636
1637        let recorder = metrics_util::debugging::DebuggingRecorder::new();
1638        let snapshotter = recorder.snapshotter();
1639        let rt = tokio::runtime::Builder::new_current_thread()
1640            .enable_all()
1641            .build()
1642            .expect("runtime builds");
1643
1644        metrics::with_local_recorder(&recorder, || {
1645            rt.block_on(async {
1646                let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
1647                manager
1648                    .register(FailingComputation)
1649                    .unwrap();
1650                manager
1651                    .compute_all(&ChangedComponents {
1652                        is_full_recompute: true,
1653                        ..Default::default()
1654                    })
1655                    .await;
1656            })
1657        });
1658
1659        let recorded = snapshotter.snapshot().into_vec();
1660        match find_metric(
1661            &recorded,
1662            "derived_computation_failures_total",
1663            &[("computation", "failing"), ("reason", "error")],
1664        ) {
1665            DebugValue::Counter(value) => {
1666                assert!(*value >= 1, "expected failure counter >= 1, got {value}");
1667            }
1668            other => panic!("derived_computation_failures_total is not a counter: {other:?}"),
1669        }
1670    }
1671}