use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Instant,
};
use async_trait::async_trait;
use futures::future::join_all;
use tokio::sync::{broadcast, RwLock};
use tracing::{error, info, trace, warn};
use tycho_simulation::tycho_common::models::Address;
use crate::types::ComponentId;
#[derive(Debug, Clone, Default)]
pub struct ChangedComponents {
pub added: HashMap<ComponentId, Vec<Address>>,
pub removed: Vec<ComponentId>,
pub updated: Vec<ComponentId>,
pub is_full_recompute: bool,
}
impl ChangedComponents {
pub fn all(market: MarketDataView) -> Self {
Self {
added: market.component_topology().clone(),
removed: vec![],
updated: vec![],
is_full_recompute: true,
}
}
pub fn is_topology_change(&self) -> bool {
!self.added.is_empty() || !self.removed.is_empty()
}
pub fn all_changed_ids(&self) -> HashSet<ComponentId> {
let mut all = HashSet::new();
all.extend(self.added.keys().cloned());
all.extend(self.removed.iter().cloned());
all.extend(self.updated.iter().cloned());
all
}
}
use super::{
computation::{ComputationId, ComputationRequirements, DerivedComputation},
computations::{PoolDepthComputation, SpotPriceComputation, TokenGasPriceComputation},
error::ComputationError,
events::DerivedDataEvent,
registry::ErasedComputation,
store::DerivedData,
};
use crate::feed::{
events::{EventError, MarketEvent, MarketEventHandler},
market_data::{MarketData, MarketDataView},
};
pub type SharedDerivedDataRef = Arc<RwLock<DerivedData>>;
#[derive(Debug, Clone)]
pub struct ComputationManagerConfig {
gas_token: Address,
max_hop: usize,
depth_slippage_threshold: f64,
}
impl ComputationManagerConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_depth_slippage_threshold(mut self, threshold: f64) -> Self {
self.depth_slippage_threshold = threshold;
self
}
pub fn with_max_hop(mut self, hop_count: usize) -> Self {
self.max_hop = hop_count;
self
}
pub fn with_gas_token(mut self, gas_token: Address) -> Self {
self.gas_token = gas_token;
self
}
pub fn gas_token(&self) -> &Address {
&self.gas_token
}
pub fn max_hop(&self) -> usize {
self.max_hop
}
pub fn depth_slippage_threshold(&self) -> f64 {
self.depth_slippage_threshold
}
}
impl Default for ComputationManagerConfig {
fn default() -> Self {
Self { gas_token: Address::zero(20), max_hop: 2, depth_slippage_threshold: 0.01 }
}
}
pub struct ComputationManager {
market_data: MarketData,
store: SharedDerivedDataRef,
computations: Vec<Box<dyn ErasedComputation>>,
event_tx: broadcast::Sender<DerivedDataEvent>,
}
struct ComputationSchedule {
stages: Vec<Vec<usize>>,
unscheduled: Vec<usize>,
}
impl ComputationManager {
pub fn new(
config: ComputationManagerConfig,
market_data: MarketData,
) -> Result<(Self, broadcast::Receiver<DerivedDataEvent>), ComputationError> {
let (mut manager, event_rx) = Self::empty(market_data);
manager.register(SpotPriceComputation::new())?;
manager.register(
TokenGasPriceComputation::default()
.with_max_hops(config.max_hop)
.with_gas_token(config.gas_token),
)?;
manager.register(PoolDepthComputation::new(config.depth_slippage_threshold)?)?;
Ok((manager, event_rx))
}
pub(crate) fn empty(market_data: MarketData) -> (Self, broadcast::Receiver<DerivedDataEvent>) {
let (event_tx, event_rx) = broadcast::channel(64);
(
Self {
market_data,
store: DerivedData::new_shared(),
computations: Vec::new(),
event_tx,
},
event_rx,
)
}
pub(crate) fn register<C: DerivedComputation>(
&mut self,
computation: C,
) -> Result<(), ComputationError> {
if self
.computations
.iter()
.any(|existing| existing.id() == C::ID)
{
return Err(ComputationError::DuplicateComputationId(C::ID));
}
self.computations
.push(Box::new(computation));
Ok(())
}
pub fn store(&self) -> SharedDerivedDataRef {
Arc::clone(&self.store)
}
pub fn event_sender(&self) -> broadcast::Sender<DerivedDataEvent> {
self.event_tx.clone()
}
pub async fn run(
mut self,
mut event_rx: broadcast::Receiver<MarketEvent>,
mut shutdown_rx: broadcast::Receiver<()>,
) {
info!("computation manager started");
loop {
tokio::select! {
biased;
_ = shutdown_rx.recv() => {
info!("computation manager shutting down");
break;
}
event_result = event_rx.recv() => {
match event_result {
Ok(event) => {
if let Err(e) = self.handle_event(&event).await {
warn!(error = ?e, "failed to handle market event");
}
}
Err(broadcast::error::RecvError::Closed) => {
info!("event channel closed, computation manager shutting down");
break;
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!(
skipped,
"computation manager lagged, skipped {} events. Recomputing from current state.",
skipped
);
let market = self.market_data.read().await;
let changed = ChangedComponents::all(market);
self.compute_all(&changed).await;
}
}
}
}
}
}
async fn compute_all(&self, changed: &ChangedComponents) {
let total_start = Instant::now();
let Some(block) = self
.market_data
.read()
.await
.last_updated()
.map(|b| b.number())
else {
warn!("market data has no last updated block, skipping computations");
return;
};
let _ = self
.event_tx
.send(DerivedDataEvent::NewBlock { block });
let nodes: Vec<(ComputationId, ComputationRequirements)> = self
.computations
.iter()
.map(|computation| (computation.id(), computation.requirements()))
.collect();
let schedule = build_schedule(&nodes);
for &idx in &schedule.unscheduled {
let computation_id = nodes[idx].0;
error!(computation = computation_id, "computation skipped: requirement cycle");
let _ = self
.event_tx
.send(DerivedDataEvent::ComputationFailed { computation_id, block });
}
let mut succeeded: HashSet<ComputationId> = HashSet::new();
for stage in &schedule.stages {
let mut runnable = Vec::new();
{
let store = self.store.read().await;
for &idx in stage {
let reqs = &nodes[idx].1;
let fresh_ready = reqs
.fresh_requirements()
.iter()
.all(|id| succeeded.contains(id));
let stale_ready = reqs
.stale_requirements()
.iter()
.all(|id| succeeded.contains(id) || store.output_block(id).is_some());
if fresh_ready && stale_ready {
runnable.push(idx);
} else {
let _ = self
.event_tx
.send(DerivedDataEvent::ComputationFailed {
computation_id: nodes[idx].0,
block,
});
}
}
}
if runnable.is_empty() {
continue;
}
let results = join_all(runnable.iter().map(|&idx| async move {
let start = Instant::now();
let result = self.computations[idx]
.compute_erased(&self.market_data, &self.store, changed, block)
.await;
(idx, result, start.elapsed())
}))
.await;
let mut store = self.store.write().await;
for (idx, result, elapsed) in results {
let computation_id = nodes[idx].0;
match result {
Ok(write) => {
(write.persist)(&mut store);
info!(
computation = computation_id,
failed = write.failed_items.len(),
elapsed_ms = elapsed.as_millis(),
"computation complete"
);
let _ = self
.event_tx
.send(DerivedDataEvent::ComputationComplete {
computation_id,
block,
failed_items: write.failed_items,
});
succeeded.insert(computation_id);
}
Err(e) => {
warn!(
error = ?e,
computation = computation_id,
elapsed_ms = elapsed.as_millis(),
"computation failed"
);
let _ = self
.event_tx
.send(DerivedDataEvent::ComputationFailed { computation_id, block });
}
}
}
}
info!(
block,
total_ms = total_start.elapsed().as_millis(),
"all derived computations complete"
);
}
}
fn build_schedule(nodes: &[(ComputationId, ComputationRequirements)]) -> ComputationSchedule {
let ids: Vec<ComputationId> = nodes
.iter()
.map(|(id, _)| *id)
.collect();
let mut stage_of: Vec<Option<usize>> = vec![None; nodes.len()];
loop {
let mut progressed = false;
for (idx, (_, reqs)) in nodes.iter().enumerate() {
if stage_of[idx].is_some() {
continue;
}
let mut stage = 0;
let mut ready = true;
for dep in reqs
.fresh_requirements()
.iter()
.chain(reqs.stale_requirements().iter())
{
let Some(dep_idx) = ids.iter().position(|id| id == dep) else {
continue;
};
match stage_of[dep_idx] {
Some(dep_stage) => stage = stage.max(dep_stage + 1),
None => {
ready = false;
break;
}
}
}
if ready {
stage_of[idx] = Some(stage);
progressed = true;
}
}
if !progressed {
break;
}
}
let stage_count = stage_of
.iter()
.filter_map(|stage| *stage)
.max()
.map_or(0, |max| max + 1);
let mut stages = vec![Vec::new(); stage_count];
let mut unscheduled = Vec::new();
for (idx, stage) in stage_of.iter().enumerate() {
match stage {
Some(stage) => stages[*stage].push(idx),
None => unscheduled.push(idx),
}
}
ComputationSchedule { stages, unscheduled }
}
#[async_trait]
impl MarketEventHandler for ComputationManager {
async fn handle_event(&mut self, event: &MarketEvent) -> Result<(), EventError> {
match event {
MarketEvent::MarketUpdated {
added_components,
removed_components,
updated_components,
} if !added_components.is_empty() ||
!removed_components.is_empty() ||
!updated_components.is_empty() =>
{
trace!(
added = added_components.len(),
removed = removed_components.len(),
updated = updated_components.len(),
"market updated, running incremental computations"
);
let changed = ChangedComponents {
added: added_components.clone(),
removed: removed_components.clone(),
updated: updated_components.clone(),
is_full_recompute: false,
};
self.compute_all(&changed).await;
}
_ => {
trace!("empty market update, skipping computations");
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tokio::sync::broadcast;
use super::*;
use crate::{
algorithm::test_utils::{component, setup_market_weighted, token, MockProtocolSim},
derived::computation::{ComputationOutput, FailedItem, FailedItemError},
feed::market_data::{MarketData, MarketState},
types::BlockInfo,
};
fn drain_events(rx: &mut broadcast::Receiver<DerivedDataEvent>) -> Vec<DerivedDataEvent> {
let mut events = vec![];
loop {
match rx.try_recv() {
Ok(e) => events.push(e),
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => break,
}
}
events
}
#[test]
fn schedule_empty_has_no_stages() {
let schedule = build_schedule(&[]);
assert!(schedule.stages.is_empty());
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_single_root_is_one_stage() {
let schedule = build_schedule(&[("a", ComputationRequirements::none())]);
assert_eq!(schedule.stages, vec![vec![0]]);
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_independent_roots_share_one_stage() {
let schedule = build_schedule(&[
("a", ComputationRequirements::none()),
("b", ComputationRequirements::none()),
]);
assert_eq!(schedule.stages, vec![vec![0, 1]]);
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_chain_orders_into_successive_stages() {
let schedule = build_schedule(&[
("a", ComputationRequirements::none()),
("b", ComputationRequirements::fresh(["a"])),
("c", ComputationRequirements::fresh(["b"])),
]);
assert_eq!(schedule.stages, vec![vec![0], vec![1], vec![2]]);
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_diamond_places_join_after_both_parents() {
let schedule = build_schedule(&[
("a", ComputationRequirements::none()),
("b", ComputationRequirements::fresh(["a"])),
("c", ComputationRequirements::fresh(["a"])),
("d", ComputationRequirements::fresh(["b", "c"])),
]);
assert_eq!(schedule.stages, vec![vec![0], vec![1, 2], vec![3]]);
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_preserves_input_order_within_a_stage() {
let schedule = build_schedule(&[
("a", ComputationRequirements::none()),
("b", ComputationRequirements::fresh(["a"])),
("c", ComputationRequirements::fresh(["a"])),
]);
assert_eq!(schedule.stages, vec![vec![0], vec![1, 2]]);
}
#[test]
fn schedule_stale_requirement_orders_after_its_producer() {
let schedule = build_schedule(&[
("a", ComputationRequirements::none()),
("b", ComputationRequirements::stale(["a"])),
]);
assert_eq!(schedule.stages, vec![vec![0], vec![1]]);
}
#[test]
fn schedule_requirement_on_unregistered_id_does_not_affect_ordering() {
let schedule = build_schedule(&[("a", ComputationRequirements::fresh(["ghost"]))]);
assert_eq!(schedule.stages, vec![vec![0]]);
assert!(schedule.unscheduled.is_empty());
}
#[test]
fn schedule_two_node_cycle_is_unscheduled() {
let schedule = build_schedule(&[
("a", ComputationRequirements::fresh(["b"])),
("b", ComputationRequirements::fresh(["a"])),
]);
assert!(schedule.stages.is_empty());
assert_eq!(schedule.unscheduled, vec![0, 1]);
}
#[test]
fn schedule_isolates_cycle_from_schedulable_nodes() {
let schedule = build_schedule(&[
("root", ComputationRequirements::none()),
("x", ComputationRequirements::fresh(["y"])),
("y", ComputationRequirements::fresh(["x"])),
]);
assert_eq!(schedule.stages, vec![vec![0]]);
assert_eq!(schedule.unscheduled, vec![1, 2]);
}
#[test]
fn invalid_slippage_threshold_returns_error() {
let (market, _) = setup_market_weighted(vec![]);
let config = ComputationManagerConfig::new().with_depth_slippage_threshold(1.5);
let result = ComputationManager::new(config, market);
assert!(matches!(result, Err(ComputationError::InvalidConfiguration(_))));
}
#[tokio::test]
async fn handle_event_runs_computations_on_market_update() {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let (market, _) = setup_market_weighted(vec![(
"eth_usdc",
ð,
&usdc,
MockProtocolSim::new(2000.0).with_gas(0),
)]);
let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
let event = MarketEvent::MarketUpdated {
added_components: HashMap::from([(
"eth_usdc".to_string(),
vec![eth.address.clone(), usdc.address.clone()],
)]),
removed_components: vec![],
updated_components: vec![],
};
manager
.handle_event(&event)
.await
.unwrap();
let store = manager.store();
let guard = store.read().await;
assert!(guard.token_prices().is_some());
assert!(guard.spot_prices().is_some());
}
#[tokio::test]
async fn handle_event_skips_empty_update() {
let (market, _) = setup_market_weighted(vec![]);
let config = ComputationManagerConfig::new();
let (mut manager, _event_rx) = ComputationManager::new(config, market).unwrap();
let event = MarketEvent::MarketUpdated {
added_components: HashMap::new(),
removed_components: vec![],
updated_components: vec![],
};
manager
.handle_event(&event)
.await
.unwrap();
let store = manager.store();
let guard = store.read().await;
assert!(guard.token_prices().is_none());
}
#[tokio::test]
async fn run_shuts_down_on_signal() {
let (market, _) = setup_market_weighted(vec![]);
let config = ComputationManagerConfig::new();
let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
let (_event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
let handle = tokio::spawn(async move {
manager.run(event_rx, shutdown_rx).await;
});
shutdown_tx.send(()).unwrap();
tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
.await
.expect("manager should shutdown")
.expect("task should complete successfully");
}
#[derive(Clone, Debug, PartialEq)]
struct CounterOutput(u32);
struct CounterComputation;
#[async_trait::async_trait]
impl DerivedComputation for CounterComputation {
type Output = CounterOutput;
const ID: ComputationId = "counter";
async fn compute(
&self,
_market: &MarketData,
_store: &SharedDerivedDataRef,
_changed: &ChangedComponents,
) -> Result<ComputationOutput<Self::Output>, ComputationError> {
Ok(ComputationOutput::success(CounterOutput(7)))
}
}
fn market_with_block() -> MarketData {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let (market, _) = setup_market_weighted(vec![(
"eth_usdc",
ð,
&usdc,
MockProtocolSim::new(2000.0).with_gas(0),
)]);
market
}
#[tokio::test]
async fn registered_custom_computation_runs_and_persists_via_default_slot() {
let (mut manager, mut event_rx) = ComputationManager::empty(market_with_block());
manager
.register(CounterComputation)
.unwrap();
manager
.compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
.await;
let store = manager.store();
let guard = store.read().await;
assert_eq!(
guard.output::<CounterOutput>(CounterComputation::ID),
Some(&CounterOutput(7)),
"default persist should write the output into the generic slot"
);
assert!(guard
.output_block(CounterComputation::ID)
.is_some());
let events = drain_events(&mut event_rx);
assert!(
events.iter().any(|e| matches!(
e,
DerivedDataEvent::ComputationComplete { computation_id: "counter", .. }
)),
"expected ComputationComplete(counter), got: {events:?}"
);
}
#[test]
fn registering_duplicate_id_is_rejected() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager
.register(CounterComputation)
.unwrap();
let result = manager.register(CounterComputation);
assert!(matches!(result, Err(ComputationError::DuplicateComputationId("counter"))));
}
fn event_summary(events: &[DerivedDataEvent]) -> Vec<(&'static str, &'static str)> {
events
.iter()
.map(|event| match event {
DerivedDataEvent::NewBlock { .. } => ("new_block", ""),
DerivedDataEvent::ComputationComplete { computation_id, .. } => {
("complete", *computation_id)
}
DerivedDataEvent::ComputationFailed { computation_id, .. } => {
("failed", *computation_id)
}
})
.collect()
}
async fn run_full_recompute(manager: &ComputationManager) -> Vec<DerivedDataEvent> {
let mut event_rx = manager.event_sender().subscribe();
manager
.compute_all(&ChangedComponents { is_full_recompute: true, ..Default::default() })
.await;
drain_events(&mut event_rx)
}
macro_rules! test_computation {
($name:ident, $id:literal, $reqs:expr, $result:expr) => {
struct $name;
#[async_trait::async_trait]
impl DerivedComputation for $name {
type Output = ();
const ID: ComputationId = $id;
fn requirements(&self) -> ComputationRequirements {
$reqs
}
async fn compute(
&self,
_market: &MarketData,
_store: &SharedDerivedDataRef,
_changed: &ChangedComponents,
) -> Result<ComputationOutput<Self::Output>, ComputationError> {
$result
}
}
};
}
test_computation!(
RootOk,
"root",
ComputationRequirements::none(),
Ok(ComputationOutput::success(()))
);
test_computation!(
DepOnRoot,
"dep",
ComputationRequirements::fresh(["root"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
SecondDepOnRoot,
"dep2",
ComputationRequirements::fresh(["root"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
RootErr,
"boom",
ComputationRequirements::none(),
Err(ComputationError::InvalidConfiguration("boom".to_string()))
);
test_computation!(
DepOnBoom,
"dep_boom",
ComputationRequirements::fresh(["boom"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
ThirdOnBoom,
"third",
ComputationRequirements::fresh(["dep_boom"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
StaleDepOnFlaky,
"stale_dep",
ComputationRequirements::stale(["flaky"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
GhostDependent,
"needs_ghost",
ComputationRequirements::fresh(["ghost"]),
Ok(ComputationOutput::success(()))
);
test_computation!(
PartialProducer,
"partial",
ComputationRequirements::none(),
Ok(ComputationOutput::with_failures(
(),
vec![FailedItem { key: "x".to_string(), error: FailedItemError::MissingSpotPrice }]
))
);
test_computation!(
DepOnPartial,
"dep_partial",
ComputationRequirements::fresh(["partial"]),
Ok(ComputationOutput::success(()))
);
struct FlakyProducer {
succeed: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl DerivedComputation for FlakyProducer {
type Output = ();
const ID: ComputationId = "flaky";
async fn compute(
&self,
_market: &MarketData,
_store: &SharedDerivedDataRef,
_changed: &ChangedComponents,
) -> Result<ComputationOutput<Self::Output>, ComputationError> {
if self.succeed.load(Ordering::SeqCst) {
Ok(ComputationOutput::success(()))
} else {
Err(ComputationError::InvalidConfiguration("flaky".to_string()))
}
}
}
#[tokio::test]
async fn events_follow_dependency_order_across_stages() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager.register(RootOk).unwrap();
manager.register(DepOnRoot).unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![("new_block", ""), ("complete", "root"), ("complete", "dep")]
);
}
#[tokio::test]
async fn events_preserve_registration_order_within_a_stage() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager.register(RootOk).unwrap();
manager.register(DepOnRoot).unwrap();
manager
.register(SecondDepOnRoot)
.unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![
("new_block", ""),
("complete", "root"),
("complete", "dep"),
("complete", "dep2"),
]
);
}
#[tokio::test]
async fn failed_dependency_cascades_to_dependents() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager.register(RootErr).unwrap();
manager.register(DepOnBoom).unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![("new_block", ""), ("failed", "boom"), ("failed", "dep_boom")]
);
}
#[tokio::test]
async fn computation_with_unregistered_requirement_is_skipped() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager
.register(GhostDependent)
.unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(event_summary(&events), vec![("new_block", ""), ("failed", "needs_ghost")]);
}
#[tokio::test]
async fn fresh_dependent_runs_when_producer_succeeds_partially() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager
.register(PartialProducer)
.unwrap();
manager.register(DepOnPartial).unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![("new_block", ""), ("complete", "partial"), ("complete", "dep_partial"),]
);
}
#[tokio::test]
async fn failure_cascade_propagates_through_three_levels() {
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager.register(RootErr).unwrap();
manager.register(DepOnBoom).unwrap();
manager.register(ThirdOnBoom).unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![
("new_block", ""),
("failed", "boom"),
("failed", "dep_boom"),
("failed", "third"),
]
);
}
#[tokio::test]
async fn stale_dependency_runs_on_prior_value_after_producer_fails() {
let succeed = Arc::new(AtomicBool::new(true));
let (mut manager, _event_rx) = ComputationManager::empty(market_with_block());
manager
.register(FlakyProducer { succeed: Arc::clone(&succeed) })
.unwrap();
manager
.register(StaleDepOnFlaky)
.unwrap();
let first = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&first),
vec![("new_block", ""), ("complete", "flaky"), ("complete", "stale_dep")]
);
succeed.store(false, Ordering::SeqCst);
let second = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&second),
vec![("new_block", ""), ("failed", "flaky"), ("complete", "stale_dep")]
);
}
#[tokio::test]
async fn default_computations_cascade_failure_in_registration_order() {
let (manager, _event_rx) = ComputationManager::new(
ComputationManagerConfig::new(),
market_with_component_no_sim_state(),
)
.unwrap();
let events = run_full_recompute(&manager).await;
assert_eq!(
event_summary(&events),
vec![
("new_block", ""),
("failed", "spot_prices"),
("failed", "token_prices"),
("failed", "pool_depths"),
]
);
}
fn market_with_component_no_sim_state() -> MarketData {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let pool = component("pool", &[eth.clone(), usdc.clone()]);
let mut market = MarketState::new();
market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
market.upsert_components(std::iter::once(pool));
market.upsert_tokens([eth, usdc]);
MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
}
fn market_with_mixed_sim_states() -> MarketData {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let dai = token(3, "DAI");
let pool1 = component("eth_usdc", &[eth.clone(), usdc.clone()]);
let pool2 = component("eth_dai", &[eth.clone(), dai.clone()]);
let mut market = MarketState::new();
market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
market.upsert_components([pool1, pool2]);
market
.update_states([("eth_usdc".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
market.upsert_tokens([eth, usdc, dai]);
MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
}
fn market_with_sim_state_no_gas_price() -> MarketData {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let pool = component("pool", &[eth.clone(), usdc.clone()]);
let mut market = MarketState::new();
market.update_last_updated(BlockInfo::new(10, "0xhash".into(), 0));
market.upsert_components(std::iter::once(pool));
market.update_states([("pool".to_string(), Box::new(MockProtocolSim::new(2000.0)) as _)]);
market.upsert_tokens([eth, usdc]);
MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
}
#[tokio::test]
async fn test_spot_price_failure_broadcasts_computation_failed() {
let market = market_with_component_no_sim_state();
let config = ComputationManagerConfig::new();
let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
manager.compute_all(&changed).await;
let events = drain_events(&mut event_rx);
assert!(
events.iter().any(|e| matches!(
e,
DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
)),
"expected ComputationFailed(spot_prices) in events: {events:?}"
);
}
#[tokio::test]
async fn test_token_price_failure_broadcasts_computation_failed() {
let eth = token(1, "ETH");
let usdc = token(2, "USDC");
let market = market_with_sim_state_no_gas_price();
let config = ComputationManagerConfig::new().with_gas_token(eth.address.clone());
let (mut manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
let event = MarketEvent::MarketUpdated {
added_components: HashMap::from([(
"pool".to_string(),
vec![eth.address.clone(), usdc.address.clone()],
)]),
removed_components: vec![],
updated_components: vec![],
};
manager
.handle_event(&event)
.await
.unwrap();
let events = drain_events(&mut event_rx);
assert!(
events.iter().any(|e| matches!(
e,
DerivedDataEvent::ComputationFailed { computation_id: "token_prices", .. }
)),
"expected ComputationFailed(token_prices) in events: {events:?}"
);
}
#[tokio::test]
async fn run_shuts_down_on_channel_close() {
let (market, _) = setup_market_weighted(vec![]);
let config = ComputationManagerConfig::new();
let (manager, _event_rx) = ComputationManager::new(config, market).unwrap();
let (event_tx, event_rx) = broadcast::channel::<MarketEvent>(16);
let (_shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
let handle = tokio::spawn(async move {
manager.run(event_rx, shutdown_rx).await;
});
drop(event_tx);
tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
.await
.expect("manager should shutdown on channel close")
.expect("task should complete successfully");
}
#[tokio::test]
async fn partial_spot_price_failure_broadcasts_computation_complete() {
let market = market_with_mixed_sim_states();
let config = ComputationManagerConfig::new();
let (manager, mut event_rx) = ComputationManager::new(config, market).unwrap();
let changed = ChangedComponents { is_full_recompute: true, ..Default::default() };
manager.compute_all(&changed).await;
let events = drain_events(&mut event_rx);
assert!(
events.iter().any(|e| matches!(
e,
DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. }
)),
"expected ComputationComplete(spot_prices), got: {events:?}"
);
assert!(
!events.iter().any(|e| matches!(
e,
DerivedDataEvent::ComputationFailed { computation_id: "spot_prices", .. }
)),
"should not broadcast ComputationFailed for partial failure"
);
let complete = events.iter().find(|e| {
matches!(e, DerivedDataEvent::ComputationComplete { computation_id: "spot_prices", .. })
});
if let Some(DerivedDataEvent::ComputationComplete { failed_items, .. }) = complete {
assert!(
!failed_items.is_empty(),
"ComputationComplete should carry failed_items for pool2"
);
}
let eth = token(1, "ETH");
let dai = token(3, "DAI");
let store = manager.store();
let guard = store.read().await;
let key_eth_dai = ("eth_dai".to_string(), eth.address.clone(), dai.address.clone());
let key_dai_eth = ("eth_dai".to_string(), dai.address.clone(), eth.address.clone());
assert!(
guard
.spot_price_failure(&key_eth_dai)
.is_some() ||
guard
.spot_price_failure(&key_dai_eth)
.is_some(),
"store should persist failure reason for eth_dai (missing sim state)"
);
}
}