From 8b51570090227539545d5fb4f2b2e9574d0dd0a2 Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 12 Jan 2026 17:21:36 +0000 Subject: [PATCH 01/42] Separate autopilot API native price estimator --- crates/autopilot/src/arguments.rs | 11 + crates/autopilot/src/run.rs | 16 +- crates/autopilot/src/solvable_orders.rs | 21 +- crates/shared/src/price_estimation/factory.rs | 120 ++++- .../price_estimation/native_price_cache.rs | 419 ++++++++++-------- 5 files changed, 374 insertions(+), 213 deletions(-) diff --git a/crates/autopilot/src/arguments.rs b/crates/autopilot/src/arguments.rs index 193be1a5a9..4ab98b150e 100644 --- a/crates/autopilot/src/arguments.rs +++ b/crates/autopilot/src/arguments.rs @@ -93,6 +93,11 @@ pub struct Arguments { #[clap(long, env)] pub native_price_estimators: NativePriceEstimators, + /// Native price estimators for the API endpoint. + /// If not provided, uses `native_price_estimators`. + #[clap(long, env)] + pub api_native_price_estimators: Option, + /// How many successful price estimates for each order will cause a native /// price estimation to return its result early. It's possible to pass /// values greater than the total number of enabled estimators but that @@ -379,6 +384,7 @@ impl std::fmt::Display for Arguments { allowed_tokens, unsupported_tokens, native_price_estimators, + api_native_price_estimators, min_order_validity_period, banned_users, banned_users_max_cache_size, @@ -428,6 +434,11 @@ impl std::fmt::Display for Arguments { writeln!(f, "allowed_tokens: {allowed_tokens:?}")?; writeln!(f, "unsupported_tokens: {unsupported_tokens:?}")?; writeln!(f, "native_price_estimators: {native_price_estimators}")?; + display_option( + f, + "api_native_price_estimators", + api_native_price_estimators, + )?; writeln!( f, "min_order_validity_period: {min_order_validity_period:?}" diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 7bba0e909b..c02d07fef1 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -390,17 +390,23 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { .await .expect("failed to initialize price estimator factory"); - let native_price_estimator = price_estimator_factory - .native_price_estimator( + let initial_prices = db_write.fetch_latest_prices().await.unwrap(); + let native_price_estimators = price_estimator_factory + .native_price_estimators_with_shared_cache( args.native_price_estimators.as_slice(), + args.api_native_price_estimators + .as_ref() + .map(|e| e.as_slice()), args.native_price_estimation_results_required, eth.contracts().weth().clone(), + initial_prices, ) .instrument(info_span!("native_price_estimator")) .await .unwrap(); - let prices = db_write.fetch_latest_prices().await.unwrap(); - native_price_estimator.initialize_cache(prices); + + let native_price_estimator = native_price_estimators.main; + let api_native_price_estimator = native_price_estimators.api; let price_estimator = price_estimator_factory .price_estimator( @@ -537,7 +543,7 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { let (api_shutdown_sender, api_shutdown_receiver) = tokio::sync::oneshot::channel(); let api_task = tokio::spawn(infra::api::serve( args.api_address, - native_price_estimator.clone(), + api_native_price_estimator, args.price_estimation.quote_timeout, api_shutdown_receiver, )); diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 458aab5523..0e1f8b3d85 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -913,6 +913,7 @@ mod tests { HEALTHY_PRICE_ESTIMATION_TIME, PriceEstimationError, native::MockNativePriceEstimating, + native_price_cache::NativePriceCache, }, signature_validator::{MockSignatureValidating, SignatureValidationError}, }, @@ -955,12 +956,9 @@ mod tests { .withf(move |token, _| *token == token3) .returning(|_, _| async { Ok(0.25) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new( + let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(native_price_estimator), - Duration::from_secs(10), - Duration::MAX, - None, - Default::default(), + NativePriceCache::new(Duration::from_secs(10)), 3, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -1046,10 +1044,10 @@ mod tests { .withf(move |token, _| *token == token5) .returning(|_, _| async { Ok(5.) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new( + let native_price_estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(native_price_estimator), - Duration::from_secs(10), - Duration::MAX, + NativePriceCache::new(Duration::from_secs(10)), + Duration::from_millis(1), // Short interval to trigger background fetch quickly None, Default::default(), 1, @@ -1143,12 +1141,9 @@ mod tests { .withf(move |token, _| *token == token_approx2) .returning(|_, _| async { Ok(50.) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new( + let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(native_price_estimator), - Duration::from_secs(10), - Duration::MAX, - None, - Default::default(), + NativePriceCache::new(Duration::from_secs(10)), 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 37efcaf17e..24bdfdb2fa 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -7,7 +7,7 @@ use { external::ExternalPriceEstimator, instrumented::InstrumentedPriceEstimator, native::{self, NativePriceEstimator}, - native_price_cache::CachingNativePriceEstimator, + native_price_cache::{CachingNativePriceEstimator, NativePriceCache}, sanitized::SanitizedPriceEstimator, trade_verifier::{TradeVerifier, TradeVerifying}, }, @@ -29,6 +29,7 @@ use { }, alloy::primitives::Address, anyhow::{Context as _, Result}, + bigdecimal::BigDecimal, contracts::alloy::WETH9, ethrpc::block_stream::CurrentBlockWatcher, gas_estimation::GasPriceEstimating, @@ -366,16 +367,73 @@ impl<'a> PriceEstimatorFactory<'a> { results_required: NonZeroUsize, weth: WETH9::Instance, ) -> Result> { + let cache = NativePriceCache::new(self.args.native_price_cache_max_age); + self.create_caching_native_estimator(native, results_required, &weth, cache, true) + .await + } + + /// Creates a main native price estimator and an optional API-specific + /// estimator that share the same cache. + /// + /// The main estimator includes all sources and runs the background + /// maintenance task. The API estimator (if `api_native` is provided) + /// uses its own set of sources but shares the cache, so it can return + /// cached prices without triggering requests to excluded sources. + /// + /// If `api_native` is None, the API will use the same estimator as main. + /// + /// The `initial_prices` are used to seed the cache before the estimators + /// start. + pub async fn native_price_estimators_with_shared_cache( + &mut self, + native: &[Vec], + api_native: Option<&[Vec]>, + results_required: NonZeroUsize, + weth: WETH9::Instance, + initial_prices: HashMap, + ) -> Result { anyhow::ensure!( self.args.native_price_cache_max_age > self.args.native_price_prefetch_time, "price cache prefetch time needs to be less than price cache max age" ); - let mut estimators = Vec::with_capacity(native.len()); - for stage in native.iter() { + let cache = NativePriceCache::new(self.args.native_price_cache_max_age); + cache.initialize(initial_prices); + + let main = self + .create_caching_native_estimator(native, results_required, &weth, cache.clone(), true) + .await?; + let api = match api_native { + Some(sources) => { + self.create_caching_native_estimator( + sources, + results_required, + &weth, + cache.clone(), + false, + ) + .await? + } + None => main.clone(), + }; + + Ok(NativePriceEstimators { main, api }) + } + + /// Helper to create a CachingNativePriceEstimator with a specific cache. + async fn create_caching_native_estimator( + &mut self, + sources: &[Vec], + results_required: NonZeroUsize, + weth: &WETH9::Instance, + cache: NativePriceCache, + spawn_background_task: bool, + ) -> Result> { + let mut estimators = Vec::with_capacity(sources.len()); + for stage in sources.iter() { let mut stages = Vec::with_capacity(stage.len()); for source in stage { - stages.push(self.create_native_estimator(source, &weth).await?); + stages.push(self.create_native_estimator(source, weth).await?); } estimators.push(stages); } @@ -384,24 +442,48 @@ impl<'a> PriceEstimatorFactory<'a> { CompetitionEstimator::new(estimators, PriceRanking::MaxOutAmount) .with_verification(self.args.quote_verification) .with_early_return(results_required); - let native_estimator = Arc::new(CachingNativePriceEstimator::new( - Box::new(competition_estimator), - self.args.native_price_cache_max_age, - self.args.native_price_cache_refresh, - Some(self.args.native_price_cache_max_update_size), - self.args.native_price_prefetch_time, - self.args.native_price_cache_concurrent_requests, - self.args - .native_price_approximation_tokens - .iter() - .copied() - .collect(), - self.args.quote_timeout, - )); - Ok(native_estimator) + + let approximation_tokens = self + .args + .native_price_approximation_tokens + .iter() + .copied() + .collect(); + + let estimator = if spawn_background_task { + CachingNativePriceEstimator::new_with_maintenance( + Box::new(competition_estimator), + cache, + self.args.native_price_cache_refresh, + Some(self.args.native_price_cache_max_update_size), + self.args.native_price_prefetch_time, + self.args.native_price_cache_concurrent_requests, + approximation_tokens, + self.args.quote_timeout, + ) + } else { + CachingNativePriceEstimator::new_without_maintenance( + Box::new(competition_estimator), + cache, + self.args.native_price_cache_concurrent_requests, + approximation_tokens, + self.args.quote_timeout, + ) + }; + + Ok(Arc::new(estimator)) } } +/// Result of creating native price estimators with shared cache. +pub struct NativePriceEstimators { + /// Main estimator, which runs the background cache maintenance task. + pub main: Arc, + /// API estimator that shares the cache with the main estimator but doesn't + /// run background task and might use a different set of sources. + pub api: Arc, +} + /// Trait for modelling the initialization of a Price estimator and its verified /// counter-part. This allows for generic price estimator creation, as well as /// per-type trade verification configuration. diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 5e95f7e45f..8bda5fc08c 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -13,7 +13,7 @@ use { rand::Rng, std::{ collections::{HashMap, hash_map::Entry}, - sync::{Arc, Mutex, MutexGuard, Weak}, + sync::{Arc, Mutex, Weak}, time::{Duration, Instant}, }, tokio::time, @@ -39,6 +39,160 @@ impl Metrics { } } +/// Shared cache storage for native price estimates. +/// +/// Can be shared between multiple `CachingNativePriceEstimator` instances, +/// allowing them to read/write from the same cache while using different +/// price estimation sources. +#[derive(Clone)] +pub struct NativePriceCache { + inner: Arc, +} + +struct CacheStorage { + cache: Mutex>, + max_age: Duration, +} + +impl NativePriceCache { + /// Creates a new cache with the given max age for entries. + pub fn new(max_age: Duration) -> Self { + Self { + inner: Arc::new(CacheStorage { + cache: Default::default(), + max_age, + }), + } + } + + /// Returns the max age configuration for this cache. + pub fn max_age(&self) -> Duration { + self.inner.max_age + } + + /// Returns the number of entries in the cache. + pub fn len(&self) -> usize { + self.inner.cache.lock().unwrap().len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Initialize the cache with prices from the database. + /// Entries are initialized with random ages to avoid expiration spikes. + pub fn initialize(&self, prices: HashMap) { + let mut rng = rand::thread_rng(); + let now = std::time::Instant::now(); + + let cache = prices + .into_iter() + .filter_map(|(token, price)| { + // Generate random `updated_at` timestamp + // to avoid spikes of expired prices. + let percent_expired = rng.gen_range(50..=90); + let age = self.inner.max_age.as_secs() * percent_expired / 100; + let updated_at = now - Duration::from_secs(age); + + Some(( + token, + CachedResult::new( + Ok(from_normalized_price(price)?), + updated_at, + now, + Default::default(), + ), + )) + }) + .collect::>(); + + *self.inner.cache.lock().unwrap() = cache; + } + + /// Get a cached price, optionally creating a placeholder entry for missing + /// tokens. Returns None if the price is not cached or is expired. + fn get_cached_price( + &self, + token: Address, + now: Instant, + create_missing_entry: bool, + ) -> Option { + let mut cache = self.inner.cache.lock().unwrap(); + match cache.entry(token) { + Entry::Occupied(mut entry) => { + let entry = entry.get_mut(); + entry.requested_at = now; + let is_recent = + now.saturating_duration_since(entry.updated_at) < self.inner.max_age; + is_recent.then_some(entry.clone()) + } + Entry::Vacant(entry) => { + if create_missing_entry { + // Create an outdated cache entry so the background task keeping the cache warm + // will fetch the price during the next maintenance cycle. + // This should happen only for prices missing while building the auction. + // Otherwise malicious actors could easily cause the cache size to blow up. + let outdated_timestamp = now.checked_sub(self.inner.max_age).unwrap(); + tracing::trace!(?token, "create outdated price entry"); + entry.insert(CachedResult::new( + Ok(0.), + outdated_timestamp, + now, + Default::default(), + )); + } + None + } + } + } + + /// Get a cached price that is ready to use (not in error accumulation + /// state). + fn get_ready_to_use_cached_price( + &self, + token: Address, + now: Instant, + create_missing_entry: bool, + ) -> Option { + self.get_cached_price(token, now, create_missing_entry) + .filter(|cached| cached.is_ready()) + } + + /// Insert or update a cached result. + fn insert(&self, token: Address, result: CachedResult) { + self.inner.cache.lock().unwrap().insert(token, result); + } + + /// Get tokens that need updating, sorted by priority. + /// High priority tokens come first, then by most recent request time. + fn sorted_tokens_to_update( + &self, + max_age: Duration, + now: Instant, + high_priority: &IndexSet
, + ) -> Vec
{ + let mut outdated: Vec<_> = self + .inner + .cache + .lock() + .unwrap() + .iter() + .filter(|(_, cached)| now.saturating_duration_since(cached.updated_at) > max_age) + .map(|(token, cached)| (*token, cached.requested_at)) + .collect(); + + let index = |token: &Address| high_priority.get_index_of(token).unwrap_or(usize::MAX); + outdated.sort_by_cached_key(|entry| { + ( + index(&entry.0), // important items have a low index + std::cmp::Reverse(entry.1), // important items have recent (i.e. "big") timestamp + ) + }); + outdated.into_iter().map(|(token, _)| token).collect() + } +} + /// Wrapper around `Box` which caches successful price /// estimates for some time and supports updating the cache in the background. /// @@ -49,10 +203,9 @@ impl Metrics { pub struct CachingNativePriceEstimator(Arc); struct Inner { - cache: Mutex>, + cache: NativePriceCache, high_priority: Mutex>, estimator: Box, - max_age: Duration, concurrent_requests: usize, // TODO remove when implementing a less hacky solution /// Maps a requested token to an approximating token. If the system @@ -119,52 +272,6 @@ impl CachedResult { } impl Inner { - // Returns a single cached price and updates its `requested_at` field. - fn get_cached_price( - token: Address, - now: Instant, - cache: &mut MutexGuard>, - max_age: &Duration, - create_missing_entry: bool, - ) -> Option { - match cache.entry(token) { - Entry::Occupied(mut entry) => { - let entry = entry.get_mut(); - entry.requested_at = now; - let is_recent = now.saturating_duration_since(entry.updated_at) < *max_age; - is_recent.then_some(entry.clone()) - } - Entry::Vacant(entry) => { - if create_missing_entry { - // Create an outdated cache entry so the background task keeping the cache warm - // will fetch the price during the next maintenance cycle. - // This should happen only for prices missing while building the auction. - // Otherwise malicious actors could easily cause the cache size to blow up. - let outdated_timestamp = now.checked_sub(*max_age).unwrap(); - tracing::trace!(?token, "create outdated price entry"); - entry.insert(CachedResult::new( - Ok(0.), - outdated_timestamp, - now, - Default::default(), - )); - } - None - } - } - } - - fn get_ready_to_use_cached_price( - token: Address, - now: Instant, - cache: &mut MutexGuard>, - max_age: &Duration, - create_missing_entry: bool, - ) -> Option { - Self::get_cached_price(token, now, cache, max_age, create_missing_entry) - .filter(|cached| cached.is_ready()) - } - /// Checks cache for the given tokens one by one. If the price is already /// cached, it gets returned. If it's not in the cache, a new price /// estimation request gets issued. We check the cache before each @@ -173,16 +280,14 @@ impl Inner { fn estimate_prices_and_update_cache<'a>( &'a self, tokens: &'a [Address], - max_age: Duration, request_timeout: Duration, ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { let estimates = tokens.iter().map(move |token| async move { let current_accumulative_errors_count = { // check if the price is cached by now let now = Instant::now(); - let mut cache = self.cache.lock().unwrap(); - match Self::get_cached_price(*token, now, &mut cache, &max_age, false) { + match self.cache.get_cached_price(*token, now, false) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -201,9 +306,7 @@ impl Inner { // update price in cache if should_cache(&result) { let now = Instant::now(); - let mut cache = self.cache.lock().unwrap(); - - cache.insert( + self.cache.insert( *token, CachedResult::new(result.clone(), now, now, current_accumulative_errors_count), ); @@ -218,24 +321,9 @@ impl Inner { /// Tokens with highest priority first. fn sorted_tokens_to_update(&self, max_age: Duration, now: Instant) -> Vec
{ - let mut outdated: Vec<_> = self - .cache - .lock() - .unwrap() - .iter() - .filter(|(_, cached)| now.saturating_duration_since(cached.updated_at) > max_age) - .map(|(token, cached)| (*token, cached.requested_at)) - .collect(); - let high_priority = self.high_priority.lock().unwrap().clone(); - let index = |token: &Address| high_priority.get_index_of(token).unwrap_or(usize::MAX); - outdated.sort_by_cached_key(|entry| { - ( - index(&entry.0), // important items have a low index - std::cmp::Reverse(entry.1), // important items have recent (i.e. "big") timestamp - ) - }); - outdated.into_iter().map(|(token, _)| token).collect() + self.cache + .sorted_tokens_to_update(max_age, now, &high_priority) } } @@ -262,9 +350,9 @@ impl UpdateTask { let metrics = Metrics::get(); metrics .native_price_cache_size - .set(i64::try_from(inner.cache.lock().unwrap().len()).unwrap_or(i64::MAX)); + .set(i64::try_from(inner.cache.len()).unwrap_or(i64::MAX)); - let max_age = inner.max_age.saturating_sub(self.prefetch_time); + let max_age = inner.cache.max_age().saturating_sub(self.prefetch_time); let mut outdated_entries = inner.sorted_tokens_to_update(max_age, Instant::now()); tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated prices to fetch"); @@ -280,7 +368,7 @@ impl UpdateTask { } let mut stream = - inner.estimate_prices_and_update_cache(&outdated_entries, max_age, inner.quote_timeout); + inner.estimate_prices_and_update_cache(&outdated_entries, inner.quote_timeout); while stream.next().await.is_some() {} metrics .native_price_cache_background_updates @@ -298,45 +386,27 @@ impl UpdateTask { } impl CachingNativePriceEstimator { + /// Initialize the cache with prices from the database. + /// Delegates to the underlying `NativePriceCache::initialize`. pub fn initialize_cache(&self, prices: HashMap) { - let mut rng = rand::thread_rng(); - let now = std::time::Instant::now(); - - let cache = prices - .into_iter() - .filter_map(|(token, price)| { - // Generate random `updated_at` timestamp - // to avoid spikes of expired prices. - let percent_expired = rng.gen_range(50..=90); - let age = self.0.max_age.as_secs() * percent_expired / 100; - let updated_at = now - Duration::from_secs(age); - - Some(( - token, - CachedResult::new( - Ok(from_normalized_price(price)?), - updated_at, - now, - Default::default(), - ), - )) - }) - .collect::>(); + self.0.cache.initialize(prices); + } - *self.0.cache.lock().unwrap() = cache; + /// Returns a reference to the underlying shared cache. + /// This can be used to share the cache with other estimator instances. + pub fn cache(&self) -> &NativePriceCache { + &self.0.cache } - /// Creates new CachingNativePriceEstimator using `estimator` to calculate - /// native prices which get cached a duration of `max_age`. - /// Spawns a background task maintaining the cache once per - /// `update_interval`. Only soon to be outdated prices get updated and - /// recently used prices have a higher priority. If `update_size` is - /// `Some(n)` at most `n` prices get updated per interval. - /// If `update_size` is `None` no limit gets applied. + /// Creates a new CachingNativePriceEstimator with a background maintenance + /// task. + /// + /// The maintenance task periodically refreshes cached prices before they + /// expire. Use this for the primary estimator in a shared-cache setup. #[expect(clippy::too_many_arguments)] - pub fn new( + pub fn new_with_maintenance( estimator: Box, - max_age: Duration, + cache: NativePriceCache, update_interval: Duration, update_size: Option, prefetch_time: Duration, @@ -346,9 +416,8 @@ impl CachingNativePriceEstimator { ) -> Self { let inner = Arc::new(Inner { estimator, - cache: Default::default(), + cache, high_priority: Default::default(), - max_age, concurrent_requests, approximation_tokens, quote_timeout, @@ -367,6 +436,28 @@ impl CachingNativePriceEstimator { Self(inner) } + /// Creates a new CachingNativePriceEstimator without a background + /// maintenance task. + /// + /// Use this for secondary estimators that share a cache with a primary + /// estimator to avoid duplicate maintenance work. + pub fn new_without_maintenance( + estimator: Box, + cache: NativePriceCache, + concurrent_requests: usize, + approximation_tokens: HashMap, + quote_timeout: Duration, + ) -> Self { + Self(Arc::new(Inner { + estimator, + cache, + high_priority: Default::default(), + concurrent_requests, + approximation_tokens, + quote_timeout, + })) + } + /// Only returns prices that are currently cached. Missing prices will get /// prioritized to get fetched during the next cycles of the maintenance /// background task. @@ -375,16 +466,12 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - let mut cache = self.0.cache.lock().unwrap(); let mut results = HashMap::default(); for token in tokens { - let cached = Inner::get_ready_to_use_cached_price( - *token, - now, - &mut cache, - &self.0.max_age, - true, - ); + let cached = self + .0 + .cache + .get_ready_to_use_cached_price(*token, now, true); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -417,9 +504,9 @@ impl CachingNativePriceEstimator { .filter(|t| !prices.contains_key(*t)) .copied() .collect(); - let price_stream = - self.0 - .estimate_prices_and_update_cache(&uncached_tokens, self.0.max_age, timeout); + let price_stream = self + .0 + .estimate_prices_and_update_cache(&uncached_tokens, timeout); let _ = time::timeout(timeout, async { let mut price_stream = price_stream; @@ -443,11 +530,11 @@ impl NativePriceEstimating for CachingNativePriceEstimator { timeout: Duration, ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { - let cached = { - let now = Instant::now(); - let mut cache = self.0.cache.lock().unwrap(); - Inner::get_ready_to_use_cached_price(token, now, &mut cache, &self.0.max_age, false) - }; + let now = Instant::now(); + let cached = self + .0 + .cache + .get_ready_to_use_cached_price(token, now, false); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -460,7 +547,7 @@ impl NativePriceEstimating for CachingNativePriceEstimator { } self.0 - .estimate_prices_and_update_cache(&[token], self.0.max_age, timeout) + .estimate_prices_and_update_cache(&[token], timeout) .next() .await .unwrap() @@ -499,12 +586,9 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_secs(MAX_AGE_SECS), - Default::default(), - None, - Default::default(), + NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS)), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -514,7 +598,7 @@ mod tests { { // Check that `updated_at` timestamps are initialized with // reasonable values. - let cache = estimator.0.cache.lock().unwrap(); + let cache = estimator.0.cache.inner.cache.lock().unwrap(); for value in cache.values() { let elapsed = value.updated_at.elapsed(); assert!(elapsed >= min_age && elapsed <= max_age); @@ -537,12 +621,9 @@ mod tests { .times(1) .returning(|_, _| async { Ok(1.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_millis(30), - Default::default(), - None, - Default::default(), + NativePriceCache::new(Duration::from_millis(30)), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -575,12 +656,9 @@ mod tests { .withf(move |t, _| *t == token(200)) .returning(|_, _| async { Ok(200.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_millis(30), - Default::default(), - None, - Default::default(), + NativePriceCache::new(Duration::from_millis(30)), 1, // set token approximations for tokens 1 and 2 HashMap::from([ @@ -630,12 +708,9 @@ mod tests { .times(1) .returning(|_, _| async { Err(PriceEstimationError::NoLiquidity) }.boxed()); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_millis(30), - Default::default(), - None, - Default::default(), + NativePriceCache::new(Duration::from_millis(30)), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -701,12 +776,9 @@ mod tests { async { Err(PriceEstimationError::EstimatorInternal(anyhow!("boom"))) }.boxed() }); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_millis(100), - Duration::from_millis(200), - None, - Default::default(), + NativePriceCache::new(Duration::from_millis(100)), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -773,12 +845,9 @@ mod tests { .times(10) .returning(|_, _| async { Err(PriceEstimationError::RateLimited) }.boxed()); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - Duration::from_millis(30), - Default::default(), - None, - Default::default(), + NativePriceCache::new(Duration::from_millis(30)), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -831,12 +900,12 @@ mod tests { async { Ok(3.0) }.boxed() }); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - Duration::from_millis(30), + NativePriceCache::new(Duration::from_millis(30)), Duration::from_millis(50), Some(1), - Duration::default(), + Default::default(), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -879,12 +948,12 @@ mod tests { .times(10) .returning(move |_, _| async { Ok(2.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - Duration::from_millis(30), + NativePriceCache::new(Duration::from_millis(30)), Duration::from_millis(50), None, - Duration::default(), + Default::default(), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -932,12 +1001,12 @@ mod tests { .boxed() }); - let estimator = CachingNativePriceEstimator::new( + let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - Duration::from_millis(30), + NativePriceCache::new(Duration::from_millis(30)), Duration::from_millis(50), None, - Duration::default(), + Default::default(), BATCH_SIZE, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -972,18 +1041,16 @@ mod tests { let t0 = Address::with_last_byte(0); let t1 = Address::with_last_byte(1); let now = Instant::now(); + + // Create a cache and populate it directly + let cache = NativePriceCache::new(Duration::from_secs(10)); + cache.insert(t0, CachedResult::new(Ok(0.), now, now, Default::default())); + cache.insert(t1, CachedResult::new(Ok(0.), now, now, Default::default())); + let inner = Inner { - cache: Mutex::new( - [ - (t0, CachedResult::new(Ok(0.), now, now, Default::default())), - (t1, CachedResult::new(Ok(0.), now, now, Default::default())), - ] - .into_iter() - .collect(), - ), + cache, high_priority: Default::default(), estimator: Box::new(MockNativePriceEstimating::new()), - max_age: Default::default(), concurrent_requests: 1, approximation_tokens: Default::default(), quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, From 961f4c295d51f69a47a6af249a5cdc4eb5af96ef Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 12 Jan 2026 17:34:33 +0000 Subject: [PATCH 02/42] Init prices in constructor --- crates/orderbook/src/run.rs | 4 ++-- crates/shared/src/price_estimation/factory.rs | 2 ++ .../src/price_estimation/native_price_cache.rs | 13 ++++--------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index 9229cd8b20..cd9b7163c7 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -319,16 +319,16 @@ pub async fn run(args: Arguments) { .await .expect("failed to initialize price estimator factory"); + let initial_prices = postgres_write.fetch_latest_prices().await.unwrap(); let native_price_estimator = price_estimator_factory .native_price_estimator( args.native_price_estimators.as_slice(), args.fast_price_estimation_results_required, native_token.clone(), + initial_prices, ) .await .unwrap(); - let prices = postgres_write.fetch_latest_prices().await.unwrap(); - native_price_estimator.initialize_cache(prices); let price_estimator = price_estimator_factory .price_estimator( diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 24bdfdb2fa..7b7ee5c6a9 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -366,8 +366,10 @@ impl<'a> PriceEstimatorFactory<'a> { native: &[Vec], results_required: NonZeroUsize, weth: WETH9::Instance, + initial_prices: HashMap, ) -> Result> { let cache = NativePriceCache::new(self.args.native_price_cache_max_age); + cache.initialize(initial_prices); self.create_caching_native_estimator(native, results_required, &weth, cache, true) .await } diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 8bda5fc08c..78b06b0470 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -39,7 +39,7 @@ impl Metrics { } } -/// Shared cache storage for native price estimates. +/// A cache storage for native price estimates. /// /// Can be shared between multiple `CachingNativePriceEstimator` instances, /// allowing them to read/write from the same cache while using different @@ -386,12 +386,6 @@ impl UpdateTask { } impl CachingNativePriceEstimator { - /// Initialize the cache with prices from the database. - /// Delegates to the underlying `NativePriceCache::initialize`. - pub fn initialize_cache(&self, prices: HashMap) { - self.0.cache.initialize(prices); - } - /// Returns a reference to the underlying shared cache. /// This can be used to share the cache with other estimator instances. pub fn cache(&self) -> &NativePriceCache { @@ -586,14 +580,15 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); + let cache = NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS)); + cache.initialize(prices); let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS)), + cache, 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, ); - estimator.initialize_cache(prices); { // Check that `updated_at` timestamps are initialized with From cafec57cde4079366b8bf53d5e66fae589158e0e Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 12 Jan 2026 17:40:03 +0000 Subject: [PATCH 03/42] Private functions --- crates/autopilot/src/solvable_orders.rs | 6 +- crates/shared/src/price_estimation/factory.rs | 6 +- .../price_estimation/native_price_cache.rs | 79 +++++++++---------- 3 files changed, 42 insertions(+), 49 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 0e1f8b3d85..ca9c006406 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -958,7 +958,7 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10)), + NativePriceCache::new(Duration::from_secs(10), Default::default()), 3, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -1046,7 +1046,7 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10)), + NativePriceCache::new(Duration::from_secs(10), Default::default()), Duration::from_millis(1), // Short interval to trigger background fetch quickly None, Default::default(), @@ -1143,7 +1143,7 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10)), + NativePriceCache::new(Duration::from_secs(10), Default::default()), 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 7b7ee5c6a9..704f7898f9 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -368,8 +368,7 @@ impl<'a> PriceEstimatorFactory<'a> { weth: WETH9::Instance, initial_prices: HashMap, ) -> Result> { - let cache = NativePriceCache::new(self.args.native_price_cache_max_age); - cache.initialize(initial_prices); + let cache = NativePriceCache::new(self.args.native_price_cache_max_age, initial_prices); self.create_caching_native_estimator(native, results_required, &weth, cache, true) .await } @@ -399,8 +398,7 @@ impl<'a> PriceEstimatorFactory<'a> { "price cache prefetch time needs to be less than price cache max age" ); - let cache = NativePriceCache::new(self.args.native_price_cache_max_age); - cache.initialize(initial_prices); + let cache = NativePriceCache::new(self.args.native_price_cache_max_age, initial_prices); let main = self .create_caching_native_estimator(native, results_required, &weth, cache.clone(), true) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 78b06b0470..1d84c9d92c 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -55,44 +55,20 @@ struct CacheStorage { } impl NativePriceCache { - /// Creates a new cache with the given max age for entries. - pub fn new(max_age: Duration) -> Self { - Self { - inner: Arc::new(CacheStorage { - cache: Default::default(), - max_age, - }), - } - } - - /// Returns the max age configuration for this cache. - pub fn max_age(&self) -> Duration { - self.inner.max_age - } - - /// Returns the number of entries in the cache. - pub fn len(&self) -> usize { - self.inner.cache.lock().unwrap().len() - } - - /// Returns true if the cache is empty. - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Initialize the cache with prices from the database. - /// Entries are initialized with random ages to avoid expiration spikes. - pub fn initialize(&self, prices: HashMap) { + /// Creates a new cache with the given max age for entries and initial + /// prices. Entries are initialized with random ages to avoid expiration + /// spikes. + pub fn new(max_age: Duration, initial_prices: HashMap) -> Self { let mut rng = rand::thread_rng(); let now = std::time::Instant::now(); - let cache = prices + let cache = initial_prices .into_iter() .filter_map(|(token, price)| { // Generate random `updated_at` timestamp // to avoid spikes of expired prices. let percent_expired = rng.gen_range(50..=90); - let age = self.inner.max_age.as_secs() * percent_expired / 100; + let age = max_age.as_secs() * percent_expired / 100; let updated_at = now - Duration::from_secs(age); Some(( @@ -107,7 +83,27 @@ impl NativePriceCache { }) .collect::>(); - *self.inner.cache.lock().unwrap() = cache; + Self { + inner: Arc::new(CacheStorage { + cache: Mutex::new(cache), + max_age, + }), + } + } + + /// Returns the max age configuration for this cache. + pub fn max_age(&self) -> Duration { + self.inner.max_age + } + + /// Returns the number of entries in the cache. + pub fn len(&self) -> usize { + self.inner.cache.lock().unwrap().len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 } /// Get a cached price, optionally creating a placeholder entry for missing @@ -580,8 +576,7 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); - let cache = NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS)); - cache.initialize(prices); + let cache = NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS), prices); let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), cache, @@ -618,7 +613,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -653,7 +648,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, // set token approximations for tokens 1 and 2 HashMap::from([ @@ -705,7 +700,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -773,7 +768,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(100)), + NativePriceCache::new(Duration::from_millis(100), Default::default()), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -842,7 +837,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_without_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), HEALTHY_PRICE_ESTIMATION_TIME, @@ -897,7 +892,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), Duration::from_millis(50), Some(1), Default::default(), @@ -945,7 +940,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), Duration::from_millis(50), None, Default::default(), @@ -998,7 +993,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new_with_maintenance( Box::new(inner), - NativePriceCache::new(Duration::from_millis(30)), + NativePriceCache::new(Duration::from_millis(30), Default::default()), Duration::from_millis(50), None, Default::default(), @@ -1038,7 +1033,7 @@ mod tests { let now = Instant::now(); // Create a cache and populate it directly - let cache = NativePriceCache::new(Duration::from_secs(10)); + let cache = NativePriceCache::new(Duration::from_secs(10), Default::default()); cache.insert(t0, CachedResult::new(Ok(0.), now, now, Default::default())); cache.insert(t1, CachedResult::new(Ok(0.), now, now, Default::default())); From 014b77ee6ce7299f7e8c11c9340cfbc9900bf909 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 13 Jan 2026 19:49:19 +0000 Subject: [PATCH 04/42] tmp --- crates/shared/src/price_estimation/factory.rs | 172 +++++-- .../price_estimation/native_price_cache.rs | 448 ++++++++++-------- 2 files changed, 392 insertions(+), 228 deletions(-) diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 704f7898f9..53a600b4e0 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -7,7 +7,7 @@ use { external::ExternalPriceEstimator, instrumented::InstrumentedPriceEstimator, native::{self, NativePriceEstimator}, - native_price_cache::{CachingNativePriceEstimator, NativePriceCache}, + native_price_cache::{CachingNativePriceEstimator, MaintenanceConfig, NativePriceCache}, sanitized::SanitizedPriceEstimator, trade_verifier::{TradeVerifier, TradeVerifying}, }, @@ -368,18 +368,41 @@ impl<'a> PriceEstimatorFactory<'a> { weth: WETH9::Instance, initial_prices: HashMap, ) -> Result> { - let cache = NativePriceCache::new(self.args.native_price_cache_max_age, initial_prices); - self.create_caching_native_estimator(native, results_required, &weth, cache, true) + // Create a non-caching estimator for the cache's maintenance task + let maintenance_estimator = self + .create_non_caching_native_estimator(native, results_required, &weth) + .await?; + + // Create cache with background maintenance + let cache = NativePriceCache::new_with_maintenance( + self.args.native_price_cache_max_age, + initial_prices, + MaintenanceConfig { + estimator: maintenance_estimator, + update_interval: self.args.native_price_cache_refresh, + update_size: Some(self.args.native_price_cache_max_update_size), + prefetch_time: self.args.native_price_prefetch_time, + concurrent_requests: self.args.native_price_cache_concurrent_requests, + quote_timeout: self.args.quote_timeout, + }, + ); + + // Create the caching estimator for on-demand price fetching + self.create_caching_native_estimator(native, results_required, &weth, cache) .await } /// Creates a main native price estimator and an optional API-specific /// estimator that share the same cache. /// - /// The main estimator includes all sources and runs the background - /// maintenance task. The API estimator (if `api_native` is provided) - /// uses its own set of sources but shares the cache, so it can return - /// cached prices without triggering requests to excluded sources. + /// The cache runs a background maintenance task using a combined estimator + /// that includes all sources from both `native` and `api_native` + /// (deduplicated). This ensures all tokens can be refreshed regardless + /// of which estimator originally requested them. + /// + /// The main estimator uses `native` sources for on-demand fetching. + /// The API estimator (if `api_native` is provided) uses its own set of + /// sources for on-demand fetching, but shares the cache. /// /// If `api_native` is None, the API will use the same estimator as main. /// @@ -398,21 +421,38 @@ impl<'a> PriceEstimatorFactory<'a> { "price cache prefetch time needs to be less than price cache max age" ); - let cache = NativePriceCache::new(self.args.native_price_cache_max_age, initial_prices); + // Merge native and api_native sources (deduplicated) for the maintenance task + let combined_sources = Self::merge_sources(native, api_native); + + // Create a non-caching estimator for the cache's maintenance task + let maintenance_estimator = self + .create_non_caching_native_estimator(&combined_sources, results_required, &weth) + .await?; + + // Create cache with background maintenance using the combined estimator + let cache = NativePriceCache::new_with_maintenance( + self.args.native_price_cache_max_age, + initial_prices, + MaintenanceConfig { + estimator: maintenance_estimator, + update_interval: self.args.native_price_cache_refresh, + update_size: Some(self.args.native_price_cache_max_update_size), + prefetch_time: self.args.native_price_prefetch_time, + concurrent_requests: self.args.native_price_cache_concurrent_requests, + quote_timeout: self.args.quote_timeout, + }, + ); + // Create main estimator for on-demand fetching let main = self - .create_caching_native_estimator(native, results_required, &weth, cache.clone(), true) + .create_caching_native_estimator(native, results_required, &weth, cache.clone()) .await?; + + // Create API estimator (or reuse main) let api = match api_native { Some(sources) => { - self.create_caching_native_estimator( - sources, - results_required, - &weth, - cache.clone(), - false, - ) - .await? + self.create_caching_native_estimator(sources, results_required, &weth, cache) + .await? } None => main.clone(), }; @@ -420,14 +460,48 @@ impl<'a> PriceEstimatorFactory<'a> { Ok(NativePriceEstimators { main, api }) } + /// Merges native and api_native sources, deduplicating by source type. + fn merge_sources( + native: &[Vec], + api_native: Option<&[Vec]>, + ) -> Vec> { + let Some(api_sources) = api_native else { + return native.to_vec(); + }; + + // Collect all unique sources from api_native that aren't in native + let native_flat: std::collections::HashSet<_> = native.iter().flatten().cloned().collect(); + + let mut result = native.to_vec(); + + // Add api_native sources that aren't already in native + for stage in api_sources { + let new_sources: Vec<_> = stage + .iter() + .filter(|s| !native_flat.contains(*s)) + .cloned() + .collect(); + + if !new_sources.is_empty() { + // Add new sources as a separate stage + result.push(new_sources); + } + } + + result + } + /// Helper to create a CachingNativePriceEstimator with a specific cache. + /// + /// The estimator will use the provided cache for lookups and fetch prices + /// on-demand for cache misses. Background maintenance is handled by the + /// cache itself, not by this estimator. async fn create_caching_native_estimator( &mut self, sources: &[Vec], results_required: NonZeroUsize, weth: &WETH9::Instance, cache: NativePriceCache, - spawn_background_task: bool, ) -> Result> { let mut estimators = Vec::with_capacity(sources.len()); for stage in sources.iter() { @@ -450,37 +524,53 @@ impl<'a> PriceEstimatorFactory<'a> { .copied() .collect(); - let estimator = if spawn_background_task { - CachingNativePriceEstimator::new_with_maintenance( - Box::new(competition_estimator), - cache, - self.args.native_price_cache_refresh, - Some(self.args.native_price_cache_max_update_size), - self.args.native_price_prefetch_time, - self.args.native_price_cache_concurrent_requests, - approximation_tokens, - self.args.quote_timeout, - ) - } else { - CachingNativePriceEstimator::new_without_maintenance( - Box::new(competition_estimator), - cache, - self.args.native_price_cache_concurrent_requests, - approximation_tokens, - self.args.quote_timeout, - ) - }; + let estimator = CachingNativePriceEstimator::new( + Box::new(competition_estimator), + cache, + self.args.native_price_cache_concurrent_requests, + approximation_tokens, + ); Ok(Arc::new(estimator)) } + + /// Helper to create a non-caching native price estimator. + /// + /// This is used for the cache's background maintenance task, which needs + /// to fetch prices directly without going through the cache. + async fn create_non_caching_native_estimator( + &mut self, + sources: &[Vec], + results_required: NonZeroUsize, + weth: &WETH9::Instance, + ) -> Result> { + let mut estimators = Vec::with_capacity(sources.len()); + for stage in sources.iter() { + let mut stages = Vec::with_capacity(stage.len()); + for source in stage { + stages.push(self.create_native_estimator(source, weth).await?); + } + estimators.push(stages); + } + + let competition_estimator = + CompetitionEstimator::new(estimators, PriceRanking::MaxOutAmount) + .with_verification(self.args.quote_verification) + .with_early_return(results_required); + + Ok(Arc::new(competition_estimator)) + } } /// Result of creating native price estimators with shared cache. +/// +/// The shared cache runs its own background maintenance task using a combined +/// estimator that includes all sources from both `main` and `api` estimators. pub struct NativePriceEstimators { - /// Main estimator, which runs the background cache maintenance task. + /// Main estimator using the primary set of sources for on-demand fetching. pub main: Arc, - /// API estimator that shares the cache with the main estimator but doesn't - /// run background task and might use a different set of sources. + /// API estimator that shares the cache with main but uses a different + /// set of sources for on-demand fetching. pub api: Arc, } diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 1d84c9d92c..d659b276f9 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -39,6 +39,23 @@ impl Metrics { } } +/// Configuration for the background maintenance task that keeps the cache warm. +pub struct MaintenanceConfig { + /// The estimator used to fetch prices during background updates. + pub estimator: Arc, + /// How often to run the maintenance task. + pub update_interval: Duration, + /// Maximum number of prices to update per maintenance cycle. + /// None means unlimited. + pub update_size: Option, + /// How early before expiration to refresh prices. + pub prefetch_time: Duration, + /// Number of concurrent price fetch requests. + pub concurrent_requests: usize, + /// Timeout for individual price fetch requests. + pub quote_timeout: Duration, +} + /// A cache storage for native price estimates. /// /// Can be shared between multiple `CachingNativePriceEstimator` instances, @@ -52,6 +69,8 @@ pub struct NativePriceCache { struct CacheStorage { cache: Mutex>, max_age: Duration, + /// Tokens that should be prioritized during maintenance updates. + high_priority: Mutex>, } impl NativePriceCache { @@ -87,6 +106,7 @@ impl NativePriceCache { inner: Arc::new(CacheStorage { cache: Mutex::new(cache), max_age, + high_priority: Default::default(), }), } } @@ -187,6 +207,155 @@ impl NativePriceCache { }); outdated.into_iter().map(|(token, _)| token).collect() } + + /// Tokens with highest priority first, using the cache's internal high + /// priority list. + fn sorted_tokens_to_update_with_internal_priority( + &self, + max_age: Duration, + now: Instant, + ) -> Vec
{ + let high_priority = self.inner.high_priority.lock().unwrap().clone(); + self.sorted_tokens_to_update(max_age, now, &high_priority) + } + + /// Updates the set of high-priority tokens for maintenance updates. + /// High-priority tokens are refreshed before other tokens in the cache. + pub fn replace_high_priority(&self, tokens: IndexSet
) { + tracing::trace!(?tokens, "update high priority tokens in cache"); + *self.inner.high_priority.lock().unwrap() = tokens; + } + + /// Creates a new cache with background maintenance task. + /// + /// The maintenance task periodically refreshes cached prices before they + /// expire, using the provided estimator to fetch new prices. + pub fn new_with_maintenance( + max_age: Duration, + initial_prices: HashMap, + config: MaintenanceConfig, + ) -> Self { + let cache = Self::new(max_age, initial_prices); + cache.spawn_maintenance_task(config); + cache + } + + /// Spawns a background maintenance task for this cache. + fn spawn_maintenance_task(&self, config: MaintenanceConfig) { + let update_task = CacheUpdateTask { + cache: Arc::downgrade(&self.inner), + estimator: config.estimator, + update_interval: config.update_interval, + update_size: config.update_size, + prefetch_time: config.prefetch_time, + concurrent_requests: config.concurrent_requests, + quote_timeout: config.quote_timeout, + } + .run() + .instrument(tracing::info_span!("native_price_cache_maintenance")); + tokio::spawn(update_task); + } + + /// Estimates prices for the given tokens and updates the cache. + /// Used by the background maintenance task. + fn estimate_prices_and_update_cache<'a>( + &'a self, + tokens: &'a [Address], + estimator: &'a dyn NativePriceEstimating, + concurrent_requests: usize, + request_timeout: Duration, + ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { + let estimates = tokens.iter().map(move |token| async move { + let current_accumulative_errors_count = { + // check if the price is cached by now + let now = Instant::now(); + + match self.get_cached_price(*token, now, false) { + Some(cached) if cached.is_ready() => { + return (*token, cached.result); + } + Some(cached) => cached.accumulative_errors_count, + None => Default::default(), + } + }; + + let result = estimator + .estimate_native_price(*token, request_timeout) + .await; + + // update price in cache + if should_cache(&result) { + let now = Instant::now(); + self.insert( + *token, + CachedResult::new(result.clone(), now, now, current_accumulative_errors_count), + ); + }; + + (*token, result) + }); + futures::stream::iter(estimates) + .buffered(concurrent_requests) + .boxed() + } +} + +/// Background task that keeps the cache warm by periodically refreshing prices. +struct CacheUpdateTask { + cache: Weak, + estimator: Arc, + update_interval: Duration, + update_size: Option, + prefetch_time: Duration, + concurrent_requests: usize, + quote_timeout: Duration, +} + +impl CacheUpdateTask { + /// Single run of the background updating process. + async fn single_update(&self, cache: &NativePriceCache) { + let metrics = Metrics::get(); + metrics + .native_price_cache_size + .set(i64::try_from(cache.len()).unwrap_or(i64::MAX)); + + let max_age = cache.max_age().saturating_sub(self.prefetch_time); + let mut outdated_entries = + cache.sorted_tokens_to_update_with_internal_priority(max_age, Instant::now()); + + tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated prices to fetch"); + + metrics + .native_price_cache_outdated_entries + .set(i64::try_from(outdated_entries.len()).unwrap_or(i64::MAX)); + + outdated_entries.truncate(self.update_size.unwrap_or(usize::MAX)); + + if outdated_entries.is_empty() { + return; + } + + let mut stream = cache.estimate_prices_and_update_cache( + &outdated_entries, + self.estimator.as_ref(), + self.concurrent_requests, + self.quote_timeout, + ); + while stream.next().await.is_some() {} + metrics + .native_price_cache_background_updates + .inc_by(outdated_entries.len() as u64); + } + + /// Runs background updates until the cache is no longer alive. + async fn run(self) { + while let Some(inner) = self.cache.upgrade() { + let cache = NativePriceCache { inner }; + let now = Instant::now(); + self.single_update(&cache).await; + tokio::time::sleep(self.update_interval.saturating_sub(now.elapsed())).await; + } + } } /// Wrapper around `Box` which caches successful price @@ -200,7 +369,6 @@ pub struct CachingNativePriceEstimator(Arc); struct Inner { cache: NativePriceCache, - high_priority: Mutex>, estimator: Box, concurrent_requests: usize, // TODO remove when implementing a less hacky solution @@ -213,14 +381,6 @@ struct Inner { /// It's very important that the 2 tokens have the same number of decimals. /// After startup this is a read only value. approximation_tokens: HashMap, - quote_timeout: Duration, -} - -struct UpdateTask { - inner: Weak, - update_interval: Duration, - update_size: Option, - prefetch_time: Duration, } type CacheEntry = Result; @@ -314,13 +474,6 @@ impl Inner { .buffered(self.concurrent_requests) .boxed() } - - /// Tokens with highest priority first. - fn sorted_tokens_to_update(&self, max_age: Duration, now: Instant) -> Vec
{ - let high_priority = self.high_priority.lock().unwrap().clone(); - self.cache - .sorted_tokens_to_update(max_age, now, &high_priority) - } } fn should_cache(result: &Result) -> bool { @@ -340,47 +493,6 @@ fn should_cache(result: &Result) -> bool { } } -impl UpdateTask { - /// Single run of the background updating process. - async fn single_update(&self, inner: &Inner) { - let metrics = Metrics::get(); - metrics - .native_price_cache_size - .set(i64::try_from(inner.cache.len()).unwrap_or(i64::MAX)); - - let max_age = inner.cache.max_age().saturating_sub(self.prefetch_time); - let mut outdated_entries = inner.sorted_tokens_to_update(max_age, Instant::now()); - - tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated prices to fetch"); - - metrics - .native_price_cache_outdated_entries - .set(i64::try_from(outdated_entries.len()).unwrap_or(i64::MAX)); - - outdated_entries.truncate(self.update_size.unwrap_or(usize::MAX)); - - if outdated_entries.is_empty() { - return; - } - - let mut stream = - inner.estimate_prices_and_update_cache(&outdated_entries, inner.quote_timeout); - while stream.next().await.is_some() {} - metrics - .native_price_cache_background_updates - .inc_by(outdated_entries.len() as u64); - } - - /// Runs background updates until inner is no longer alive. - async fn run(self) { - while let Some(inner) = self.inner.upgrade() { - let now = Instant::now(); - self.single_update(&inner).await; - tokio::time::sleep(self.update_interval.saturating_sub(now.elapsed())).await; - } - } -} - impl CachingNativePriceEstimator { /// Returns a reference to the underlying shared cache. /// This can be used to share the cache with other estimator instances. @@ -388,63 +500,22 @@ impl CachingNativePriceEstimator { &self.0.cache } - /// Creates a new CachingNativePriceEstimator with a background maintenance - /// task. + /// Creates a new CachingNativePriceEstimator. /// - /// The maintenance task periodically refreshes cached prices before they - /// expire. Use this for the primary estimator in a shared-cache setup. - #[expect(clippy::too_many_arguments)] - pub fn new_with_maintenance( + /// The estimator will use the provided cache for lookups and will fetch + /// prices on-demand for cache misses. Background maintenance (keeping the + /// cache warm) is handled by the cache itself, not by this estimator. + pub fn new( estimator: Box, cache: NativePriceCache, - update_interval: Duration, - update_size: Option, - prefetch_time: Duration, concurrent_requests: usize, approximation_tokens: HashMap, - quote_timeout: Duration, - ) -> Self { - let inner = Arc::new(Inner { - estimator, - cache, - high_priority: Default::default(), - concurrent_requests, - approximation_tokens, - quote_timeout, - }); - - let update_task = UpdateTask { - inner: Arc::downgrade(&inner), - update_interval, - update_size, - prefetch_time, - } - .run() - .instrument(tracing::info_span!("caching_native_price_estimator")); - tokio::spawn(update_task); - - Self(inner) - } - - /// Creates a new CachingNativePriceEstimator without a background - /// maintenance task. - /// - /// Use this for secondary estimators that share a cache with a primary - /// estimator to avoid duplicate maintenance work. - pub fn new_without_maintenance( - estimator: Box, - cache: NativePriceCache, - concurrent_requests: usize, - approximation_tokens: HashMap, - quote_timeout: Duration, ) -> Self { Self(Arc::new(Inner { estimator, cache, - high_priority: Default::default(), concurrent_requests, approximation_tokens, - quote_timeout, })) } @@ -474,9 +545,10 @@ impl CachingNativePriceEstimator { results } + /// Updates the set of high-priority tokens for maintenance updates. + /// Forwards to the underlying cache. pub fn replace_high_priority(&self, tokens: IndexSet
) { - tracing::trace!(?tokens, "update high priority tokens"); - *self.0.high_priority.lock().unwrap() = tokens; + self.0.cache.replace_high_priority(tokens); } pub async fn estimate_native_prices_with_timeout<'a>( @@ -577,13 +649,8 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); let cache = NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS), prices); - let estimator = CachingNativePriceEstimator::new_without_maintenance( - Box::new(inner), - cache, - 1, - Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, - ); + let estimator = + CachingNativePriceEstimator::new(Box::new(inner), cache, 1, Default::default()); { // Check that `updated_at` timestamps are initialized with @@ -611,12 +678,11 @@ mod tests { .times(1) .returning(|_, _| async { Ok(1.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new_without_maintenance( + let estimator = CachingNativePriceEstimator::new( Box::new(inner), NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, ); for _ in 0..10 { @@ -646,7 +712,7 @@ mod tests { .withf(move |t, _| *t == token(200)) .returning(|_, _| async { Ok(200.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new_without_maintenance( + let estimator = CachingNativePriceEstimator::new( Box::new(inner), NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, @@ -655,7 +721,6 @@ mod tests { (Address::with_last_byte(1), Address::with_last_byte(100)), (Address::with_last_byte(2), Address::with_last_byte(200)), ]), - HEALTHY_PRICE_ESTIMATION_TIME, ); // no approximation token used for token 0 @@ -698,12 +763,11 @@ mod tests { .times(1) .returning(|_, _| async { Err(PriceEstimationError::NoLiquidity) }.boxed()); - let estimator = CachingNativePriceEstimator::new_without_maintenance( + let estimator = CachingNativePriceEstimator::new( Box::new(inner), NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, ); for _ in 0..10 { @@ -766,12 +830,11 @@ mod tests { async { Err(PriceEstimationError::EstimatorInternal(anyhow!("boom"))) }.boxed() }); - let estimator = CachingNativePriceEstimator::new_without_maintenance( + let estimator = CachingNativePriceEstimator::new( Box::new(inner), NativePriceCache::new(Duration::from_millis(100), Default::default()), 1, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, ); // First 3 calls: The cache is not used. Counter gets increased. @@ -835,12 +898,11 @@ mod tests { .times(10) .returning(|_, _| async { Err(PriceEstimationError::RateLimited) }.boxed()); - let estimator = CachingNativePriceEstimator::new_without_maintenance( + let estimator = CachingNativePriceEstimator::new( Box::new(inner), NativePriceCache::new(Duration::from_millis(30), Default::default()), 1, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, ); for _ in 0..10 { @@ -856,51 +918,50 @@ mod tests { #[tokio::test] async fn maintenance_can_limit_update_size_to_n() { - let mut inner = MockNativePriceEstimating::new(); - // first request from user - inner + // On-demand estimator for initial cache population + let mut on_demand = MockNativePriceEstimating::new(); + on_demand .expect_estimate_native_price() - .times(1) + .times(2) .returning(|passed_token, _| { - assert_eq!(passed_token, token(0)); - async { Ok(1.0) }.boxed() + let price = if passed_token == token(0) { 1.0 } else { 2.0 }; + async move { Ok(price) }.boxed() }); - // second request from user - inner + // After maintenance skips token(0), user request triggers on-demand fetch + on_demand .expect_estimate_native_price() .times(1) .returning(|passed_token, _| { - assert_eq!(passed_token, token(1)); - async { Ok(2.0) }.boxed() + assert_eq!(passed_token, token(0)); + async { Ok(3.0) }.boxed() }); - // maintenance task updates n=1 outdated prices - inner + + // Maintenance estimator updates n=1 outdated prices (most recently requested) + let mut maintenance = MockNativePriceEstimating::new(); + maintenance .expect_estimate_native_price() .times(1) .returning(|passed_token, _| { assert_eq!(passed_token, token(1)); async { Ok(4.0) }.boxed() }); - // user requested something which has been skipped by the maintenance task - inner - .expect_estimate_native_price() - .times(1) - .returning(|passed_token, _| { - assert_eq!(passed_token, token(0)); - async { Ok(3.0) }.boxed() - }); - let estimator = CachingNativePriceEstimator::new_with_maintenance( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), - Duration::from_millis(50), - Some(1), - Default::default(), - 1, + let cache = NativePriceCache::new_with_maintenance( + Duration::from_millis(30), Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, + MaintenanceConfig { + estimator: Arc::new(maintenance), + update_interval: Duration::from_millis(50), + update_size: Some(1), + prefetch_time: Default::default(), + concurrent_requests: 1, + quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, + }, ); + let estimator = + CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + // fill cache with 2 different queries let result = estimator .estimate_native_price(token(0), HEALTHY_PRICE_ESTIMATION_TIME) @@ -914,11 +975,13 @@ mod tests { // wait for maintenance cycle tokio::time::sleep(Duration::from_millis(60)).await; + // token(0) was not updated by maintenance (n=1 limit), triggers on-demand let result = estimator .estimate_native_price(token(0), HEALTHY_PRICE_ESTIMATION_TIME) .await; assert_eq!(result.as_ref().unwrap().to_i64().unwrap(), 3); + // token(1) was updated by maintenance let result = estimator .estimate_native_price(token(1), HEALTHY_PRICE_ESTIMATION_TIME) .await; @@ -927,28 +990,36 @@ mod tests { #[tokio::test] async fn maintenance_can_update_all_old_queries() { - let mut inner = MockNativePriceEstimating::new(); - inner + // On-demand estimator for initial cache population + let mut on_demand = MockNativePriceEstimating::new(); + on_demand .expect_estimate_native_price() .times(10) .returning(move |_, _| async { Ok(1.0) }.boxed()); - // background task updates all outdated prices - inner + + // Maintenance estimator updates all outdated prices + let mut maintenance = MockNativePriceEstimating::new(); + maintenance .expect_estimate_native_price() .times(10) .returning(move |_, _| async { Ok(2.0) }.boxed()); - let estimator = CachingNativePriceEstimator::new_with_maintenance( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), - Duration::from_millis(50), - None, + let cache = NativePriceCache::new_with_maintenance( + Duration::from_millis(30), Default::default(), - 1, - Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, + MaintenanceConfig { + estimator: Arc::new(maintenance), + update_interval: Duration::from_millis(50), + update_size: None, + prefetch_time: Default::default(), + concurrent_requests: 1, + quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, + }, ); + let estimator = + CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + let tokens: Vec<_> = (0..10).map(Address::with_last_byte).collect(); for token in &tokens { let price = estimator @@ -974,13 +1045,18 @@ mod tests { async fn maintenance_can_update_concurrently() { const WAIT_TIME_MS: u64 = 100; const BATCH_SIZE: usize = 100; - let mut inner = MockNativePriceEstimating::new(); - inner + + // On-demand estimator for initial cache population + let mut on_demand = MockNativePriceEstimating::new(); + on_demand .expect_estimate_native_price() .times(BATCH_SIZE) .returning(|_, _| async { Ok(1.0) }.boxed()); - // background task updates all outdated prices - inner + + // Maintenance estimator updates all outdated prices (with delay to test + // concurrency) + let mut maintenance = MockNativePriceEstimating::new(); + maintenance .expect_estimate_native_price() .times(BATCH_SIZE) .returning(move |_, _| { @@ -991,17 +1067,22 @@ mod tests { .boxed() }); - let estimator = CachingNativePriceEstimator::new_with_maintenance( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), - Duration::from_millis(50), - None, - Default::default(), - BATCH_SIZE, + let cache = NativePriceCache::new_with_maintenance( + Duration::from_millis(30), Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, + MaintenanceConfig { + estimator: Arc::new(maintenance), + update_interval: Duration::from_millis(50), + update_size: None, + prefetch_time: Default::default(), + concurrent_requests: BATCH_SIZE, + quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, + }, ); + let estimator = + CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + let tokens: Vec<_> = (0..BATCH_SIZE as u64).map(token).collect(); for token in &tokens { let price = estimator @@ -1037,24 +1118,17 @@ mod tests { cache.insert(t0, CachedResult::new(Ok(0.), now, now, Default::default())); cache.insert(t1, CachedResult::new(Ok(0.), now, now, Default::default())); - let inner = Inner { - cache, - high_priority: Default::default(), - estimator: Box::new(MockNativePriceEstimating::new()), - concurrent_requests: 1, - approximation_tokens: Default::default(), - quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, - }; - let now = now + Duration::from_secs(1); - *inner.high_priority.lock().unwrap() = std::iter::once(t0).collect(); - let tokens = inner.sorted_tokens_to_update(Duration::from_secs(0), now); + cache.replace_high_priority(std::iter::once(t0).collect()); + let tokens = + cache.sorted_tokens_to_update_with_internal_priority(Duration::from_secs(0), now); assert_eq!(tokens[0], t0); assert_eq!(tokens[1], t1); - *inner.high_priority.lock().unwrap() = std::iter::once(t1).collect(); - let tokens = inner.sorted_tokens_to_update(Duration::from_secs(0), now); + cache.replace_high_priority(std::iter::once(t1).collect()); + let tokens = + cache.sorted_tokens_to_update_with_internal_priority(Duration::from_secs(0), now); assert_eq!(tokens[0], t1); assert_eq!(tokens[1], t0); } From f38208f9c8200364a98a1286cbb29f29822e17a4 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 14 Jan 2026 13:52:38 +0000 Subject: [PATCH 05/42] Refactor --- crates/autopilot/src/run.rs | 6 +- crates/autopilot/src/solvable_orders.rs | 47 ++- crates/orderbook/src/run.rs | 6 +- crates/shared/src/price_estimation/factory.rs | 222 ++++------- .../price_estimation/native_price_cache.rs | 349 ++++++++++++------ 5 files changed, 346 insertions(+), 284 deletions(-) diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index c02d07fef1..6971f7c601 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -392,7 +392,7 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { let initial_prices = db_write.fetch_latest_prices().await.unwrap(); let native_price_estimators = price_estimator_factory - .native_price_estimators_with_shared_cache( + .native_price_estimators( args.native_price_estimators.as_slice(), args.api_native_price_estimators .as_ref() @@ -405,8 +405,8 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { .await .unwrap(); - let native_price_estimator = native_price_estimators.main; - let api_native_price_estimator = native_price_estimators.api; + let native_price_estimator = native_price_estimators.primary; + let api_native_price_estimator = native_price_estimators.secondary; let price_estimator = price_estimator_factory .price_estimator( diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index ca9c006406..4189867aa9 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -913,10 +913,11 @@ mod tests { HEALTHY_PRICE_ESTIMATION_TIME, PriceEstimationError, native::MockNativePriceEstimating, - native_price_cache::NativePriceCache, + native_price_cache::{EstimatorSource, NativePriceCache}, }, signature_validator::{MockSignatureValidating, SignatureValidationError}, }, + std::sync::Arc, }; #[tokio::test] @@ -956,12 +957,12 @@ mod tests { .withf(move |token, _| *token == token3) .returning(|_, _| async { Ok(0.25) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( - Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10), Default::default()), + let native_price_estimator = CachingNativePriceEstimator::new( + Arc::new(native_price_estimator), + NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, + EstimatorSource::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1044,15 +1045,31 @@ mod tests { .withf(move |token, _| *token == token5) .returning(|_, _| async { Ok(5.) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new_with_maintenance( - Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10), Default::default()), - Duration::from_millis(1), // Short interval to trigger background fetch quickly - None, + let maintenance_estimator: Arc< + dyn shared::price_estimation::native::NativePriceEstimating, + > = Arc::new(native_price_estimator); + let cache = NativePriceCache::new_with_maintenance( + Duration::from_secs(10), Default::default(), + shared::price_estimation::native_price_cache::MaintenanceConfig { + estimators: std::collections::HashMap::from([( + EstimatorSource::Primary, + maintenance_estimator.clone(), + )]), + update_interval: Duration::from_millis(1), /* Short interval to trigger + * background fetch quickly */ + update_size: None, + prefetch_time: Default::default(), + concurrent_requests: 1, + quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, + }, + ); + let native_price_estimator = CachingNativePriceEstimator::new( + maintenance_estimator, + cache, 1, Default::default(), - HEALTHY_PRICE_ESTIMATION_TIME, + EstimatorSource::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1141,13 +1158,13 @@ mod tests { .withf(move |token, _| *token == token_approx2) .returning(|_, _| async { Ok(50.) }.boxed()); - let native_price_estimator = CachingNativePriceEstimator::new_without_maintenance( - Box::new(native_price_estimator), - NativePriceCache::new(Duration::from_secs(10), Default::default()), + let native_price_estimator = CachingNativePriceEstimator::new( + Arc::new(native_price_estimator), + NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), - HEALTHY_PRICE_ESTIMATION_TIME, + EstimatorSource::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index cd9b7163c7..0c2ab973cf 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -321,14 +321,16 @@ pub async fn run(args: Arguments) { let initial_prices = postgres_write.fetch_latest_prices().await.unwrap(); let native_price_estimator = price_estimator_factory - .native_price_estimator( + .native_price_estimators( args.native_price_estimators.as_slice(), + None, args.fast_price_estimation_results_required, native_token.clone(), initial_prices, ) .await - .unwrap(); + .unwrap() + .primary; let price_estimator = price_estimator_factory .price_estimator( diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 53a600b4e0..0a4613ea64 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -7,7 +7,12 @@ use { external::ExternalPriceEstimator, instrumented::InstrumentedPriceEstimator, native::{self, NativePriceEstimator}, - native_price_cache::{CachingNativePriceEstimator, MaintenanceConfig, NativePriceCache}, + native_price_cache::{ + CachingNativePriceEstimator, + EstimatorSource, + MaintenanceConfig, + NativePriceCache, + }, sanitized::SanitizedPriceEstimator, trade_verifier::{TradeVerifier, TradeVerifying}, }, @@ -361,57 +366,24 @@ impl<'a> PriceEstimatorFactory<'a> { )) } - pub async fn native_price_estimator( - &mut self, - native: &[Vec], - results_required: NonZeroUsize, - weth: WETH9::Instance, - initial_prices: HashMap, - ) -> Result> { - // Create a non-caching estimator for the cache's maintenance task - let maintenance_estimator = self - .create_non_caching_native_estimator(native, results_required, &weth) - .await?; - - // Create cache with background maintenance - let cache = NativePriceCache::new_with_maintenance( - self.args.native_price_cache_max_age, - initial_prices, - MaintenanceConfig { - estimator: maintenance_estimator, - update_interval: self.args.native_price_cache_refresh, - update_size: Some(self.args.native_price_cache_max_update_size), - prefetch_time: self.args.native_price_prefetch_time, - concurrent_requests: self.args.native_price_cache_concurrent_requests, - quote_timeout: self.args.quote_timeout, - }, - ); - - // Create the caching estimator for on-demand price fetching - self.create_caching_native_estimator(native, results_required, &weth, cache) - .await - } - - /// Creates a main native price estimator and an optional API-specific - /// estimator that share the same cache. - /// - /// The cache runs a background maintenance task using a combined estimator - /// that includes all sources from both `native` and `api_native` - /// (deduplicated). This ensures all tokens can be refreshed regardless - /// of which estimator originally requested them. + /// Creates native price estimators with a shared cache and background + /// maintenance task. /// - /// The main estimator uses `native` sources for on-demand fetching. - /// The API estimator (if `api_native` is provided) uses its own set of - /// sources for on-demand fetching, but shares the cache. + /// Each cached entry tracks which estimator (Primary or Secondary) + /// originally fetched it. The cache's background maintenance task uses + /// this information to dispatch updates to the appropriate estimator, + /// ensuring each token is refreshed using the same source that + /// originally fetched it. /// - /// If `api_native` is None, the API will use the same estimator as main. + /// The secondary estimators are optional - if not provided, only the + /// primary estimator is used. /// /// The `initial_prices` are used to seed the cache before the estimators /// start. - pub async fn native_price_estimators_with_shared_cache( + pub async fn native_price_estimators( &mut self, - native: &[Vec], - api_native: Option<&[Vec]>, + primary_estimators: &[Vec], + secondary_estimators: Option<&[Vec]>, results_required: NonZeroUsize, weth: WETH9::Instance, initial_prices: HashMap, @@ -421,20 +393,34 @@ impl<'a> PriceEstimatorFactory<'a> { "price cache prefetch time needs to be less than price cache max age" ); - // Merge native and api_native sources (deduplicated) for the maintenance task - let combined_sources = Self::merge_sources(native, api_native); + // Create non-caching estimators for main and api + let primary_estimator: Arc = Arc::new( + self.create_competition_native_estimator(primary_estimators, results_required, &weth) + .await?, + ); + + let secondary_estimator: Arc = match secondary_estimators { + Some(sources) => Arc::new( + self.create_competition_native_estimator(sources, results_required, &weth) + .await?, + ), + None => primary_estimator.clone(), + }; - // Create a non-caching estimator for the cache's maintenance task - let maintenance_estimator = self - .create_non_caching_native_estimator(&combined_sources, results_required, &weth) - .await?; + // Build estimators map for maintenance - each source type has its own + // estimator so maintenance can dispatch to the correct one + let mut estimators = HashMap::new(); + estimators.insert(EstimatorSource::Primary, primary_estimator.clone()); + if secondary_estimators.is_some() { + estimators.insert(EstimatorSource::Secondary, secondary_estimator.clone()); + } - // Create cache with background maintenance using the combined estimator + // Create cache with background maintenance let cache = NativePriceCache::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, MaintenanceConfig { - estimator: maintenance_estimator, + estimators, update_interval: self.args.native_price_cache_refresh, update_size: Some(self.args.native_price_cache_max_update_size), prefetch_time: self.args.native_price_prefetch_time, @@ -443,80 +429,28 @@ impl<'a> PriceEstimatorFactory<'a> { }, ); - // Create main estimator for on-demand fetching - let main = self - .create_caching_native_estimator(native, results_required, &weth, cache.clone()) - .await?; - - // Create API estimator (or reuse main) - let api = match api_native { - Some(sources) => { - self.create_caching_native_estimator(sources, results_required, &weth, cache) - .await? - } - None => main.clone(), + // Wrap estimators with caching layer for on-demand price fetching + let primary = + self.wrap_with_cache(primary_estimator, cache.clone(), EstimatorSource::Primary); + let secondary = if secondary_estimators.is_some() { + self.wrap_with_cache(secondary_estimator, cache, EstimatorSource::Secondary) + } else { + primary.clone() }; - Ok(NativePriceEstimators { main, api }) + Ok(NativePriceEstimators { primary, secondary }) } - /// Merges native and api_native sources, deduplicating by source type. - fn merge_sources( - native: &[Vec], - api_native: Option<&[Vec]>, - ) -> Vec> { - let Some(api_sources) = api_native else { - return native.to_vec(); - }; - - // Collect all unique sources from api_native that aren't in native - let native_flat: std::collections::HashSet<_> = native.iter().flatten().cloned().collect(); - - let mut result = native.to_vec(); - - // Add api_native sources that aren't already in native - for stage in api_sources { - let new_sources: Vec<_> = stage - .iter() - .filter(|s| !native_flat.contains(*s)) - .cloned() - .collect(); - - if !new_sources.is_empty() { - // Add new sources as a separate stage - result.push(new_sources); - } - } - - result - } - - /// Helper to create a CachingNativePriceEstimator with a specific cache. + /// Wraps a native price estimator with caching functionality. /// - /// The estimator will use the provided cache for lookups and fetch prices - /// on-demand for cache misses. Background maintenance is handled by the - /// cache itself, not by this estimator. - async fn create_caching_native_estimator( - &mut self, - sources: &[Vec], - results_required: NonZeroUsize, - weth: &WETH9::Instance, + /// The `source` parameter identifies this estimator type so cached entries + /// are tagged appropriately for maintenance dispatch. + fn wrap_with_cache( + &self, + estimator: Arc, cache: NativePriceCache, - ) -> Result> { - let mut estimators = Vec::with_capacity(sources.len()); - for stage in sources.iter() { - let mut stages = Vec::with_capacity(stage.len()); - for source in stage { - stages.push(self.create_native_estimator(source, weth).await?); - } - estimators.push(stages); - } - - let competition_estimator = - CompetitionEstimator::new(estimators, PriceRanking::MaxOutAmount) - .with_verification(self.args.quote_verification) - .with_early_return(results_required); - + source: EstimatorSource, + ) -> Arc { let approximation_tokens = self .args .native_price_approximation_tokens @@ -524,26 +458,22 @@ impl<'a> PriceEstimatorFactory<'a> { .copied() .collect(); - let estimator = CachingNativePriceEstimator::new( - Box::new(competition_estimator), + Arc::new(CachingNativePriceEstimator::new( + estimator, cache, self.args.native_price_cache_concurrent_requests, approximation_tokens, - ); - - Ok(Arc::new(estimator)) + source, + )) } - /// Helper to create a non-caching native price estimator. - /// - /// This is used for the cache's background maintenance task, which needs - /// to fetch prices directly without going through the cache. - async fn create_non_caching_native_estimator( + /// Helper to create a CompetitionEstimator for native price estimation. + async fn create_competition_native_estimator( &mut self, sources: &[Vec], results_required: NonZeroUsize, weth: &WETH9::Instance, - ) -> Result> { + ) -> Result>> { let mut estimators = Vec::with_capacity(sources.len()); for stage in sources.iter() { let mut stages = Vec::with_capacity(stage.len()); @@ -553,25 +483,29 @@ impl<'a> PriceEstimatorFactory<'a> { estimators.push(stages); } - let competition_estimator = + Ok( CompetitionEstimator::new(estimators, PriceRanking::MaxOutAmount) .with_verification(self.args.quote_verification) - .with_early_return(results_required); - - Ok(Arc::new(competition_estimator)) + .with_early_return(results_required), + ) } } /// Result of creating native price estimators with shared cache. /// -/// The shared cache runs its own background maintenance task using a combined -/// estimator that includes all sources from both `main` and `api` estimators. +/// The shared cache tracks which estimator type originally fetched each entry. +/// The background maintenance task uses this information to dispatch updates +/// to the appropriate estimator. pub struct NativePriceEstimators { - /// Main estimator using the primary set of sources for on-demand fetching. - pub main: Arc, - /// API estimator that shares the cache with main but uses a different - /// set of sources for on-demand fetching. - pub api: Arc, + /// Primary estimator using the main set of sources for on-demand fetching. + /// Cached entries fetched via this estimator are tagged with + /// `EstimatorSource::Primary`. + pub primary: Arc, + /// Secondary estimator that shares the cache with primary but uses a + /// different set of sources for on-demand fetching. Cached entries + /// fetched via this estimator are tagged with + /// `EstimatorSource::Secondary`. + pub secondary: Arc, } /// Trait for modelling the initialization of a Price estimator and its verified diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index d659b276f9..df8de0e186 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -20,6 +20,18 @@ use { tracing::{Instrument, instrument}, }; +/// Identifies which estimator type fetched a cached entry. +/// Used by maintenance to dispatch to the correct estimator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum EstimatorSource { + /// Primary estimator - the main source for price estimates. + #[default] + Primary, + /// Secondary estimator - supplementary source that may have different + /// price sources. + Secondary, +} + #[derive(prometheus_metric_storage::MetricStorage)] struct Metrics { /// native price cache hits misses @@ -41,8 +53,10 @@ impl Metrics { /// Configuration for the background maintenance task that keeps the cache warm. pub struct MaintenanceConfig { - /// The estimator used to fetch prices during background updates. - pub estimator: Arc, + /// Map of estimators by source type for maintenance. + /// Maintenance dispatches to the appropriate estimator based on + /// which source originally fetched each cached entry. + pub estimators: HashMap>, /// How often to run the maintenance task. pub update_interval: Duration, /// Maximum number of prices to update per maintenance cycle. @@ -77,7 +91,7 @@ impl NativePriceCache { /// Creates a new cache with the given max age for entries and initial /// prices. Entries are initialized with random ages to avoid expiration /// spikes. - pub fn new(max_age: Duration, initial_prices: HashMap) -> Self { + fn new(max_age: Duration, initial_prices: HashMap) -> Self { let mut rng = rand::thread_rng(); let now = std::time::Instant::now(); @@ -97,6 +111,7 @@ impl NativePriceCache { updated_at, now, Default::default(), + EstimatorSource::default(), ), )) }) @@ -111,6 +126,32 @@ impl NativePriceCache { } } + /// Creates a new cache with background maintenance task. + /// + /// The maintenance task periodically refreshes cached prices before they + /// expire, using the provided estimator to fetch new prices. + pub fn new_with_maintenance( + max_age: Duration, + initial_prices: HashMap, + config: MaintenanceConfig, + ) -> Self { + let cache = Self::new(max_age, initial_prices); + cache.spawn_maintenance_task(config); + cache + } + + /// Creates a new cache without background maintenance. + /// + /// This is only available for testing purposes. Production code should use + /// `new_with_maintenance` instead. + #[cfg(any(test, feature = "test-util"))] + pub fn new_without_maintenance( + max_age: Duration, + initial_prices: HashMap, + ) -> Self { + Self::new(max_age, initial_prices) + } + /// Returns the max age configuration for this cache. pub fn max_age(&self) -> Duration { self.inner.max_age @@ -128,11 +169,13 @@ impl NativePriceCache { /// Get a cached price, optionally creating a placeholder entry for missing /// tokens. Returns None if the price is not cached or is expired. + /// If `create_missing_entry` is Some, creates an outdated entry with the + /// given source type so maintenance will fetch it. fn get_cached_price( &self, token: Address, now: Instant, - create_missing_entry: bool, + create_missing_entry: Option, ) -> Option { let mut cache = self.inner.cache.lock().unwrap(); match cache.entry(token) { @@ -144,7 +187,7 @@ impl NativePriceCache { is_recent.then_some(entry.clone()) } Entry::Vacant(entry) => { - if create_missing_entry { + if let Some(source) = create_missing_entry { // Create an outdated cache entry so the background task keeping the cache warm // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. @@ -156,6 +199,7 @@ impl NativePriceCache { outdated_timestamp, now, Default::default(), + source, )); } None @@ -169,7 +213,7 @@ impl NativePriceCache { &self, token: Address, now: Instant, - create_missing_entry: bool, + create_missing_entry: Option, ) -> Option { self.get_cached_price(token, now, create_missing_entry) .filter(|cached| cached.is_ready()) @@ -180,14 +224,13 @@ impl NativePriceCache { self.inner.cache.lock().unwrap().insert(token, result); } - /// Get tokens that need updating, sorted by priority. - /// High priority tokens come first, then by most recent request time. - fn sorted_tokens_to_update( + /// Get tokens that need updating with their sources, sorted by priority. + fn sorted_tokens_to_update_with_sources( &self, max_age: Duration, now: Instant, high_priority: &IndexSet
, - ) -> Vec
{ + ) -> Vec<(Address, EstimatorSource)> { let mut outdated: Vec<_> = self .inner .cache @@ -195,7 +238,7 @@ impl NativePriceCache { .unwrap() .iter() .filter(|(_, cached)| now.saturating_duration_since(cached.updated_at) > max_age) - .map(|(token, cached)| (*token, cached.requested_at)) + .map(|(token, cached)| (*token, cached.requested_at, cached.source)) .collect(); let index = |token: &Address| high_priority.get_index_of(token).unwrap_or(usize::MAX); @@ -205,18 +248,10 @@ impl NativePriceCache { std::cmp::Reverse(entry.1), // important items have recent (i.e. "big") timestamp ) }); - outdated.into_iter().map(|(token, _)| token).collect() - } - - /// Tokens with highest priority first, using the cache's internal high - /// priority list. - fn sorted_tokens_to_update_with_internal_priority( - &self, - max_age: Duration, - now: Instant, - ) -> Vec
{ - let high_priority = self.inner.high_priority.lock().unwrap().clone(); - self.sorted_tokens_to_update(max_age, now, &high_priority) + outdated + .into_iter() + .map(|(token, _, source)| (token, source)) + .collect() } /// Updates the set of high-priority tokens for maintenance updates. @@ -226,25 +261,11 @@ impl NativePriceCache { *self.inner.high_priority.lock().unwrap() = tokens; } - /// Creates a new cache with background maintenance task. - /// - /// The maintenance task periodically refreshes cached prices before they - /// expire, using the provided estimator to fetch new prices. - pub fn new_with_maintenance( - max_age: Duration, - initial_prices: HashMap, - config: MaintenanceConfig, - ) -> Self { - let cache = Self::new(max_age, initial_prices); - cache.spawn_maintenance_task(config); - cache - } - /// Spawns a background maintenance task for this cache. fn spawn_maintenance_task(&self, config: MaintenanceConfig) { let update_task = CacheUpdateTask { cache: Arc::downgrade(&self.inner), - estimator: config.estimator, + estimators: config.estimators, update_interval: config.update_interval, update_size: config.update_size, prefetch_time: config.prefetch_time, @@ -257,42 +278,53 @@ impl NativePriceCache { } /// Estimates prices for the given tokens and updates the cache. - /// Used by the background maintenance task. + /// Used by the background maintenance task. Each token is processed using + /// the estimator corresponding to its source. fn estimate_prices_and_update_cache<'a>( &'a self, - tokens: &'a [Address], - estimator: &'a dyn NativePriceEstimating, + tokens: &'a [(Address, EstimatorSource)], + estimators: &'a HashMap>, concurrent_requests: usize, request_timeout: Duration, ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { - let estimates = tokens.iter().map(move |token| async move { - let current_accumulative_errors_count = { - // check if the price is cached by now - let now = Instant::now(); - - match self.get_cached_price(*token, now, false) { - Some(cached) if cached.is_ready() => { - return (*token, cached.result); + let estimates = tokens.iter().filter_map(move |(token, source)| { + let source = *source; + let estimator = estimators.get(&source)?.clone(); + Some(async move { + let current_accumulative_errors_count = { + // check if the price is cached by now + let now = Instant::now(); + + match self.get_cached_price(*token, now, None) { + Some(cached) if cached.is_ready() => { + return (*token, cached.result); + } + Some(cached) => cached.accumulative_errors_count, + None => Default::default(), } - Some(cached) => cached.accumulative_errors_count, - None => Default::default(), - } - }; - - let result = estimator - .estimate_native_price(*token, request_timeout) - .await; - - // update price in cache - if should_cache(&result) { - let now = Instant::now(); - self.insert( - *token, - CachedResult::new(result.clone(), now, now, current_accumulative_errors_count), - ); - }; - - (*token, result) + }; + + let result = estimator + .estimate_native_price(*token, request_timeout) + .await; + + // update price in cache + if should_cache(&result) { + let now = Instant::now(); + self.insert( + *token, + CachedResult::new( + result.clone(), + now, + now, + current_accumulative_errors_count, + source, + ), + ); + }; + + (*token, result) + }) }); futures::stream::iter(estimates) .buffered(concurrent_requests) @@ -303,7 +335,9 @@ impl NativePriceCache { /// Background task that keeps the cache warm by periodically refreshing prices. struct CacheUpdateTask { cache: Weak, - estimator: Arc, + /// Map of estimators by source type. Maintenance dispatches to the + /// appropriate estimator based on which source fetched each entry. + estimators: HashMap>, update_interval: Duration, update_size: Option, prefetch_time: Duration, @@ -320,8 +354,9 @@ impl CacheUpdateTask { .set(i64::try_from(cache.len()).unwrap_or(i64::MAX)); let max_age = cache.max_age().saturating_sub(self.prefetch_time); + let high_priority = cache.inner.high_priority.lock().unwrap().clone(); let mut outdated_entries = - cache.sorted_tokens_to_update_with_internal_priority(max_age, Instant::now()); + cache.sorted_tokens_to_update_with_sources(max_age, Instant::now(), &high_priority); tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated prices to fetch"); @@ -337,14 +372,19 @@ impl CacheUpdateTask { let mut stream = cache.estimate_prices_and_update_cache( &outdated_entries, - self.estimator.as_ref(), + &self.estimators, self.concurrent_requests, self.quote_timeout, ); - while stream.next().await.is_some() {} + + let mut updates_count = 0u64; + while stream.next().await.is_some() { + updates_count += 1; + } + metrics .native_price_cache_background_updates - .inc_by(outdated_entries.len() as u64); + .inc_by(updates_count); } /// Runs background updates until the cache is no longer alive. @@ -358,8 +398,9 @@ impl CacheUpdateTask { } } -/// Wrapper around `Box` which caches successful price -/// estimates for some time and supports updating the cache in the background. +/// Wrapper around `Arc` which caches successful +/// price estimates for some time and supports updating the cache in the +/// background. /// /// The size of the underlying cache is unbounded. /// @@ -369,7 +410,7 @@ pub struct CachingNativePriceEstimator(Arc); struct Inner { cache: NativePriceCache, - estimator: Box, + estimator: Arc, concurrent_requests: usize, // TODO remove when implementing a less hacky solution /// Maps a requested token to an approximating token. If the system @@ -381,6 +422,9 @@ struct Inner { /// It's very important that the 2 tokens have the same number of decimals. /// After startup this is a read only value. approximation_tokens: HashMap, + /// Identifies which estimator type this is, used to track which + /// estimator fetched each cached entry for proper maintenance. + source: EstimatorSource, } type CacheEntry = Result; @@ -391,6 +435,8 @@ struct CachedResult { updated_at: Instant, requested_at: Instant, accumulative_errors_count: u32, + /// Which estimator type fetched this entry. + source: EstimatorSource, } /// Defines how many consecutive errors are allowed before the cache starts @@ -404,6 +450,7 @@ impl CachedResult { updated_at: Instant, requested_at: Instant, current_accumulative_errors_count: u32, + source: EstimatorSource, ) -> Self { let estimator_internal_errors_count = matches!(result, Err(PriceEstimationError::EstimatorInternal(_))) @@ -415,6 +462,7 @@ impl CachedResult { updated_at, requested_at, accumulative_errors_count: estimator_internal_errors_count, + source, } } @@ -443,7 +491,7 @@ impl Inner { // check if the price is cached by now let now = Instant::now(); - match self.cache.get_cached_price(*token, now, false) { + match self.cache.get_cached_price(*token, now, None) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -464,7 +512,13 @@ impl Inner { let now = Instant::now(); self.cache.insert( *token, - CachedResult::new(result.clone(), now, now, current_accumulative_errors_count), + CachedResult::new( + result.clone(), + now, + now, + current_accumulative_errors_count, + self.source, + ), ); }; @@ -505,17 +559,23 @@ impl CachingNativePriceEstimator { /// The estimator will use the provided cache for lookups and will fetch /// prices on-demand for cache misses. Background maintenance (keeping the /// cache warm) is handled by the cache itself, not by this estimator. + /// + /// The `source` parameter identifies which estimator type this is, so that + /// the maintenance task knows which estimator to use when refreshing + /// entries fetched by this estimator. pub fn new( - estimator: Box, + estimator: Arc, cache: NativePriceCache, concurrent_requests: usize, approximation_tokens: HashMap, + source: EstimatorSource, ) -> Self { Self(Arc::new(Inner { estimator, cache, concurrent_requests, approximation_tokens, + source, })) } @@ -529,10 +589,12 @@ impl CachingNativePriceEstimator { let now = Instant::now(); let mut results = HashMap::default(); for token in tokens { - let cached = self - .0 - .cache - .get_ready_to_use_cached_price(*token, now, true); + // Pass our source so that if a missing entry is created, it's tagged + // with our source for proper maintenance later. + let cached = + self.0 + .cache + .get_ready_to_use_cached_price(*token, now, Some(self.0.source)); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -593,10 +655,7 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = self - .0 - .cache - .get_ready_to_use_cached_price(token, now, false); + let cached = self.0.cache.get_ready_to_use_cached_price(token, now, None); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -648,9 +707,15 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); - let cache = NativePriceCache::new(Duration::from_secs(MAX_AGE_SECS), prices); - let estimator = - CachingNativePriceEstimator::new(Box::new(inner), cache, 1, Default::default()); + let cache = + NativePriceCache::new_without_maintenance(Duration::from_secs(MAX_AGE_SECS), prices); + let estimator = CachingNativePriceEstimator::new( + Arc::new(inner), + cache, + 1, + Default::default(), + Default::default(), + ); { // Check that `updated_at` timestamps are initialized with @@ -679,10 +744,14 @@ mod tests { .returning(|_, _| async { Ok(1.0) }.boxed()); let estimator = CachingNativePriceEstimator::new( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), + Arc::new(inner), + NativePriceCache::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + ), 1, Default::default(), + Default::default(), ); for _ in 0..10 { @@ -713,14 +782,18 @@ mod tests { .returning(|_, _| async { Ok(200.0) }.boxed()); let estimator = CachingNativePriceEstimator::new( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), + Arc::new(inner), + NativePriceCache::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + ), 1, // set token approximations for tokens 1 and 2 HashMap::from([ (Address::with_last_byte(1), Address::with_last_byte(100)), (Address::with_last_byte(2), Address::with_last_byte(200)), ]), + Default::default(), ); // no approximation token used for token 0 @@ -764,10 +837,14 @@ mod tests { .returning(|_, _| async { Err(PriceEstimationError::NoLiquidity) }.boxed()); let estimator = CachingNativePriceEstimator::new( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), + Arc::new(inner), + NativePriceCache::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + ), 1, Default::default(), + Default::default(), ); for _ in 0..10 { @@ -831,10 +908,14 @@ mod tests { }); let estimator = CachingNativePriceEstimator::new( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(100), Default::default()), + Arc::new(inner), + NativePriceCache::new_without_maintenance( + Duration::from_millis(100), + Default::default(), + ), 1, Default::default(), + Default::default(), ); // First 3 calls: The cache is not used. Counter gets increased. @@ -899,10 +980,14 @@ mod tests { .returning(|_, _| async { Err(PriceEstimationError::RateLimited) }.boxed()); let estimator = CachingNativePriceEstimator::new( - Box::new(inner), - NativePriceCache::new(Duration::from_millis(30), Default::default()), + Arc::new(inner), + NativePriceCache::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + ), 1, Default::default(), + Default::default(), ); for _ in 0..10 { @@ -950,7 +1035,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimator: Arc::new(maintenance), + estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), update_interval: Duration::from_millis(50), update_size: Some(1), prefetch_time: Default::default(), @@ -959,8 +1044,13 @@ mod tests { }, ); - let estimator = - CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + let estimator = CachingNativePriceEstimator::new( + Arc::new(on_demand), + cache, + 1, + Default::default(), + Default::default(), + ); // fill cache with 2 different queries let result = estimator @@ -1008,7 +1098,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimator: Arc::new(maintenance), + estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), update_interval: Duration::from_millis(50), update_size: None, prefetch_time: Default::default(), @@ -1017,8 +1107,13 @@ mod tests { }, ); - let estimator = - CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + let estimator = CachingNativePriceEstimator::new( + Arc::new(on_demand), + cache, + 1, + Default::default(), + Default::default(), + ); let tokens: Vec<_> = (0..10).map(Address::with_last_byte).collect(); for token in &tokens { @@ -1071,7 +1166,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimator: Arc::new(maintenance), + estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), update_interval: Duration::from_millis(50), update_size: None, prefetch_time: Default::default(), @@ -1080,8 +1175,13 @@ mod tests { }, ); - let estimator = - CachingNativePriceEstimator::new(Box::new(on_demand), cache, 1, Default::default()); + let estimator = CachingNativePriceEstimator::new( + Arc::new(on_demand), + cache, + 1, + Default::default(), + Default::default(), + ); let tokens: Vec<_> = (0..BATCH_SIZE as u64).map(token).collect(); for token in &tokens { @@ -1114,22 +1214,31 @@ mod tests { let now = Instant::now(); // Create a cache and populate it directly - let cache = NativePriceCache::new(Duration::from_secs(10), Default::default()); - cache.insert(t0, CachedResult::new(Ok(0.), now, now, Default::default())); - cache.insert(t1, CachedResult::new(Ok(0.), now, now, Default::default())); + let cache = + NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()); + cache.insert( + t0, + CachedResult::new(Ok(0.), now, now, Default::default(), Default::default()), + ); + cache.insert( + t1, + CachedResult::new(Ok(0.), now, now, Default::default(), Default::default()), + ); let now = now + Duration::from_secs(1); - cache.replace_high_priority(std::iter::once(t0).collect()); + let high_priority: IndexSet
= std::iter::once(t0).collect(); + cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_tokens_to_update_with_internal_priority(Duration::from_secs(0), now); - assert_eq!(tokens[0], t0); - assert_eq!(tokens[1], t1); + cache.sorted_tokens_to_update_with_sources(Duration::from_secs(0), now, &high_priority); + assert_eq!(tokens[0].0, t0); + assert_eq!(tokens[1].0, t1); - cache.replace_high_priority(std::iter::once(t1).collect()); + let high_priority: IndexSet
= std::iter::once(t1).collect(); + cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_tokens_to_update_with_internal_priority(Duration::from_secs(0), now); - assert_eq!(tokens[0], t1); - assert_eq!(tokens[1], t0); + cache.sorted_tokens_to_update_with_sources(Duration::from_secs(0), now, &high_priority); + assert_eq!(tokens[0].0, t1); + assert_eq!(tokens[1].0, t0); } } From 0d3732635aeaf2ea0c1b45719f48ff91938beb4d Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 14 Jan 2026 17:43:05 +0000 Subject: [PATCH 06/42] Nits --- crates/autopilot/src/solvable_orders.rs | 4 +- .../price_estimation/native_price_cache.rs | 37 +++++++++++-------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 4189867aa9..ff712ae748 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -1056,8 +1056,8 @@ mod tests { EstimatorSource::Primary, maintenance_estimator.clone(), )]), - update_interval: Duration::from_millis(1), /* Short interval to trigger - * background fetch quickly */ + // Short interval to trigger background fetch quickly + update_interval: Duration::from_millis(1), update_size: None, prefetch_time: Default::default(), concurrent_requests: 1, diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index df8de0e186..5cd91c5121 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -209,6 +209,9 @@ impl NativePriceCache { /// Get a cached price that is ready to use (not in error accumulation /// state). + /// + /// Returns None if the price is not cached, is expired, or is not ready to + /// use. fn get_ready_to_use_cached_price( &self, token: Address, @@ -254,26 +257,18 @@ impl NativePriceCache { .collect() } - /// Updates the set of high-priority tokens for maintenance updates. + /// Replaces the set of high-priority tokens with the provided set. /// High-priority tokens are refreshed before other tokens in the cache. pub fn replace_high_priority(&self, tokens: IndexSet
) { - tracing::trace!(?tokens, "update high priority tokens in cache"); + tracing::trace!(?tokens, "updated high priority tokens in cache"); *self.inner.high_priority.lock().unwrap() = tokens; } /// Spawns a background maintenance task for this cache. fn spawn_maintenance_task(&self, config: MaintenanceConfig) { - let update_task = CacheUpdateTask { - cache: Arc::downgrade(&self.inner), - estimators: config.estimators, - update_interval: config.update_interval, - update_size: config.update_size, - prefetch_time: config.prefetch_time, - concurrent_requests: config.concurrent_requests, - quote_timeout: config.quote_timeout, - } - .run() - .instrument(tracing::info_span!("native_price_cache_maintenance")); + let update_task = CacheMaintenanceTask::new(Arc::downgrade(&self.inner), config) + .run() + .instrument(tracing::info_span!("native_price_cache_maintenance")); tokio::spawn(update_task); } @@ -333,7 +328,7 @@ impl NativePriceCache { } /// Background task that keeps the cache warm by periodically refreshing prices. -struct CacheUpdateTask { +struct CacheMaintenanceTask { cache: Weak, /// Map of estimators by source type. Maintenance dispatches to the /// appropriate estimator based on which source fetched each entry. @@ -345,7 +340,19 @@ struct CacheUpdateTask { quote_timeout: Duration, } -impl CacheUpdateTask { +impl CacheMaintenanceTask { + fn new(cache: Weak, config: MaintenanceConfig) -> Self { + CacheMaintenanceTask { + cache, + estimators: config.estimators, + update_interval: config.update_interval, + update_size: config.update_size, + prefetch_time: config.prefetch_time, + concurrent_requests: config.concurrent_requests, + quote_timeout: config.quote_timeout, + } + } + /// Single run of the background updating process. async fn single_update(&self, cache: &NativePriceCache) { let metrics = Metrics::get(); From 213776b9ab1804f38134a8655752e863b8f93e5a Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 15 Jan 2026 09:57:57 +0000 Subject: [PATCH 07/42] Log --- crates/e2e/src/setup/proxy.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/e2e/src/setup/proxy.rs b/crates/e2e/src/setup/proxy.rs index b6b8caa208..fdf093d00b 100644 --- a/crates/e2e/src/setup/proxy.rs +++ b/crates/e2e/src/setup/proxy.rs @@ -51,6 +51,7 @@ impl ProxyState { if let Some(current) = backends.pop_front() { backends.push_back(current); } + tracing::info!(?backends, "rotated backends"); } /// Returns the total number of backends configured. From 7a3d1407e735c4e6716fd943f5766b5af453841b Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 19 Jan 2026 18:12:40 +0000 Subject: [PATCH 08/42] Rework --- crates/autopilot/src/arguments.rs | 11 - crates/autopilot/src/run.rs | 14 +- crates/autopilot/src/solvable_orders.rs | 5 +- crates/orderbook/src/run.rs | 6 +- crates/shared/src/price_estimation/factory.rs | 86 ++---- .../price_estimation/native_price_cache.rs | 252 +++++++++++++----- 6 files changed, 212 insertions(+), 162 deletions(-) diff --git a/crates/autopilot/src/arguments.rs b/crates/autopilot/src/arguments.rs index 4ab98b150e..193be1a5a9 100644 --- a/crates/autopilot/src/arguments.rs +++ b/crates/autopilot/src/arguments.rs @@ -93,11 +93,6 @@ pub struct Arguments { #[clap(long, env)] pub native_price_estimators: NativePriceEstimators, - /// Native price estimators for the API endpoint. - /// If not provided, uses `native_price_estimators`. - #[clap(long, env)] - pub api_native_price_estimators: Option, - /// How many successful price estimates for each order will cause a native /// price estimation to return its result early. It's possible to pass /// values greater than the total number of enabled estimators but that @@ -384,7 +379,6 @@ impl std::fmt::Display for Arguments { allowed_tokens, unsupported_tokens, native_price_estimators, - api_native_price_estimators, min_order_validity_period, banned_users, banned_users_max_cache_size, @@ -434,11 +428,6 @@ impl std::fmt::Display for Arguments { writeln!(f, "allowed_tokens: {allowed_tokens:?}")?; writeln!(f, "unsupported_tokens: {unsupported_tokens:?}")?; writeln!(f, "native_price_estimators: {native_price_estimators}")?; - display_option( - f, - "api_native_price_estimators", - api_native_price_estimators, - )?; writeln!( f, "min_order_validity_period: {min_order_validity_period:?}" diff --git a/crates/autopilot/src/run.rs b/crates/autopilot/src/run.rs index 6971f7c601..a56d3627b6 100644 --- a/crates/autopilot/src/run.rs +++ b/crates/autopilot/src/run.rs @@ -391,12 +391,9 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { .expect("failed to initialize price estimator factory"); let initial_prices = db_write.fetch_latest_prices().await.unwrap(); - let native_price_estimators = price_estimator_factory - .native_price_estimators( + let native_price_estimator = price_estimator_factory + .native_price_estimator( args.native_price_estimators.as_slice(), - args.api_native_price_estimators - .as_ref() - .map(|e| e.as_slice()), args.native_price_estimation_results_required, eth.contracts().weth().clone(), initial_prices, @@ -405,8 +402,11 @@ pub async fn run(args: Arguments, shutdown_controller: ShutdownController) { .await .unwrap(); - let native_price_estimator = native_price_estimators.primary; - let api_native_price_estimator = native_price_estimators.secondary; + let api_native_price_estimator = Arc::new( + shared::price_estimation::native_price_cache::QuoteCompetitionEstimator::new( + native_price_estimator.clone(), + ), + ); let price_estimator = price_estimator_factory .price_estimator( diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index ff712ae748..4034a91837 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -1052,10 +1052,7 @@ mod tests { Duration::from_secs(10), Default::default(), shared::price_estimation::native_price_cache::MaintenanceConfig { - estimators: std::collections::HashMap::from([( - EstimatorSource::Primary, - maintenance_estimator.clone(), - )]), + estimator: maintenance_estimator.clone(), // Short interval to trigger background fetch quickly update_interval: Duration::from_millis(1), update_size: None, diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index 0c2ab973cf..cd9b7163c7 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -321,16 +321,14 @@ pub async fn run(args: Arguments) { let initial_prices = postgres_write.fetch_latest_prices().await.unwrap(); let native_price_estimator = price_estimator_factory - .native_price_estimators( + .native_price_estimator( args.native_price_estimators.as_slice(), - None, args.fast_price_estimation_results_required, native_token.clone(), initial_prices, ) .await - .unwrap() - .primary; + .unwrap(); let price_estimator = price_estimator_factory .price_estimator( diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 0a4613ea64..ff9f0c6aac 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -366,61 +366,41 @@ impl<'a> PriceEstimatorFactory<'a> { )) } - /// Creates native price estimators with a shared cache and background + /// Creates a native price estimator with a shared cache and background /// maintenance task. /// - /// Each cached entry tracks which estimator (Primary or Secondary) - /// originally fetched it. The cache's background maintenance task uses - /// this information to dispatch updates to the appropriate estimator, - /// ensuring each token is refreshed using the same source that - /// originally fetched it. + /// The estimator is configured with Auction source, meaning entries are + /// actively maintained by the background task. For the quote competition + /// use, wrap the returned estimator with `QuoteSourceEstimator` to mark + /// prices as Quote source (cached but not actively maintained). /// - /// The secondary estimators are optional - if not provided, only the - /// primary estimator is used. - /// - /// The `initial_prices` are used to seed the cache before the estimators - /// start. - pub async fn native_price_estimators( + /// The `initial_prices` are used to seed the cache before the estimator + /// starts. + pub async fn native_price_estimator( &mut self, - primary_estimators: &[Vec], - secondary_estimators: Option<&[Vec]>, + estimators: &[Vec], results_required: NonZeroUsize, weth: WETH9::Instance, initial_prices: HashMap, - ) -> Result { + ) -> Result> { anyhow::ensure!( self.args.native_price_cache_max_age > self.args.native_price_prefetch_time, "price cache prefetch time needs to be less than price cache max age" ); - // Create non-caching estimators for main and api - let primary_estimator: Arc = Arc::new( - self.create_competition_native_estimator(primary_estimators, results_required, &weth) + // Create non-caching estimator + let estimator: Arc = Arc::new( + self.create_competition_native_estimator(estimators, results_required, &weth) .await?, ); - let secondary_estimator: Arc = match secondary_estimators { - Some(sources) => Arc::new( - self.create_competition_native_estimator(sources, results_required, &weth) - .await?, - ), - None => primary_estimator.clone(), - }; - - // Build estimators map for maintenance - each source type has its own - // estimator so maintenance can dispatch to the correct one - let mut estimators = HashMap::new(); - estimators.insert(EstimatorSource::Primary, primary_estimator.clone()); - if secondary_estimators.is_some() { - estimators.insert(EstimatorSource::Secondary, secondary_estimator.clone()); - } - // Create cache with background maintenance + // Maintenance only refreshes Auction-sourced entries let cache = NativePriceCache::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, MaintenanceConfig { - estimators, + estimator: estimator.clone(), update_interval: self.args.native_price_cache_refresh, update_size: Some(self.args.native_price_cache_max_update_size), prefetch_time: self.args.native_price_prefetch_time, @@ -429,27 +409,16 @@ impl<'a> PriceEstimatorFactory<'a> { }, ); - // Wrap estimators with caching layer for on-demand price fetching - let primary = - self.wrap_with_cache(primary_estimator, cache.clone(), EstimatorSource::Primary); - let secondary = if secondary_estimators.is_some() { - self.wrap_with_cache(secondary_estimator, cache, EstimatorSource::Secondary) - } else { - primary.clone() - }; - - Ok(NativePriceEstimators { primary, secondary }) + // Wrap with caching layer using Auction source + Ok(self.wrap_with_cache(estimator, cache)) } /// Wraps a native price estimator with caching functionality. - /// - /// The `source` parameter identifies this estimator type so cached entries - /// are tagged appropriately for maintenance dispatch. + /// Uses Auction source so entries are actively maintained. fn wrap_with_cache( &self, estimator: Arc, cache: NativePriceCache, - source: EstimatorSource, ) -> Arc { let approximation_tokens = self .args @@ -463,7 +432,7 @@ impl<'a> PriceEstimatorFactory<'a> { cache, self.args.native_price_cache_concurrent_requests, approximation_tokens, - source, + EstimatorSource::Auction, )) } @@ -491,23 +460,6 @@ impl<'a> PriceEstimatorFactory<'a> { } } -/// Result of creating native price estimators with shared cache. -/// -/// The shared cache tracks which estimator type originally fetched each entry. -/// The background maintenance task uses this information to dispatch updates -/// to the appropriate estimator. -pub struct NativePriceEstimators { - /// Primary estimator using the main set of sources for on-demand fetching. - /// Cached entries fetched via this estimator are tagged with - /// `EstimatorSource::Primary`. - pub primary: Arc, - /// Secondary estimator that shares the cache with primary but uses a - /// different set of sources for on-demand fetching. Cached entries - /// fetched via this estimator are tagged with - /// `EstimatorSource::Secondary`. - pub secondary: Arc, -} - /// Trait for modelling the initialization of a Price estimator and its verified /// counter-part. This allows for generic price estimator creation, as well as /// per-type trade verification configuration. diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 5cd91c5121..0a0a3688d6 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -21,15 +21,14 @@ use { }; /// Identifies which estimator type fetched a cached entry. -/// Used by maintenance to dispatch to the correct estimator. +/// Used by maintenance to decide which entries to refresh. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum EstimatorSource { - /// Primary estimator - the main source for price estimates. + /// Auction-related requests - actively maintained by background task. #[default] - Primary, - /// Secondary estimator - supplementary source that may have different - /// price sources. - Secondary, + Auction, + /// Quote competition-related requests - cached but not maintained. + Quote, } #[derive(prometheus_metric_storage::MetricStorage)] @@ -53,10 +52,9 @@ impl Metrics { /// Configuration for the background maintenance task that keeps the cache warm. pub struct MaintenanceConfig { - /// Map of estimators by source type for maintenance. - /// Maintenance dispatches to the appropriate estimator based on - /// which source originally fetched each cached entry. - pub estimators: HashMap>, + /// Estimator used for maintenance updates. + /// Maintenance only refreshes Auction-sourced entries. + pub estimator: Arc, /// How often to run the maintenance task. pub update_interval: Duration, /// Maximum number of prices to update per maintenance cycle. @@ -169,22 +167,39 @@ impl NativePriceCache { /// Get a cached price, optionally creating a placeholder entry for missing /// tokens. Returns None if the price is not cached or is expired. + /// /// If `create_missing_entry` is Some, creates an outdated entry with the /// given source type so maintenance will fetch it. + /// + /// If `upgrade_to_source` is Some(Auction) and the existing entry has Quote + /// source, the entry is upgraded to Auction source and its expiration + /// is reset. This ensures that tokens originally fetched for quotes + /// become actively maintained when they're later needed for auctions. fn get_cached_price( &self, token: Address, now: Instant, create_missing_entry: Option, + upgrade_to_source: Option, ) -> Option { let mut cache = self.inner.cache.lock().unwrap(); match cache.entry(token) { Entry::Occupied(mut entry) => { - let entry = entry.get_mut(); - entry.requested_at = now; + let cached = entry.get_mut(); + cached.requested_at = now; + + // Upgrade Quote to Auction if requested - this makes the entry + // actively maintained by the background task + if upgrade_to_source == Some(EstimatorSource::Auction) + && cached.source == EstimatorSource::Quote + { + tracing::trace!(?token, "upgrading Quote-sourced entry to Auction"); + cached.source = EstimatorSource::Auction; + } + let is_recent = - now.saturating_duration_since(entry.updated_at) < self.inner.max_age; - is_recent.then_some(entry.clone()) + now.saturating_duration_since(cached.updated_at) < self.inner.max_age; + is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { if let Some(source) = create_missing_entry { @@ -217,8 +232,9 @@ impl NativePriceCache { token: Address, now: Instant, create_missing_entry: Option, + upgrade_to_source: Option, ) -> Option { - self.get_cached_price(token, now, create_missing_entry) + self.get_cached_price(token, now, create_missing_entry, upgrade_to_source) .filter(|cached| cached.is_ready()) } @@ -227,21 +243,26 @@ impl NativePriceCache { self.inner.cache.lock().unwrap().insert(token, result); } - /// Get tokens that need updating with their sources, sorted by priority. - fn sorted_tokens_to_update_with_sources( + /// Get Auction-sourced tokens that need updating, sorted by priority. + /// Only returns tokens with Auction source since maintenance doesn't + /// refresh Quote-sourced entries. + fn sorted_auction_tokens_to_update( &self, max_age: Duration, now: Instant, high_priority: &IndexSet
, - ) -> Vec<(Address, EstimatorSource)> { + ) -> Vec
{ let mut outdated: Vec<_> = self .inner .cache .lock() .unwrap() .iter() - .filter(|(_, cached)| now.saturating_duration_since(cached.updated_at) > max_age) - .map(|(token, cached)| (*token, cached.requested_at, cached.source)) + .filter(|(_, cached)| { + cached.source == EstimatorSource::Auction + && now.saturating_duration_since(cached.updated_at) > max_age + }) + .map(|(token, cached)| (*token, cached.requested_at)) .collect(); let index = |token: &Address| high_priority.get_index_of(token).unwrap_or(usize::MAX); @@ -251,10 +272,7 @@ impl NativePriceCache { std::cmp::Reverse(entry.1), // important items have recent (i.e. "big") timestamp ) }); - outdated - .into_iter() - .map(|(token, _, source)| (token, source)) - .collect() + outdated.into_iter().map(|(token, _)| token).collect() } /// Replaces the set of high-priority tokens with the provided set. @@ -273,24 +291,23 @@ impl NativePriceCache { } /// Estimates prices for the given tokens and updates the cache. - /// Used by the background maintenance task. Each token is processed using - /// the estimator corresponding to its source. - fn estimate_prices_and_update_cache<'a>( + /// Used by the background maintenance task. All tokens are processed using + /// the provided estimator and marked as Auction source. + fn estimate_prices_and_update_cache_for_maintenance<'a>( &'a self, - tokens: &'a [(Address, EstimatorSource)], - estimators: &'a HashMap>, + tokens: &'a [Address], + estimator: &'a Arc, concurrent_requests: usize, request_timeout: Duration, ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { - let estimates = tokens.iter().filter_map(move |(token, source)| { - let source = *source; - let estimator = estimators.get(&source)?.clone(); - Some(async move { + let estimates = tokens.iter().map(move |token| { + let estimator = estimator.clone(); + async move { let current_accumulative_errors_count = { // check if the price is cached by now let now = Instant::now(); - match self.get_cached_price(*token, now, None) { + match self.get_cached_price(*token, now, None, None) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -303,7 +320,7 @@ impl NativePriceCache { .estimate_native_price(*token, request_timeout) .await; - // update price in cache + // update price in cache with Auction source if should_cache(&result) { let now = Instant::now(); self.insert( @@ -313,13 +330,13 @@ impl NativePriceCache { now, now, current_accumulative_errors_count, - source, + EstimatorSource::Auction, ), ); }; (*token, result) - }) + } }); futures::stream::iter(estimates) .buffered(concurrent_requests) @@ -328,11 +345,12 @@ impl NativePriceCache { } /// Background task that keeps the cache warm by periodically refreshing prices. +/// Only refreshes Auction-sourced entries; Quote-sourced entries are cached +/// but not maintained. struct CacheMaintenanceTask { cache: Weak, - /// Map of estimators by source type. Maintenance dispatches to the - /// appropriate estimator based on which source fetched each entry. - estimators: HashMap>, + /// Estimator used for maintenance updates. + estimator: Arc, update_interval: Duration, update_size: Option, prefetch_time: Duration, @@ -344,7 +362,7 @@ impl CacheMaintenanceTask { fn new(cache: Weak, config: MaintenanceConfig) -> Self { CacheMaintenanceTask { cache, - estimators: config.estimators, + estimator: config.estimator, update_interval: config.update_interval, update_size: config.update_size, prefetch_time: config.prefetch_time, @@ -354,6 +372,7 @@ impl CacheMaintenanceTask { } /// Single run of the background updating process. + /// Only updates Auction-sourced entries; Quote-sourced entries are skipped. async fn single_update(&self, cache: &NativePriceCache) { let metrics = Metrics::get(); metrics @@ -363,9 +382,9 @@ impl CacheMaintenanceTask { let max_age = cache.max_age().saturating_sub(self.prefetch_time); let high_priority = cache.inner.high_priority.lock().unwrap().clone(); let mut outdated_entries = - cache.sorted_tokens_to_update_with_sources(max_age, Instant::now(), &high_priority); + cache.sorted_auction_tokens_to_update(max_age, Instant::now(), &high_priority); - tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated prices to fetch"); + tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated auction prices to fetch"); metrics .native_price_cache_outdated_entries @@ -377,9 +396,9 @@ impl CacheMaintenanceTask { return; } - let mut stream = cache.estimate_prices_and_update_cache( + let mut stream = cache.estimate_prices_and_update_cache_for_maintenance( &outdated_entries, - &self.estimators, + &self.estimator, self.concurrent_requests, self.quote_timeout, ); @@ -488,6 +507,9 @@ impl Inner { /// estimation request gets issued. We check the cache before each /// request because they can take a long time and some other task might /// have fetched some requested price in the meantime. + /// + /// If this estimator has Auction source and the cached entry has Quote + /// source, the entry is upgraded to Auction source. fn estimate_prices_and_update_cache<'a>( &'a self, tokens: &'a [Address], @@ -498,7 +520,11 @@ impl Inner { // check if the price is cached by now let now = Instant::now(); - match self.cache.get_cached_price(*token, now, None) { + // Pass our source for potential upgrade from Quote to Auction + match self + .cache + .get_cached_price(*token, now, None, Some(self.source)) + { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -588,7 +614,10 @@ impl CachingNativePriceEstimator { /// Only returns prices that are currently cached. Missing prices will get /// prioritized to get fetched during the next cycles of the maintenance - /// background task. + /// background task (only for Auction source). + /// + /// If this estimator has Auction source and a cached entry has Quote + /// source, the entry is upgraded to Auction source. fn get_cached_prices( &self, tokens: &[Address], @@ -596,12 +625,15 @@ impl CachingNativePriceEstimator { let now = Instant::now(); let mut results = HashMap::default(); for token in tokens { - // Pass our source so that if a missing entry is created, it's tagged - // with our source for proper maintenance later. - let cached = - self.0 - .cache - .get_ready_to_use_cached_price(*token, now, Some(self.0.source)); + // Pass our source so that: + // 1. If a missing entry is created, it's tagged with our source + // 2. If Quote source and we're Auction, upgrade to Auction + let cached = self.0.cache.get_ready_to_use_cached_price( + *token, + now, + Some(self.0.source), + Some(self.0.source), + ); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -662,7 +694,10 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = self.0.cache.get_ready_to_use_cached_price(token, now, None); + let cached = + self.0 + .cache + .get_ready_to_use_cached_price(token, now, None, Some(self.0.source)); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -685,6 +720,72 @@ impl NativePriceEstimating for CachingNativePriceEstimator { } } +/// Wrapper around `CachingNativePriceEstimator` that marks all requests as +/// Quote source. Used for the autopilot API endpoints where prices should be +/// cached but not actively maintained by the background task. +#[derive(Clone)] +pub struct QuoteCompetitionEstimator(Arc); + +impl QuoteCompetitionEstimator { + /// Creates a new QuoteSourceEstimator wrapping the given estimator. + /// + /// Prices fetched through this wrapper will be cached with Quote source, + /// meaning they won't be actively refreshed by the background maintenance + /// task. However, if the same token is later requested for auction + /// purposes, the entry will be upgraded to Auction source and become + /// actively maintained. + pub fn new(estimator: Arc) -> Self { + Self(estimator) + } +} + +impl NativePriceEstimating for QuoteCompetitionEstimator { + fn estimate_native_price( + &self, + token: Address, + timeout: Duration, + ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { + async move { + let now = Instant::now(); + let cached = self.0.0.cache.get_ready_to_use_cached_price( + token, + now, + None, + Some(EstimatorSource::Quote), + ); + + let label = if cached.is_some() { "hits" } else { "misses" }; + Metrics::get() + .native_price_cache_access + .with_label_values(&[label]) + .inc_by(1); + + if let Some(cached) = cached { + return cached.result; + } + + // Fetch the price and cache it with Quote source + let result = self + .0 + .0 + .estimator + .estimate_native_price(token, timeout) + .await; + + if should_cache(&result) { + let now = Instant::now(); + self.0.0.cache.insert( + token, + CachedResult::new(result.clone(), now, now, 0, EstimatorSource::Quote), + ); + } + + result + } + .boxed() + } +} + #[cfg(test)] mod tests { use { @@ -1042,7 +1143,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), + estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), update_size: Some(1), prefetch_time: Default::default(), @@ -1056,7 +1157,7 @@ mod tests { cache, 1, Default::default(), - Default::default(), + EstimatorSource::Auction, ); // fill cache with 2 different queries @@ -1105,7 +1206,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), + estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), update_size: None, prefetch_time: Default::default(), @@ -1119,7 +1220,7 @@ mod tests { cache, 1, Default::default(), - Default::default(), + EstimatorSource::Auction, ); let tokens: Vec<_> = (0..10).map(Address::with_last_byte).collect(); @@ -1173,7 +1274,7 @@ mod tests { Duration::from_millis(30), Default::default(), MaintenanceConfig { - estimators: HashMap::from([(EstimatorSource::Primary, Arc::new(maintenance) as _)]), + estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), update_size: None, prefetch_time: Default::default(), @@ -1187,7 +1288,7 @@ mod tests { cache, 1, Default::default(), - Default::default(), + EstimatorSource::Auction, ); let tokens: Vec<_> = (0..BATCH_SIZE as u64).map(token).collect(); @@ -1220,16 +1321,29 @@ mod tests { let t1 = Address::with_last_byte(1); let now = Instant::now(); - // Create a cache and populate it directly + // Create a cache and populate it directly with Auction-sourced entries + // (since maintenance only updates Auction entries) let cache = NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()); cache.insert( t0, - CachedResult::new(Ok(0.), now, now, Default::default(), Default::default()), + CachedResult::new( + Ok(0.), + now, + now, + Default::default(), + EstimatorSource::Auction, + ), ); cache.insert( t1, - CachedResult::new(Ok(0.), now, now, Default::default(), Default::default()), + CachedResult::new( + Ok(0.), + now, + now, + Default::default(), + EstimatorSource::Auction, + ), ); let now = now + Duration::from_secs(1); @@ -1237,15 +1351,15 @@ mod tests { let high_priority: IndexSet
= std::iter::once(t0).collect(); cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_tokens_to_update_with_sources(Duration::from_secs(0), now, &high_priority); - assert_eq!(tokens[0].0, t0); - assert_eq!(tokens[1].0, t1); + cache.sorted_auction_tokens_to_update(Duration::from_secs(0), now, &high_priority); + assert_eq!(tokens[0], t0); + assert_eq!(tokens[1], t1); let high_priority: IndexSet
= std::iter::once(t1).collect(); cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_tokens_to_update_with_sources(Duration::from_secs(0), now, &high_priority); - assert_eq!(tokens[0].0, t1); - assert_eq!(tokens[1].0, t0); + cache.sorted_auction_tokens_to_update(Duration::from_secs(0), now, &high_priority); + assert_eq!(tokens[0], t1); + assert_eq!(tokens[1], t0); } } From 3c1214ac31c792e66f6a33d1c23b887a42219cba Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 19 Jan 2026 18:42:18 +0000 Subject: [PATCH 09/42] Nits --- crates/shared/src/price_estimation/factory.rs | 4 ++-- crates/shared/src/price_estimation/native_price_cache.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index ff9f0c6aac..a618d2d9e1 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -394,8 +394,8 @@ impl<'a> PriceEstimatorFactory<'a> { .await?, ); - // Create cache with background maintenance - // Maintenance only refreshes Auction-sourced entries + // Create cache with background maintenance, which only refreshes + // Auction-sourced entries let cache = NativePriceCache::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 0a0a3688d6..cd5bf7c9d6 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -162,7 +162,7 @@ impl NativePriceCache { /// Returns true if the cache is empty. pub fn is_empty(&self) -> bool { - self.len() == 0 + self.inner.cache.lock().unwrap().is_empty() } /// Get a cached price, optionally creating a placeholder entry for missing From 9bcf4dd8954b42a8a9bc67b391d50948c91b96e8 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 20 Jan 2026 15:39:50 +0000 Subject: [PATCH 10/42] CacheLookup --- .../price_estimation/native_price_cache.rs | 107 +++++++++++------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index cd5bf7c9d6..7abcef401c 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -31,6 +31,22 @@ pub enum EstimatorSource { Quote, } +/// Specifies what cache modifications to perform during a lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheLookup { + /// Read-only access - no modifications to the cache. + /// Used by maintenance when checking if a price is already cached. + ReadOnly, + /// Upgrade existing Quote-sourced entry to Auction if applicable, but don't + /// create missing entries. Used by estimators when looking up cached + /// prices. + UpgradeOnly, + /// Create missing entry for maintenance (with Auction source) and upgrade + /// existing Quote→Auction. Used when building auctions to ensure missing + /// prices get fetched by the maintenance task. + CreateForMaintenance, +} + #[derive(prometheus_metric_storage::MetricStorage)] struct Metrics { /// native price cache hits misses @@ -165,22 +181,19 @@ impl NativePriceCache { self.inner.cache.lock().unwrap().is_empty() } - /// Get a cached price, optionally creating a placeholder entry for missing - /// tokens. Returns None if the price is not cached or is expired. - /// - /// If `create_missing_entry` is Some, creates an outdated entry with the - /// given source type so maintenance will fetch it. + /// Get a cached price with optional cache modifications. + /// Returns None if the price is not cached or is expired. /// - /// If `upgrade_to_source` is Some(Auction) and the existing entry has Quote - /// source, the entry is upgraded to Auction source and its expiration - /// is reset. This ensures that tokens originally fetched for quotes - /// become actively maintained when they're later needed for auctions. + /// The `lookup` parameter controls what modifications to perform: + /// - `ReadOnly`: No modifications, just check the cache + /// - `UpgradeOnly`: Upgrade Quote→Auction entries, but don't create missing + /// - `CreateForMaintenance`: Create missing entries with Auction source and + /// upgrade existing Quote→Auction entries fn get_cached_price( &self, token: Address, now: Instant, - create_missing_entry: Option, - upgrade_to_source: Option, + lookup: CacheLookup, ) -> Option { let mut cache = self.inner.cache.lock().unwrap(); match cache.entry(token) { @@ -190,8 +203,10 @@ impl NativePriceCache { // Upgrade Quote to Auction if requested - this makes the entry // actively maintained by the background task - if upgrade_to_source == Some(EstimatorSource::Auction) - && cached.source == EstimatorSource::Quote + if matches!( + lookup, + CacheLookup::UpgradeOnly | CacheLookup::CreateForMaintenance + ) && cached.source == EstimatorSource::Quote { tracing::trace!(?token, "upgrading Quote-sourced entry to Auction"); cached.source = EstimatorSource::Auction; @@ -202,7 +217,7 @@ impl NativePriceCache { is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { - if let Some(source) = create_missing_entry { + if lookup == CacheLookup::CreateForMaintenance { // Create an outdated cache entry so the background task keeping the cache warm // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. @@ -214,7 +229,7 @@ impl NativePriceCache { outdated_timestamp, now, Default::default(), - source, + EstimatorSource::Auction, )); } None @@ -231,10 +246,9 @@ impl NativePriceCache { &self, token: Address, now: Instant, - create_missing_entry: Option, - upgrade_to_source: Option, + lookup: CacheLookup, ) -> Option { - self.get_cached_price(token, now, create_missing_entry, upgrade_to_source) + self.get_cached_price(token, now, lookup) .filter(|cached| cached.is_ready()) } @@ -307,7 +321,7 @@ impl NativePriceCache { // check if the price is cached by now let now = Instant::now(); - match self.get_cached_price(*token, now, None, None) { + match self.get_cached_price(*token, now, CacheLookup::ReadOnly) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -520,11 +534,12 @@ impl Inner { // check if the price is cached by now let now = Instant::now(); - // Pass our source for potential upgrade from Quote to Auction - match self - .cache - .get_cached_price(*token, now, None, Some(self.source)) - { + // Upgrade Quote→Auction if this is an Auction-sourced estimator + let lookup = match self.source { + EstimatorSource::Auction => CacheLookup::UpgradeOnly, + EstimatorSource::Quote => CacheLookup::ReadOnly, + }; + match self.cache.get_cached_price(*token, now, lookup) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -624,16 +639,17 @@ impl CachingNativePriceEstimator { ) -> HashMap> { let now = Instant::now(); let mut results = HashMap::default(); + // For Auction source: create missing entries and upgrade Quote→Auction + // For Quote source: just read (Quote entries shouldn't be maintained) + let lookup = match self.0.source { + EstimatorSource::Auction => CacheLookup::CreateForMaintenance, + EstimatorSource::Quote => CacheLookup::ReadOnly, + }; for token in tokens { - // Pass our source so that: - // 1. If a missing entry is created, it's tagged with our source - // 2. If Quote source and we're Auction, upgrade to Auction - let cached = self.0.cache.get_ready_to_use_cached_price( - *token, - now, - Some(self.0.source), - Some(self.0.source), - ); + let cached = self + .0 + .cache + .get_ready_to_use_cached_price(*token, now, lookup); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -694,10 +710,15 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = - self.0 - .cache - .get_ready_to_use_cached_price(token, now, None, Some(self.0.source)); + // Upgrade Quote→Auction if this is an Auction-sourced estimator + let lookup = match self.0.source { + EstimatorSource::Auction => CacheLookup::UpgradeOnly, + EstimatorSource::Quote => CacheLookup::ReadOnly, + }; + let cached = self + .0 + .cache + .get_ready_to_use_cached_price(token, now, lookup); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -747,12 +768,12 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = self.0.0.cache.get_ready_to_use_cached_price( - token, - now, - None, - Some(EstimatorSource::Quote), - ); + // Quote source doesn't upgrade or create entries, just read + let cached = + self.0 + .0 + .cache + .get_ready_to_use_cached_price(token, now, CacheLookup::ReadOnly); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() From 24f89bd2456cb471a3cb503b715817a61c6a209d Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 20 Jan 2026 18:12:17 +0000 Subject: [PATCH 11/42] Reduce code duplication --- .../price_estimation/native_price_cache.rs | 78 +++++++++---------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 7abcef401c..166ee5ca90 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -410,18 +410,14 @@ impl CacheMaintenanceTask { return; } - let mut stream = cache.estimate_prices_and_update_cache_for_maintenance( + let stream = cache.estimate_prices_and_update_cache_for_maintenance( &outdated_entries, &self.estimator, self.concurrent_requests, self.quote_timeout, ); - let mut updates_count = 0u64; - while stream.next().await.is_some() { - updates_count += 1; - } - + let updates_count = stream.count().await as u64; metrics .native_price_cache_background_updates .inc_by(updates_count); @@ -548,27 +544,14 @@ impl Inner { } }; - let token_to_fetch = *self.approximation_tokens.get(token).unwrap_or(token); - let result = self - .estimator - .estimate_native_price(token_to_fetch, request_timeout) - .await; - - // update price in cache - if should_cache(&result) { - let now = Instant::now(); - self.cache.insert( + .fetch_and_cache_price( *token, - CachedResult::new( - result.clone(), - now, - now, - current_accumulative_errors_count, - self.source, - ), - ); - }; + request_timeout, + self.source, + current_accumulative_errors_count, + ) + .await; (*token, result) }); @@ -576,6 +559,32 @@ impl Inner { .buffered(self.concurrent_requests) .boxed() } + + /// Fetches a single price and caches it. + async fn fetch_and_cache_price( + &self, + token: Address, + timeout: Duration, + source: EstimatorSource, + accumulative_errors_count: u32, + ) -> NativePriceEstimateResult { + let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); + + let result = self + .estimator + .estimate_native_price(token_to_fetch, timeout) + .await; + + if should_cache(&result) { + let now = Instant::now(); + self.cache.insert( + token, + CachedResult::new(result.clone(), now, now, accumulative_errors_count, source), + ); + } + + result + } } fn should_cache(result: &Result) -> bool { @@ -785,23 +794,10 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { return cached.result; } - // Fetch the price and cache it with Quote source - let result = self - .0 + self.0 .0 - .estimator - .estimate_native_price(token, timeout) - .await; - - if should_cache(&result) { - let now = Instant::now(); - self.0.0.cache.insert( - token, - CachedResult::new(result.clone(), now, now, 0, EstimatorSource::Quote), - ); - } - - result + .fetch_and_cache_price(token, timeout, EstimatorSource::Quote, 0) + .await } .boxed() } From 58d414bda4f31215594b0bc51bfa95504c99b9f0 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 21 Jan 2026 14:29:53 +0000 Subject: [PATCH 12/42] Avoid unwrap --- crates/shared/src/price_estimation/native_price_cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 166ee5ca90..3eacbe5051 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -222,7 +222,7 @@ impl NativePriceCache { // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. // Otherwise malicious actors could easily cause the cache size to blow up. - let outdated_timestamp = now.checked_sub(self.inner.max_age).unwrap(); + let outdated_timestamp = now.checked_sub(self.inner.max_age).unwrap_or(now); tracing::trace!(?token, "create outdated price entry"); entry.insert(CachedResult::new( Ok(0.), From 701a7bd83412dc40cf7b4535611fc878295fc124 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 21 Jan 2026 18:47:11 +0000 Subject: [PATCH 13/42] Fix logging --- crates/e2e/src/setup/proxy.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/e2e/src/setup/proxy.rs b/crates/e2e/src/setup/proxy.rs index 4788ba33d9..0d018f3138 100644 --- a/crates/e2e/src/setup/proxy.rs +++ b/crates/e2e/src/setup/proxy.rs @@ -51,7 +51,7 @@ impl ProxyState { if let Some(current) = backends.pop_front() { backends.push_back(current); } - tracing::info!(?backends, "rotated backends"); + tracing::info!(backends = ?backends.iter().map(Url::as_str).collect::>(), "rotated backends"); } /// Returns the total number of backends configured. @@ -99,7 +99,7 @@ async fn serve(listen_addr: SocketAddr, backends: Vec, state: ProxyState) { let app = Router::new().fallback(proxy_handler); - tracing::info!(?listen_addr, ?backends, "starting reverse proxy"); + tracing::info!(%listen_addr, backends = ?backends.iter().map(Url::as_str).collect::>(), "starting reverse proxy"); axum::Server::bind(&listen_addr) .serve(app.into_make_service()) .await @@ -133,7 +133,7 @@ async fn handle_request( match try_backend(&client, &parts, body_bytes.to_vec(), &backend).await { Ok(response) => return response.into_response(), Err(err) => { - tracing::warn!(?err, ?backend, attempt, "backend failed, rotating to next"); + tracing::warn!(?err, %backend, attempt, "backend failed, rotating to next"); state.rotate_backends().await; } } From 7c91aa0231be790dd7c85502f903336e4b36ccec Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 21 Jan 2026 19:06:21 +0000 Subject: [PATCH 14/42] Fix attempt --- crates/e2e/tests/e2e/autopilot_leader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/e2e/tests/e2e/autopilot_leader.rs b/crates/e2e/tests/e2e/autopilot_leader.rs index 7ff1e1fc7d..abaada1d77 100644 --- a/crates/e2e/tests/e2e/autopilot_leader.rs +++ b/crates/e2e/tests/e2e/autopilot_leader.rs @@ -160,12 +160,12 @@ async fn dual_autopilot_only_leader_produces_auctions(web3: Web3) { // Stop autopilot-leader, follower should take over manual_shutdown.shutdown(); - onchain.mint_block().await; assert!( tokio::time::timeout(Duration::from_secs(15), autopilot_leader) .await .is_ok() ); + onchain.mint_block().await; // Run 10 txs, autopilot-backup is in charge // - only test_solver2 should participate and settle From 69bf1399f0991bf768f4c4f07fc351d67293e1f1 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 21 Jan 2026 19:11:54 +0000 Subject: [PATCH 15/42] Fix attempt --- crates/e2e/tests/e2e/autopilot_leader.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/e2e/tests/e2e/autopilot_leader.rs b/crates/e2e/tests/e2e/autopilot_leader.rs index abaada1d77..fbf8af5a72 100644 --- a/crates/e2e/tests/e2e/autopilot_leader.rs +++ b/crates/e2e/tests/e2e/autopilot_leader.rs @@ -160,12 +160,15 @@ async fn dual_autopilot_only_leader_produces_auctions(web3: Web3) { // Stop autopilot-leader, follower should take over manual_shutdown.shutdown(); - assert!( - tokio::time::timeout(Duration::from_secs(15), autopilot_leader) - .await - .is_ok() - ); + tokio::time::timeout(Duration::from_secs(15), autopilot_leader) + .await + .unwrap() + .unwrap(); + // Ensure all the locks are released and follower has time to step up + tokio::time::sleep(Duration::from_secs(2)).await; onchain.mint_block().await; + // Ensure the follower has stepped up as leader + tokio::time::sleep(Duration::from_secs(2)).await; // Run 10 txs, autopilot-backup is in charge // - only test_solver2 should participate and settle From 2b7a5a8bbf3f327c56105e99d02079309a2fcb29 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 21 Jan 2026 19:17:51 +0000 Subject: [PATCH 16/42] Revert --- crates/e2e/tests/e2e/autopilot_leader.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/e2e/tests/e2e/autopilot_leader.rs b/crates/e2e/tests/e2e/autopilot_leader.rs index fbf8af5a72..b26cd35572 100644 --- a/crates/e2e/tests/e2e/autopilot_leader.rs +++ b/crates/e2e/tests/e2e/autopilot_leader.rs @@ -160,10 +160,11 @@ async fn dual_autopilot_only_leader_produces_auctions(web3: Web3) { // Stop autopilot-leader, follower should take over manual_shutdown.shutdown(); - tokio::time::timeout(Duration::from_secs(15), autopilot_leader) - .await - .unwrap() - .unwrap(); + assert!( + tokio::time::timeout(Duration::from_secs(15), autopilot_leader) + .await + .is_ok() + ); // Ensure all the locks are released and follower has time to step up tokio::time::sleep(Duration::from_secs(2)).await; onchain.mint_block().await; From 5e40784b5eb2ecde3352a3ab0bb8fd462df6cd6c Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 10:57:29 +0000 Subject: [PATCH 17/42] Rename EstimatorSource -> KeepPriceUpdated --- crates/autopilot/src/solvable_orders.rs | 8 +-- crates/shared/src/price_estimation/factory.rs | 4 +- .../price_estimation/native_price_cache.rs | 72 ++++++++----------- 3 files changed, 36 insertions(+), 48 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 4034a91837..dd656e33de 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -913,7 +913,7 @@ mod tests { HEALTHY_PRICE_ESTIMATION_TIME, PriceEstimationError, native::MockNativePriceEstimating, - native_price_cache::{EstimatorSource, NativePriceCache}, + native_price_cache::{KeepPriceUpdated, NativePriceCache}, }, signature_validator::{MockSignatureValidating, SignatureValidationError}, }, @@ -962,7 +962,7 @@ mod tests { NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, Default::default(), - EstimatorSource::default(), + KeepPriceUpdated::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1066,7 +1066,7 @@ mod tests { cache, 1, Default::default(), - EstimatorSource::default(), + KeepPriceUpdated::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1161,7 +1161,7 @@ mod tests { 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), - EstimatorSource::default(), + KeepPriceUpdated::default(), ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index c0d9b971fd..62681eabe3 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -9,7 +9,7 @@ use { native::{self, NativePriceEstimator}, native_price_cache::{ CachingNativePriceEstimator, - EstimatorSource, + KeepPriceUpdated, MaintenanceConfig, NativePriceCache, }, @@ -432,7 +432,7 @@ impl<'a> PriceEstimatorFactory<'a> { cache, self.args.native_price_cache_concurrent_requests, approximation_tokens, - EstimatorSource::Auction, + KeepPriceUpdated::Yes, )) } diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 3eacbe5051..c7f01ac388 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -20,15 +20,15 @@ use { tracing::{Instrument, instrument}, }; -/// Identifies which estimator type fetched a cached entry. -/// Used by maintenance to decide which entries to refresh. +// This could be a bool but lets keep it as an enum for clarity. +// Arguably this should not implement Default for the same argument... +/// Determines whether the background maintenance task should +/// keep the token price up to date automatically. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum EstimatorSource { - /// Auction-related requests - actively maintained by background task. +pub enum KeepPriceUpdated { #[default] - Auction, - /// Quote competition-related requests - cached but not maintained. - Quote, + Yes, + No, } /// Specifies what cache modifications to perform during a lookup. @@ -125,7 +125,7 @@ impl NativePriceCache { updated_at, now, Default::default(), - EstimatorSource::default(), + KeepPriceUpdated::Yes, ), )) }) @@ -206,10 +206,10 @@ impl NativePriceCache { if matches!( lookup, CacheLookup::UpgradeOnly | CacheLookup::CreateForMaintenance - ) && cached.source == EstimatorSource::Quote + ) && cached.source == KeepPriceUpdated::No { tracing::trace!(?token, "upgrading Quote-sourced entry to Auction"); - cached.source = EstimatorSource::Auction; + cached.source = KeepPriceUpdated::Yes; } let is_recent = @@ -229,7 +229,7 @@ impl NativePriceCache { outdated_timestamp, now, Default::default(), - EstimatorSource::Auction, + KeepPriceUpdated::Yes, )); } None @@ -273,7 +273,7 @@ impl NativePriceCache { .unwrap() .iter() .filter(|(_, cached)| { - cached.source == EstimatorSource::Auction + cached.source == KeepPriceUpdated::Yes && now.saturating_duration_since(cached.updated_at) > max_age }) .map(|(token, cached)| (*token, cached.requested_at)) @@ -344,7 +344,7 @@ impl NativePriceCache { now, now, current_accumulative_errors_count, - EstimatorSource::Auction, + KeepPriceUpdated::Yes, ), ); }; @@ -460,7 +460,7 @@ struct Inner { approximation_tokens: HashMap, /// Identifies which estimator type this is, used to track which /// estimator fetched each cached entry for proper maintenance. - source: EstimatorSource, + source: KeepPriceUpdated, } type CacheEntry = Result; @@ -472,7 +472,7 @@ struct CachedResult { requested_at: Instant, accumulative_errors_count: u32, /// Which estimator type fetched this entry. - source: EstimatorSource, + source: KeepPriceUpdated, } /// Defines how many consecutive errors are allowed before the cache starts @@ -486,7 +486,7 @@ impl CachedResult { updated_at: Instant, requested_at: Instant, current_accumulative_errors_count: u32, - source: EstimatorSource, + source: KeepPriceUpdated, ) -> Self { let estimator_internal_errors_count = matches!(result, Err(PriceEstimationError::EstimatorInternal(_))) @@ -532,8 +532,8 @@ impl Inner { // Upgrade Quote→Auction if this is an Auction-sourced estimator let lookup = match self.source { - EstimatorSource::Auction => CacheLookup::UpgradeOnly, - EstimatorSource::Quote => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => CacheLookup::UpgradeOnly, + KeepPriceUpdated::No => CacheLookup::ReadOnly, }; match self.cache.get_cached_price(*token, now, lookup) { Some(cached) if cached.is_ready() => { @@ -565,7 +565,7 @@ impl Inner { &self, token: Address, timeout: Duration, - source: EstimatorSource, + source: KeepPriceUpdated, accumulative_errors_count: u32, ) -> NativePriceEstimateResult { let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); @@ -625,7 +625,7 @@ impl CachingNativePriceEstimator { cache: NativePriceCache, concurrent_requests: usize, approximation_tokens: HashMap, - source: EstimatorSource, + source: KeepPriceUpdated, ) -> Self { Self(Arc::new(Inner { estimator, @@ -651,8 +651,8 @@ impl CachingNativePriceEstimator { // For Auction source: create missing entries and upgrade Quote→Auction // For Quote source: just read (Quote entries shouldn't be maintained) let lookup = match self.0.source { - EstimatorSource::Auction => CacheLookup::CreateForMaintenance, - EstimatorSource::Quote => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => CacheLookup::CreateForMaintenance, + KeepPriceUpdated::No => CacheLookup::ReadOnly, }; for token in tokens { let cached = self @@ -721,8 +721,8 @@ impl NativePriceEstimating for CachingNativePriceEstimator { let now = Instant::now(); // Upgrade Quote→Auction if this is an Auction-sourced estimator let lookup = match self.0.source { - EstimatorSource::Auction => CacheLookup::UpgradeOnly, - EstimatorSource::Quote => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => CacheLookup::UpgradeOnly, + KeepPriceUpdated::No => CacheLookup::ReadOnly, }; let cached = self .0 @@ -796,7 +796,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { self.0 .0 - .fetch_and_cache_price(token, timeout, EstimatorSource::Quote, 0) + .fetch_and_cache_price(token, timeout, KeepPriceUpdated::No, 0) .await } .boxed() @@ -1174,7 +1174,7 @@ mod tests { cache, 1, Default::default(), - EstimatorSource::Auction, + KeepPriceUpdated::Yes, ); // fill cache with 2 different queries @@ -1237,7 +1237,7 @@ mod tests { cache, 1, Default::default(), - EstimatorSource::Auction, + KeepPriceUpdated::Yes, ); let tokens: Vec<_> = (0..10).map(Address::with_last_byte).collect(); @@ -1305,7 +1305,7 @@ mod tests { cache, 1, Default::default(), - EstimatorSource::Auction, + KeepPriceUpdated::Yes, ); let tokens: Vec<_> = (0..BATCH_SIZE as u64).map(token).collect(); @@ -1344,23 +1344,11 @@ mod tests { NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()); cache.insert( t0, - CachedResult::new( - Ok(0.), - now, - now, - Default::default(), - EstimatorSource::Auction, - ), + CachedResult::new(Ok(0.), now, now, Default::default(), KeepPriceUpdated::Yes), ); cache.insert( t1, - CachedResult::new( - Ok(0.), - now, - now, - Default::default(), - EstimatorSource::Auction, - ), + CachedResult::new(Ok(0.), now, now, Default::default(), KeepPriceUpdated::Yes), ); let now = now + Duration::from_secs(1); From e3b9de0faa757cf52629952f2846c3d635b5fae5 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 11:08:20 +0000 Subject: [PATCH 18/42] Rename CacheLookup -> RequireUpdatingPrice --- .../price_estimation/native_price_cache.rs | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index c7f01ac388..d80973e536 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -31,20 +31,17 @@ pub enum KeepPriceUpdated { No, } -/// Specifies what cache modifications to perform during a lookup. +// This could be a bool but lets keep it as an enum for clarity. +/// Determines whether we need the price of the token to be +/// actively kept up to date by the maintenance task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CacheLookup { - /// Read-only access - no modifications to the cache. - /// Used by maintenance when checking if a price is already cached. - ReadOnly, - /// Upgrade existing Quote-sourced entry to Auction if applicable, but don't - /// create missing entries. Used by estimators when looking up cached - /// prices. - UpgradeOnly, - /// Create missing entry for maintenance (with Auction source) and upgrade - /// existing Quote→Auction. Used when building auctions to ensure missing - /// prices get fetched by the maintenance task. - CreateForMaintenance, +pub enum RequireUpdatingPrice { + /// The lookup does not care whether the price of the token + /// is actively being maintained. In other words the flag + /// of the token should not be changed. + DontCare, + /// The token will be marked to require active maintenance. + Yes, } #[derive(prometheus_metric_storage::MetricStorage)] @@ -193,7 +190,7 @@ impl NativePriceCache { &self, token: Address, now: Instant, - lookup: CacheLookup, + lookup: RequireUpdatingPrice, ) -> Option { let mut cache = self.inner.cache.lock().unwrap(); match cache.entry(token) { @@ -201,14 +198,7 @@ impl NativePriceCache { let cached = entry.get_mut(); cached.requested_at = now; - // Upgrade Quote to Auction if requested - this makes the entry - // actively maintained by the background task - if matches!( - lookup, - CacheLookup::UpgradeOnly | CacheLookup::CreateForMaintenance - ) && cached.source == KeepPriceUpdated::No - { - tracing::trace!(?token, "upgrading Quote-sourced entry to Auction"); + if lookup == RequireUpdatingPrice::Yes { cached.source = KeepPriceUpdated::Yes; } @@ -217,7 +207,7 @@ impl NativePriceCache { is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { - if lookup == CacheLookup::CreateForMaintenance { + if lookup == RequireUpdatingPrice::Yes { // Create an outdated cache entry so the background task keeping the cache warm // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. @@ -246,7 +236,7 @@ impl NativePriceCache { &self, token: Address, now: Instant, - lookup: CacheLookup, + lookup: RequireUpdatingPrice, ) -> Option { self.get_cached_price(token, now, lookup) .filter(|cached| cached.is_ready()) @@ -321,7 +311,7 @@ impl NativePriceCache { // check if the price is cached by now let now = Instant::now(); - match self.get_cached_price(*token, now, CacheLookup::ReadOnly) { + match self.get_cached_price(*token, now, RequireUpdatingPrice::DontCare) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -530,10 +520,9 @@ impl Inner { // check if the price is cached by now let now = Instant::now(); - // Upgrade Quote→Auction if this is an Auction-sourced estimator let lookup = match self.source { - KeepPriceUpdated::Yes => CacheLookup::UpgradeOnly, - KeepPriceUpdated::No => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, + KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, }; match self.cache.get_cached_price(*token, now, lookup) { Some(cached) if cached.is_ready() => { @@ -651,8 +640,8 @@ impl CachingNativePriceEstimator { // For Auction source: create missing entries and upgrade Quote→Auction // For Quote source: just read (Quote entries shouldn't be maintained) let lookup = match self.0.source { - KeepPriceUpdated::Yes => CacheLookup::CreateForMaintenance, - KeepPriceUpdated::No => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, + KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, }; for token in tokens { let cached = self @@ -721,8 +710,8 @@ impl NativePriceEstimating for CachingNativePriceEstimator { let now = Instant::now(); // Upgrade Quote→Auction if this is an Auction-sourced estimator let lookup = match self.0.source { - KeepPriceUpdated::Yes => CacheLookup::UpgradeOnly, - KeepPriceUpdated::No => CacheLookup::ReadOnly, + KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, + KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, }; let cached = self .0 @@ -778,11 +767,11 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { async move { let now = Instant::now(); // Quote source doesn't upgrade or create entries, just read - let cached = - self.0 - .0 - .cache - .get_ready_to_use_cached_price(token, now, CacheLookup::ReadOnly); + let cached = self.0.0.cache.get_ready_to_use_cached_price( + token, + now, + RequireUpdatingPrice::DontCare, + ); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() From 194ba72ecd49f6b2f3a9b092bddf4a983a6ec2bd Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 11:22:45 +0000 Subject: [PATCH 19/42] More renaming --- crates/autopilot/src/solvable_orders.rs | 8 +- crates/shared/src/price_estimation/factory.rs | 10 +- .../price_estimation/native_price_cache.rs | 116 ++++++++---------- 3 files changed, 59 insertions(+), 75 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index dd656e33de..7c7e46dd85 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -913,7 +913,7 @@ mod tests { HEALTHY_PRICE_ESTIMATION_TIME, PriceEstimationError, native::MockNativePriceEstimating, - native_price_cache::{KeepPriceUpdated, NativePriceCache}, + native_price_cache::{NativePriceCache, RequiresUpdatingPrices}, }, signature_validator::{MockSignatureValidating, SignatureValidationError}, }, @@ -962,7 +962,7 @@ mod tests { NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, Default::default(), - KeepPriceUpdated::default(), + RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1066,7 +1066,7 @@ mod tests { cache, 1, Default::default(), - KeepPriceUpdated::default(), + RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1161,7 +1161,7 @@ mod tests { 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), - KeepPriceUpdated::default(), + RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 62681eabe3..abbf03dc37 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -7,12 +7,7 @@ use { external::ExternalPriceEstimator, instrumented::InstrumentedPriceEstimator, native::{self, NativePriceEstimator}, - native_price_cache::{ - CachingNativePriceEstimator, - KeepPriceUpdated, - MaintenanceConfig, - NativePriceCache, - }, + native_price_cache::{CachingNativePriceEstimator, MaintenanceConfig, NativePriceCache}, sanitized::SanitizedPriceEstimator, trade_verifier::{TradeVerifier, TradeVerifying}, }, @@ -29,6 +24,7 @@ use { buffered::{self, BufferedRequest, NativePriceBatchFetching}, competition::PriceRanking, native::NativePriceEstimating, + native_price_cache::RequiresUpdatingPrices, }, tenderly_api::TenderlyCodeSimulator, token_info::TokenInfoFetching, @@ -432,7 +428,7 @@ impl<'a> PriceEstimatorFactory<'a> { cache, self.args.native_price_cache_concurrent_requests, approximation_tokens, - KeepPriceUpdated::Yes, + RequiresUpdatingPrices::Yes, )) } diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index d80973e536..4c78579be9 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -35,7 +35,7 @@ pub enum KeepPriceUpdated { /// Determines whether we need the price of the token to be /// actively kept up to date by the maintenance task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RequireUpdatingPrice { +pub enum RequiresUpdatingPrices { /// The lookup does not care whether the price of the token /// is actively being maintained. In other words the flag /// of the token should not be changed. @@ -190,7 +190,7 @@ impl NativePriceCache { &self, token: Address, now: Instant, - lookup: RequireUpdatingPrice, + require_updating_price: RequiresUpdatingPrices, ) -> Option { let mut cache = self.inner.cache.lock().unwrap(); match cache.entry(token) { @@ -198,8 +198,8 @@ impl NativePriceCache { let cached = entry.get_mut(); cached.requested_at = now; - if lookup == RequireUpdatingPrice::Yes { - cached.source = KeepPriceUpdated::Yes; + if require_updating_price == RequiresUpdatingPrices::Yes { + cached.update_price_continuously = KeepPriceUpdated::Yes; } let is_recent = @@ -207,7 +207,7 @@ impl NativePriceCache { is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { - if lookup == RequireUpdatingPrice::Yes { + if require_updating_price == RequiresUpdatingPrices::Yes { // Create an outdated cache entry so the background task keeping the cache warm // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. @@ -236,9 +236,9 @@ impl NativePriceCache { &self, token: Address, now: Instant, - lookup: RequireUpdatingPrice, + required_updating_price: RequiresUpdatingPrices, ) -> Option { - self.get_cached_price(token, now, lookup) + self.get_cached_price(token, now, required_updating_price) .filter(|cached| cached.is_ready()) } @@ -263,7 +263,7 @@ impl NativePriceCache { .unwrap() .iter() .filter(|(_, cached)| { - cached.source == KeepPriceUpdated::Yes + cached.update_price_continuously == KeepPriceUpdated::Yes && now.saturating_duration_since(cached.updated_at) > max_age }) .map(|(token, cached)| (*token, cached.requested_at)) @@ -311,7 +311,7 @@ impl NativePriceCache { // check if the price is cached by now let now = Instant::now(); - match self.get_cached_price(*token, now, RequireUpdatingPrice::DontCare) { + match self.get_cached_price(*token, now, RequiresUpdatingPrices::DontCare) { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -448,9 +448,7 @@ struct Inner { /// It's very important that the 2 tokens have the same number of decimals. /// After startup this is a read only value. approximation_tokens: HashMap, - /// Identifies which estimator type this is, used to track which - /// estimator fetched each cached entry for proper maintenance. - source: KeepPriceUpdated, + require_updating_prices: RequiresUpdatingPrices, } type CacheEntry = Result; @@ -461,8 +459,7 @@ struct CachedResult { updated_at: Instant, requested_at: Instant, accumulative_errors_count: u32, - /// Which estimator type fetched this entry. - source: KeepPriceUpdated, + update_price_continuously: KeepPriceUpdated, } /// Defines how many consecutive errors are allowed before the cache starts @@ -476,7 +473,7 @@ impl CachedResult { updated_at: Instant, requested_at: Instant, current_accumulative_errors_count: u32, - source: KeepPriceUpdated, + update_price_continuously: KeepPriceUpdated, ) -> Self { let estimator_internal_errors_count = matches!(result, Err(PriceEstimationError::EstimatorInternal(_))) @@ -488,7 +485,7 @@ impl CachedResult { updated_at, requested_at, accumulative_errors_count: estimator_internal_errors_count, - source, + update_price_continuously, } } @@ -520,11 +517,10 @@ impl Inner { // check if the price is cached by now let now = Instant::now(); - let lookup = match self.source { - KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, - KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, - }; - match self.cache.get_cached_price(*token, now, lookup) { + match self + .cache + .get_cached_price(*token, now, self.require_updating_prices) + { Some(cached) if cached.is_ready() => { return (*token, cached.result); } @@ -534,12 +530,7 @@ impl Inner { }; let result = self - .fetch_and_cache_price( - *token, - request_timeout, - self.source, - current_accumulative_errors_count, - ) + .fetch_and_cache_price(*token, request_timeout, current_accumulative_errors_count) .await; (*token, result) @@ -554,7 +545,6 @@ impl Inner { &self, token: Address, timeout: Duration, - source: KeepPriceUpdated, accumulative_errors_count: u32, ) -> NativePriceEstimateResult { let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); @@ -566,9 +556,19 @@ impl Inner { if should_cache(&result) { let now = Instant::now(); + let continuously_update_price = match self.require_updating_prices { + RequiresUpdatingPrices::Yes => KeepPriceUpdated::Yes, + RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, + }; self.cache.insert( token, - CachedResult::new(result.clone(), now, now, accumulative_errors_count, source), + CachedResult::new( + result.clone(), + now, + now, + accumulative_errors_count, + continuously_update_price, + ), ); } @@ -614,14 +614,14 @@ impl CachingNativePriceEstimator { cache: NativePriceCache, concurrent_requests: usize, approximation_tokens: HashMap, - source: KeepPriceUpdated, + require_updating_prices: RequiresUpdatingPrices, ) -> Self { Self(Arc::new(Inner { estimator, cache, concurrent_requests, approximation_tokens, - source, + require_updating_prices, })) } @@ -637,17 +637,12 @@ impl CachingNativePriceEstimator { ) -> HashMap> { let now = Instant::now(); let mut results = HashMap::default(); - // For Auction source: create missing entries and upgrade Quote→Auction - // For Quote source: just read (Quote entries shouldn't be maintained) - let lookup = match self.0.source { - KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, - KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, - }; for token in tokens { - let cached = self - .0 - .cache - .get_ready_to_use_cached_price(*token, now, lookup); + let cached = self.0.cache.get_ready_to_use_cached_price( + *token, + now, + self.0.require_updating_prices, + ); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -708,15 +703,11 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - // Upgrade Quote→Auction if this is an Auction-sourced estimator - let lookup = match self.0.source { - KeepPriceUpdated::Yes => RequireUpdatingPrice::Yes, - KeepPriceUpdated::No => RequireUpdatingPrice::DontCare, - }; - let cached = self - .0 - .cache - .get_ready_to_use_cached_price(token, now, lookup); + let cached = self.0.cache.get_ready_to_use_cached_price( + token, + now, + self.0.require_updating_prices, + ); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -770,7 +761,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { let cached = self.0.0.cache.get_ready_to_use_cached_price( token, now, - RequireUpdatingPrice::DontCare, + RequiresUpdatingPrices::DontCare, ); let label = if cached.is_some() { "hits" } else { "misses" }; @@ -783,10 +774,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { return cached.result; } - self.0 - .0 - .fetch_and_cache_price(token, timeout, KeepPriceUpdated::No, 0) - .await + self.0.0.fetch_and_cache_price(token, timeout, 0).await } .boxed() } @@ -828,7 +816,7 @@ mod tests { cache, 1, Default::default(), - Default::default(), + RequiresUpdatingPrices::Yes, ); { @@ -865,7 +853,7 @@ mod tests { ), 1, Default::default(), - Default::default(), + RequiresUpdatingPrices::Yes, ); for _ in 0..10 { @@ -907,7 +895,7 @@ mod tests { (Address::with_last_byte(1), Address::with_last_byte(100)), (Address::with_last_byte(2), Address::with_last_byte(200)), ]), - Default::default(), + RequiresUpdatingPrices::Yes, ); // no approximation token used for token 0 @@ -958,7 +946,7 @@ mod tests { ), 1, Default::default(), - Default::default(), + RequiresUpdatingPrices::Yes, ); for _ in 0..10 { @@ -1029,7 +1017,7 @@ mod tests { ), 1, Default::default(), - Default::default(), + RequiresUpdatingPrices::Yes, ); // First 3 calls: The cache is not used. Counter gets increased. @@ -1101,7 +1089,7 @@ mod tests { ), 1, Default::default(), - Default::default(), + RequiresUpdatingPrices::Yes, ); for _ in 0..10 { @@ -1163,7 +1151,7 @@ mod tests { cache, 1, Default::default(), - KeepPriceUpdated::Yes, + RequiresUpdatingPrices::Yes, ); // fill cache with 2 different queries @@ -1226,7 +1214,7 @@ mod tests { cache, 1, Default::default(), - KeepPriceUpdated::Yes, + RequiresUpdatingPrices::Yes, ); let tokens: Vec<_> = (0..10).map(Address::with_last_byte).collect(); @@ -1294,7 +1282,7 @@ mod tests { cache, 1, Default::default(), - KeepPriceUpdated::Yes, + RequiresUpdatingPrices::Yes, ); let tokens: Vec<_> = (0..BATCH_SIZE as u64).map(token).collect(); From 9641b1e4cde80ebe1f55d06fc64a2c19660a3188 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 11:27:48 +0000 Subject: [PATCH 20/42] reintroduce trace log --- crates/shared/src/price_estimation/native_price_cache.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 4c78579be9..60ff3f08ab 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -198,7 +198,10 @@ impl NativePriceCache { let cached = entry.get_mut(); cached.requested_at = now; - if require_updating_price == RequiresUpdatingPrices::Yes { + if cached.update_price_continuously == KeepPriceUpdated::No + && require_updating_price == RequiresUpdatingPrices::Yes + { + tracing::trace!(?token, "marking token for needing active maintenance"); cached.update_price_continuously = KeepPriceUpdated::Yes; } From 31d2302919eb2cd041f98eef8145d66a2693afc8 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 11:33:04 +0000 Subject: [PATCH 21/42] rename function --- .../src/price_estimation/native_price_cache.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 60ff3f08ab..e01adbcd59 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -250,10 +250,9 @@ impl NativePriceCache { self.inner.cache.lock().unwrap().insert(token, result); } - /// Get Auction-sourced tokens that need updating, sorted by priority. - /// Only returns tokens with Auction source since maintenance doesn't - /// refresh Quote-sourced entries. - fn sorted_auction_tokens_to_update( + /// Fetches all tokens that need to be updated sorted by the provided + /// priority. + fn prioritized_tokens_to_update( &self, max_age: Duration, now: Instant, @@ -389,7 +388,7 @@ impl CacheMaintenanceTask { let max_age = cache.max_age().saturating_sub(self.prefetch_time); let high_priority = cache.inner.high_priority.lock().unwrap().clone(); let mut outdated_entries = - cache.sorted_auction_tokens_to_update(max_age, Instant::now(), &high_priority); + cache.prioritized_tokens_to_update(max_age, Instant::now(), &high_priority); tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated auction prices to fetch"); @@ -1336,14 +1335,14 @@ mod tests { let high_priority: IndexSet
= std::iter::once(t0).collect(); cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_auction_tokens_to_update(Duration::from_secs(0), now, &high_priority); + cache.prioritized_tokens_to_update(Duration::from_secs(0), now, &high_priority); assert_eq!(tokens[0], t0); assert_eq!(tokens[1], t1); let high_priority: IndexSet
= std::iter::once(t1).collect(); cache.replace_high_priority(high_priority.clone()); let tokens = - cache.sorted_auction_tokens_to_update(Duration::from_secs(0), now, &high_priority); + cache.prioritized_tokens_to_update(Duration::from_secs(0), now, &high_priority); assert_eq!(tokens[0], t1); assert_eq!(tokens[1], t0); } From dee4b92ba07a31b2faef53fefd386274b81f730e Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 12:00:29 +0000 Subject: [PATCH 22/42] Drop confusing Inner type --- .../price_estimation/native_price_cache.rs | 196 ++++++++---------- 1 file changed, 92 insertions(+), 104 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index e01adbcd59..ae3d4e1fee 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -433,10 +433,7 @@ impl CacheMaintenanceTask { /// The size of the underlying cache is unbounded. /// /// Is an Arc internally. -#[derive(Clone)] -pub struct CachingNativePriceEstimator(Arc); - -struct Inner { +pub struct CachingNativePriceEstimator { cache: NativePriceCache, estimator: Arc, concurrent_requests: usize, @@ -500,84 +497,6 @@ impl CachedResult { } } -impl Inner { - /// Checks cache for the given tokens one by one. If the price is already - /// cached, it gets returned. If it's not in the cache, a new price - /// estimation request gets issued. We check the cache before each - /// request because they can take a long time and some other task might - /// have fetched some requested price in the meantime. - /// - /// If this estimator has Auction source and the cached entry has Quote - /// source, the entry is upgraded to Auction source. - fn estimate_prices_and_update_cache<'a>( - &'a self, - tokens: &'a [Address], - request_timeout: Duration, - ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { - let estimates = tokens.iter().map(move |token| async move { - let current_accumulative_errors_count = { - // check if the price is cached by now - let now = Instant::now(); - - match self - .cache - .get_cached_price(*token, now, self.require_updating_prices) - { - Some(cached) if cached.is_ready() => { - return (*token, cached.result); - } - Some(cached) => cached.accumulative_errors_count, - None => Default::default(), - } - }; - - let result = self - .fetch_and_cache_price(*token, request_timeout, current_accumulative_errors_count) - .await; - - (*token, result) - }); - futures::stream::iter(estimates) - .buffered(self.concurrent_requests) - .boxed() - } - - /// Fetches a single price and caches it. - async fn fetch_and_cache_price( - &self, - token: Address, - timeout: Duration, - accumulative_errors_count: u32, - ) -> NativePriceEstimateResult { - let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); - - let result = self - .estimator - .estimate_native_price(token_to_fetch, timeout) - .await; - - if should_cache(&result) { - let now = Instant::now(); - let continuously_update_price = match self.require_updating_prices { - RequiresUpdatingPrices::Yes => KeepPriceUpdated::Yes, - RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, - }; - self.cache.insert( - token, - CachedResult::new( - result.clone(), - now, - now, - accumulative_errors_count, - continuously_update_price, - ), - ); - } - - result - } -} - fn should_cache(result: &Result) -> bool { // We don't want to cache errors that we consider transient match result { @@ -599,7 +518,7 @@ impl CachingNativePriceEstimator { /// Returns a reference to the underlying shared cache. /// This can be used to share the cache with other estimator instances. pub fn cache(&self) -> &NativePriceCache { - &self.0.cache + &self.cache } /// Creates a new CachingNativePriceEstimator. @@ -618,13 +537,13 @@ impl CachingNativePriceEstimator { approximation_tokens: HashMap, require_updating_prices: RequiresUpdatingPrices, ) -> Self { - Self(Arc::new(Inner { + Self { estimator, cache, concurrent_requests, approximation_tokens, require_updating_prices, - })) + } } /// Only returns prices that are currently cached. Missing prices will get @@ -640,11 +559,9 @@ impl CachingNativePriceEstimator { let now = Instant::now(); let mut results = HashMap::default(); for token in tokens { - let cached = self.0.cache.get_ready_to_use_cached_price( - *token, - now, - self.0.require_updating_prices, - ); + let cached = + self.cache + .get_ready_to_use_cached_price(*token, now, self.require_updating_prices); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() .native_price_cache_access @@ -660,7 +577,7 @@ impl CachingNativePriceEstimator { /// Updates the set of high-priority tokens for maintenance updates. /// Forwards to the underlying cache. pub fn replace_high_priority(&self, tokens: IndexSet
) { - self.0.cache.replace_high_priority(tokens); + self.cache.replace_high_priority(tokens); } pub async fn estimate_native_prices_with_timeout<'a>( @@ -678,9 +595,7 @@ impl CachingNativePriceEstimator { .filter(|t| !prices.contains_key(*t)) .copied() .collect(); - let price_stream = self - .0 - .estimate_prices_and_update_cache(&uncached_tokens, timeout); + let price_stream = self.estimate_prices_and_update_cache(&uncached_tokens, timeout); let _ = time::timeout(timeout, async { let mut price_stream = price_stream; @@ -694,6 +609,82 @@ impl CachingNativePriceEstimator { // Return whatever was collected up to that point, regardless of the timeout prices } + + /// Checks cache for the given tokens one by one. If the price is already + /// cached, it gets returned. If it's not in the cache, a new price + /// estimation request gets issued. We check the cache before each + /// request because they can take a long time and some other task might + /// have fetched some requested price in the meantime. + /// + /// If this estimator has Auction source and the cached entry has Quote + /// source, the entry is upgraded to Auction source. + fn estimate_prices_and_update_cache<'a>( + &'a self, + tokens: &'a [Address], + request_timeout: Duration, + ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { + let estimates = tokens.iter().map(move |token| async move { + let current_accumulative_errors_count = { + // check if the price is cached by now + let now = Instant::now(); + + match self + .cache + .get_cached_price(*token, now, self.require_updating_prices) + { + Some(cached) if cached.is_ready() => { + return (*token, cached.result); + } + Some(cached) => cached.accumulative_errors_count, + None => Default::default(), + } + }; + + let result = self + .fetch_and_cache_price(*token, request_timeout, current_accumulative_errors_count) + .await; + + (*token, result) + }); + futures::stream::iter(estimates) + .buffered(self.concurrent_requests) + .boxed() + } + + /// Fetches a single price and caches it. + async fn fetch_and_cache_price( + &self, + token: Address, + timeout: Duration, + accumulative_errors_count: u32, + ) -> NativePriceEstimateResult { + let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); + + let result = self + .estimator + .estimate_native_price(token_to_fetch, timeout) + .await; + + if should_cache(&result) { + let now = Instant::now(); + let continuously_update_price = match self.require_updating_prices { + RequiresUpdatingPrices::Yes => KeepPriceUpdated::Yes, + RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, + }; + self.cache.insert( + token, + CachedResult::new( + result.clone(), + now, + now, + accumulative_errors_count, + continuously_update_price, + ), + ); + } + + result + } } impl NativePriceEstimating for CachingNativePriceEstimator { @@ -705,11 +696,9 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = self.0.cache.get_ready_to_use_cached_price( - token, - now, - self.0.require_updating_prices, - ); + let cached = + self.cache + .get_ready_to_use_cached_price(token, now, self.require_updating_prices); let label = if cached.is_some() { "hits" } else { "misses" }; Metrics::get() @@ -721,8 +710,7 @@ impl NativePriceEstimating for CachingNativePriceEstimator { return cached.result; } - self.0 - .estimate_prices_and_update_cache(&[token], timeout) + self.estimate_prices_and_update_cache(&[token], timeout) .next() .await .unwrap() @@ -760,7 +748,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { async move { let now = Instant::now(); // Quote source doesn't upgrade or create entries, just read - let cached = self.0.0.cache.get_ready_to_use_cached_price( + let cached = self.0.cache.get_ready_to_use_cached_price( token, now, RequiresUpdatingPrices::DontCare, @@ -776,7 +764,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { return cached.result; } - self.0.0.fetch_and_cache_price(token, timeout, 0).await + self.0.fetch_and_cache_price(token, timeout, 0).await } .boxed() } @@ -824,7 +812,7 @@ mod tests { { // Check that `updated_at` timestamps are initialized with // reasonable values. - let cache = estimator.0.cache.inner.cache.lock().unwrap(); + let cache = estimator.cache.inner.cache.lock().unwrap(); for value in cache.values() { let elapsed = value.updated_at.elapsed(); assert!(elapsed >= min_age && elapsed <= max_age); From aeb528d2ac4fe1be5533770cbacba662c6d11636 Mon Sep 17 00:00:00 2001 From: MartinquaXD Date: Thu, 22 Jan 2026 12:27:26 +0000 Subject: [PATCH 23/42] Added some review comments --- crates/shared/src/price_estimation/native_price_cache.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index ae3d4e1fee..a617f10193 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -81,6 +81,8 @@ pub struct MaintenanceConfig { pub quote_timeout: Duration, } +// TODO: AFAICS occurences of `NativePriceCache` could be replaced with `Arc` +// if the methods of `NativePriceCache` get moved to more appropariate places. /// A cache storage for native price estimates. /// /// Can be shared between multiple `CachingNativePriceEstimator` instances, @@ -296,6 +298,9 @@ impl NativePriceCache { tokio::spawn(update_task); } + // TODO: I think it should be possible to unify this with `estimate_prices_and_update_cache`. + // Not sure why the new function suddenly has to exist. + // /// Estimates prices for the given tokens and updates the cache. /// Used by the background maintenance task. All tokens are processed using /// the provided estimator and marked as Auction source. @@ -559,6 +564,9 @@ impl CachingNativePriceEstimator { let now = Instant::now(); let mut results = HashMap::default(); for token in tokens { + // TODO: this is currently locking the cache once per token which + // has terrible performance. The original functions specifically + // pass `MutexGuards` around for that reason. let cached = self.cache .get_ready_to_use_cached_price(*token, now, self.require_updating_prices); From 7f139e0663ad3185e7b9ed43bd459dbacf30309c Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 22 Jan 2026 14:17:31 +0000 Subject: [PATCH 24/42] Get rid of NativePriceCache --- crates/autopilot/src/solvable_orders.rs | 8 +- crates/shared/src/price_estimation/factory.rs | 6 +- .../price_estimation/native_price_cache.rs | 121 ++++++++---------- 3 files changed, 57 insertions(+), 78 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 7c7e46dd85..81adf00b9f 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -913,7 +913,7 @@ mod tests { HEALTHY_PRICE_ESTIMATION_TIME, PriceEstimationError, native::MockNativePriceEstimating, - native_price_cache::{NativePriceCache, RequiresUpdatingPrices}, + native_price_cache::{CacheStorage, RequiresUpdatingPrices}, }, signature_validator::{MockSignatureValidating, SignatureValidationError}, }, @@ -959,7 +959,7 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new( Arc::new(native_price_estimator), - NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), + CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, Default::default(), RequiresUpdatingPrices::Yes, @@ -1048,7 +1048,7 @@ mod tests { let maintenance_estimator: Arc< dyn shared::price_estimation::native::NativePriceEstimating, > = Arc::new(native_price_estimator); - let cache = NativePriceCache::new_with_maintenance( + let cache = CacheStorage::new_with_maintenance( Duration::from_secs(10), Default::default(), shared::price_estimation::native_price_cache::MaintenanceConfig { @@ -1157,7 +1157,7 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new( Arc::new(native_price_estimator), - NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()), + CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()), 3, // Set to use native price approximations for the following tokens HashMap::from([(token1, token_approx1), (token2, token_approx2)]), diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index abbf03dc37..218c21336a 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -7,7 +7,7 @@ use { external::ExternalPriceEstimator, instrumented::InstrumentedPriceEstimator, native::{self, NativePriceEstimator}, - native_price_cache::{CachingNativePriceEstimator, MaintenanceConfig, NativePriceCache}, + native_price_cache::{CacheStorage, CachingNativePriceEstimator, MaintenanceConfig}, sanitized::SanitizedPriceEstimator, trade_verifier::{TradeVerifier, TradeVerifying}, }, @@ -392,7 +392,7 @@ impl<'a> PriceEstimatorFactory<'a> { // Create cache with background maintenance, which only refreshes // Auction-sourced entries - let cache = NativePriceCache::new_with_maintenance( + let cache = CacheStorage::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, MaintenanceConfig { @@ -414,7 +414,7 @@ impl<'a> PriceEstimatorFactory<'a> { fn wrap_with_cache( &self, estimator: Arc, - cache: NativePriceCache, + cache: Arc, ) -> Arc { let approximation_tokens = self .args diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index a617f10193..e9e05990a4 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -81,30 +81,28 @@ pub struct MaintenanceConfig { pub quote_timeout: Duration, } -// TODO: AFAICS occurences of `NativePriceCache` could be replaced with `Arc` -// if the methods of `NativePriceCache` get moved to more appropariate places. +/// Type alias for backwards compatibility. +/// `NativePriceCache` is now `Arc` with methods on +/// `CacheStorage`. +pub type NativePriceCache = Arc; + /// A cache storage for native price estimates. /// /// Can be shared between multiple `CachingNativePriceEstimator` instances, /// allowing them to read/write from the same cache while using different /// price estimation sources. -#[derive(Clone)] -pub struct NativePriceCache { - inner: Arc, -} - -struct CacheStorage { +pub struct CacheStorage { cache: Mutex>, max_age: Duration, /// Tokens that should be prioritized during maintenance updates. high_priority: Mutex>, } -impl NativePriceCache { +impl CacheStorage { /// Creates a new cache with the given max age for entries and initial /// prices. Entries are initialized with random ages to avoid expiration /// spikes. - fn new(max_age: Duration, initial_prices: HashMap) -> Self { + fn new(max_age: Duration, initial_prices: HashMap) -> Arc { let mut rng = rand::thread_rng(); let now = std::time::Instant::now(); @@ -130,13 +128,11 @@ impl NativePriceCache { }) .collect::>(); - Self { - inner: Arc::new(CacheStorage { - cache: Mutex::new(cache), - max_age, - high_priority: Default::default(), - }), - } + Arc::new(Self { + cache: Mutex::new(cache), + max_age, + high_priority: Default::default(), + }) } /// Creates a new cache with background maintenance task. @@ -147,9 +143,9 @@ impl NativePriceCache { max_age: Duration, initial_prices: HashMap, config: MaintenanceConfig, - ) -> Self { + ) -> Arc { let cache = Self::new(max_age, initial_prices); - cache.spawn_maintenance_task(config); + spawn_maintenance_task(&cache, config); cache } @@ -161,23 +157,23 @@ impl NativePriceCache { pub fn new_without_maintenance( max_age: Duration, initial_prices: HashMap, - ) -> Self { + ) -> Arc { Self::new(max_age, initial_prices) } /// Returns the max age configuration for this cache. pub fn max_age(&self) -> Duration { - self.inner.max_age + self.max_age } /// Returns the number of entries in the cache. pub fn len(&self) -> usize { - self.inner.cache.lock().unwrap().len() + self.cache.lock().unwrap().len() } /// Returns true if the cache is empty. pub fn is_empty(&self) -> bool { - self.inner.cache.lock().unwrap().is_empty() + self.cache.lock().unwrap().is_empty() } /// Get a cached price with optional cache modifications. @@ -194,7 +190,7 @@ impl NativePriceCache { now: Instant, require_updating_price: RequiresUpdatingPrices, ) -> Option { - let mut cache = self.inner.cache.lock().unwrap(); + let mut cache = self.cache.lock().unwrap(); match cache.entry(token) { Entry::Occupied(mut entry) => { let cached = entry.get_mut(); @@ -207,8 +203,7 @@ impl NativePriceCache { cached.update_price_continuously = KeepPriceUpdated::Yes; } - let is_recent = - now.saturating_duration_since(cached.updated_at) < self.inner.max_age; + let is_recent = now.saturating_duration_since(cached.updated_at) < self.max_age; is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { @@ -217,7 +212,7 @@ impl NativePriceCache { // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. // Otherwise malicious actors could easily cause the cache size to blow up. - let outdated_timestamp = now.checked_sub(self.inner.max_age).unwrap_or(now); + let outdated_timestamp = now.checked_sub(self.max_age).unwrap_or(now); tracing::trace!(?token, "create outdated price entry"); entry.insert(CachedResult::new( Ok(0.), @@ -249,7 +244,7 @@ impl NativePriceCache { /// Insert or update a cached result. fn insert(&self, token: Address, result: CachedResult) { - self.inner.cache.lock().unwrap().insert(token, result); + self.cache.lock().unwrap().insert(token, result); } /// Fetches all tokens that need to be updated sorted by the provided @@ -261,7 +256,6 @@ impl NativePriceCache { high_priority: &IndexSet
, ) -> Vec
{ let mut outdated: Vec<_> = self - .inner .cache .lock() .unwrap() @@ -287,19 +281,12 @@ impl NativePriceCache { /// High-priority tokens are refreshed before other tokens in the cache. pub fn replace_high_priority(&self, tokens: IndexSet
) { tracing::trace!(?tokens, "updated high priority tokens in cache"); - *self.inner.high_priority.lock().unwrap() = tokens; - } - - /// Spawns a background maintenance task for this cache. - fn spawn_maintenance_task(&self, config: MaintenanceConfig) { - let update_task = CacheMaintenanceTask::new(Arc::downgrade(&self.inner), config) - .run() - .instrument(tracing::info_span!("native_price_cache_maintenance")); - tokio::spawn(update_task); + *self.high_priority.lock().unwrap() = tokens; } - // TODO: I think it should be possible to unify this with `estimate_prices_and_update_cache`. - // Not sure why the new function suddenly has to exist. + // TODO: I think it should be possible to unify this with + // `estimate_prices_and_update_cache`. Not sure why the new function + // suddenly has to exist. // /// Estimates prices for the given tokens and updates the cache. /// Used by the background maintenance task. All tokens are processed using @@ -355,6 +342,14 @@ impl NativePriceCache { } } +/// Spawns a background maintenance task for the given cache. +fn spawn_maintenance_task(cache: &Arc, config: MaintenanceConfig) { + let update_task = CacheMaintenanceTask::new(Arc::downgrade(cache), config) + .run() + .instrument(tracing::info_span!("native_price_cache_maintenance")); + tokio::spawn(update_task); +} + /// Background task that keeps the cache warm by periodically refreshing prices. /// Only refreshes Auction-sourced entries; Quote-sourced entries are cached /// but not maintained. @@ -384,14 +379,14 @@ impl CacheMaintenanceTask { /// Single run of the background updating process. /// Only updates Auction-sourced entries; Quote-sourced entries are skipped. - async fn single_update(&self, cache: &NativePriceCache) { + async fn single_update(&self, cache: &Arc) { let metrics = Metrics::get(); metrics .native_price_cache_size .set(i64::try_from(cache.len()).unwrap_or(i64::MAX)); let max_age = cache.max_age().saturating_sub(self.prefetch_time); - let high_priority = cache.inner.high_priority.lock().unwrap().clone(); + let high_priority = cache.high_priority.lock().unwrap().clone(); let mut outdated_entries = cache.prioritized_tokens_to_update(max_age, Instant::now(), &high_priority); @@ -422,8 +417,7 @@ impl CacheMaintenanceTask { /// Runs background updates until the cache is no longer alive. async fn run(self) { - while let Some(inner) = self.cache.upgrade() { - let cache = NativePriceCache { inner }; + while let Some(cache) = self.cache.upgrade() { let now = Instant::now(); self.single_update(&cache).await; tokio::time::sleep(self.update_interval.saturating_sub(now.elapsed())).await; @@ -522,7 +516,7 @@ fn should_cache(result: &Result) -> bool { impl CachingNativePriceEstimator { /// Returns a reference to the underlying shared cache. /// This can be used to share the cache with other estimator instances. - pub fn cache(&self) -> &NativePriceCache { + pub fn cache(&self) -> &Arc { &self.cache } @@ -808,7 +802,7 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); let cache = - NativePriceCache::new_without_maintenance(Duration::from_secs(MAX_AGE_SECS), prices); + CacheStorage::new_without_maintenance(Duration::from_secs(MAX_AGE_SECS), prices); let estimator = CachingNativePriceEstimator::new( Arc::new(inner), cache, @@ -820,7 +814,7 @@ mod tests { { // Check that `updated_at` timestamps are initialized with // reasonable values. - let cache = estimator.cache.inner.cache.lock().unwrap(); + let cache = estimator.cache.cache.lock().unwrap(); for value in cache.values() { let elapsed = value.updated_at.elapsed(); assert!(elapsed >= min_age && elapsed <= max_age); @@ -845,10 +839,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - NativePriceCache::new_without_maintenance( - Duration::from_millis(30), - Default::default(), - ), + CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), 1, Default::default(), RequiresUpdatingPrices::Yes, @@ -883,10 +874,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - NativePriceCache::new_without_maintenance( - Duration::from_millis(30), - Default::default(), - ), + CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), 1, // set token approximations for tokens 1 and 2 HashMap::from([ @@ -938,10 +926,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - NativePriceCache::new_without_maintenance( - Duration::from_millis(30), - Default::default(), - ), + CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), 1, Default::default(), RequiresUpdatingPrices::Yes, @@ -1009,10 +994,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - NativePriceCache::new_without_maintenance( - Duration::from_millis(100), - Default::default(), - ), + CacheStorage::new_without_maintenance(Duration::from_millis(100), Default::default()), 1, Default::default(), RequiresUpdatingPrices::Yes, @@ -1081,10 +1063,7 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - NativePriceCache::new_without_maintenance( - Duration::from_millis(30), - Default::default(), - ), + CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), 1, Default::default(), RequiresUpdatingPrices::Yes, @@ -1131,7 +1110,7 @@ mod tests { async { Ok(4.0) }.boxed() }); - let cache = NativePriceCache::new_with_maintenance( + let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), MaintenanceConfig { @@ -1194,7 +1173,7 @@ mod tests { .times(10) .returning(move |_, _| async { Ok(2.0) }.boxed()); - let cache = NativePriceCache::new_with_maintenance( + let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), MaintenanceConfig { @@ -1262,7 +1241,7 @@ mod tests { .boxed() }); - let cache = NativePriceCache::new_with_maintenance( + let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), MaintenanceConfig { @@ -1316,7 +1295,7 @@ mod tests { // Create a cache and populate it directly with Auction-sourced entries // (since maintenance only updates Auction entries) let cache = - NativePriceCache::new_without_maintenance(Duration::from_secs(10), Default::default()); + CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()); cache.insert( t0, CachedResult::new(Ok(0.), now, now, Default::default(), KeepPriceUpdated::Yes), From 82aff38c048b7ac36dff3f7d3641e492247d7bcd Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 22 Jan 2026 14:31:28 +0000 Subject: [PATCH 25/42] Improve lock performance --- .../price_estimation/native_price_cache.rs | 86 +++++++++++++++---- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index e9e05990a4..651e3ad2aa 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -191,6 +191,19 @@ impl CacheStorage { require_updating_price: RequiresUpdatingPrices, ) -> Option { let mut cache = self.cache.lock().unwrap(); + Self::get_cached_price_inner(&mut cache, token, now, require_updating_price, self.max_age) + } + + /// Inner implementation of cache lookup that works with an already-locked + /// cache. This allows both single and batch lookups to share the same + /// logic. + fn get_cached_price_inner( + cache: &mut HashMap, + token: Address, + now: Instant, + require_updating_price: RequiresUpdatingPrices, + max_age: Duration, + ) -> Option { match cache.entry(token) { Entry::Occupied(mut entry) => { let cached = entry.get_mut(); @@ -203,7 +216,7 @@ impl CacheStorage { cached.update_price_continuously = KeepPriceUpdated::Yes; } - let is_recent = now.saturating_duration_since(cached.updated_at) < self.max_age; + let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; is_recent.then_some(cached.clone()) } Entry::Vacant(entry) => { @@ -212,7 +225,7 @@ impl CacheStorage { // will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. // Otherwise malicious actors could easily cause the cache size to blow up. - let outdated_timestamp = now.checked_sub(self.max_age).unwrap_or(now); + let outdated_timestamp = now.checked_sub(max_age).unwrap_or(now); tracing::trace!(?token, "create outdated price entry"); entry.insert(CachedResult::new( Ok(0.), @@ -242,6 +255,37 @@ impl CacheStorage { .filter(|cached| cached.is_ready()) } + /// Batch version of `get_ready_to_use_cached_price` that acquires the lock + /// once for all tokens, improving performance when looking up multiple + /// prices. + /// + /// Returns a HashMap of token addresses to their cached results (only for + /// tokens that have valid, ready-to-use cached prices). + fn get_ready_to_use_cached_prices( + &self, + tokens: &[Address], + now: Instant, + require_updating_price: RequiresUpdatingPrices, + ) -> HashMap { + let mut cache = self.cache.lock().unwrap(); + let mut results = HashMap::with_capacity(tokens.len()); + + for token in tokens { + let cached_result = Self::get_cached_price_inner( + &mut cache, + *token, + now, + require_updating_price, + self.max_age, + ); + if let Some(cached) = cached_result.filter(|c| c.is_ready()) { + results.insert(*token, cached); + } + } + + results + } + /// Insert or update a cached result. fn insert(&self, token: Address, result: CachedResult) { self.cache.lock().unwrap().insert(token, result); @@ -556,24 +600,30 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - let mut results = HashMap::default(); - for token in tokens { - // TODO: this is currently locking the cache once per token which - // has terrible performance. The original functions specifically - // pass `MutexGuards` around for that reason. - let cached = - self.cache - .get_ready_to_use_cached_price(*token, now, self.require_updating_prices); - let label = if cached.is_some() { "hits" } else { "misses" }; - Metrics::get() + let cached_results = + self.cache + .get_ready_to_use_cached_prices(tokens, now, self.require_updating_prices); + + let hits = cached_results.len(); + let misses = tokens.len().saturating_sub(hits); + let metrics = Metrics::get(); + if hits > 0 { + metrics .native_price_cache_access - .with_label_values(&[label]) - .inc_by(1); - if let Some(result) = cached { - results.insert(*token, result.result); - } + .with_label_values(&["hits"]) + .inc_by(hits as u64); } - results + if misses > 0 { + metrics + .native_price_cache_access + .with_label_values(&["misses"]) + .inc_by(misses as u64); + } + + cached_results + .into_iter() + .map(|(token, cached)| (token, cached.result)) + .collect() } /// Updates the set of high-priority tokens for maintenance updates. From ccfca8673665c4f5464ae45dc76ca4606fe2388d Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 22 Jan 2026 16:10:48 +0000 Subject: [PATCH 26/42] Refactor --- .../price_estimation/native_price_cache.rs | 174 +++++++++--------- 1 file changed, 86 insertions(+), 88 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 651e3ad2aa..26fad4d326 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -328,10 +328,52 @@ impl CacheStorage { *self.high_priority.lock().unwrap() = tokens; } - // TODO: I think it should be possible to unify this with - // `estimate_prices_and_update_cache`. Not sure why the new function - // suddenly has to exist. - // + /// Helper for estimating a price with cache check and update. + /// + /// Returns early if a valid cached price exists, otherwise calls the + /// provided fetch function and caches the result. + /// + /// This is the core logic shared by both on-demand price fetching and + /// background maintenance. + async fn estimate_with_cache_update( + &self, + token: Address, + require_updating_price: RequiresUpdatingPrices, + keep_updated: KeepPriceUpdated, + fetch: F, + ) -> NativePriceEstimateResult + where + F: FnOnce(u32) -> Fut, + Fut: std::future::Future, + { + let current_accumulative_errors_count = { + let now = Instant::now(); + match self.get_cached_price(token, now, require_updating_price) { + Some(cached) if cached.is_ready() => return cached.result, + Some(cached) => cached.accumulative_errors_count, + None => Default::default(), + } + }; + + let result = fetch(current_accumulative_errors_count).await; + + if should_cache(&result) { + let now = Instant::now(); + self.insert( + token, + CachedResult::new( + result.clone(), + now, + now, + current_accumulative_errors_count, + keep_updated, + ), + ); + } + + result + } + /// Estimates prices for the given tokens and updates the cache. /// Used by the background maintenance task. All tokens are processed using /// the provided estimator and marked as Auction source. @@ -344,40 +386,17 @@ impl CacheStorage { ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { let estimates = tokens.iter().map(move |token| { let estimator = estimator.clone(); + let token = *token; async move { - let current_accumulative_errors_count = { - // check if the price is cached by now - let now = Instant::now(); - - match self.get_cached_price(*token, now, RequiresUpdatingPrices::DontCare) { - Some(cached) if cached.is_ready() => { - return (*token, cached.result); - } - Some(cached) => cached.accumulative_errors_count, - None => Default::default(), - } - }; - - let result = estimator - .estimate_native_price(*token, request_timeout) + let result = self + .estimate_with_cache_update( + token, + RequiresUpdatingPrices::DontCare, + KeepPriceUpdated::Yes, + |_| estimator.estimate_native_price(token, request_timeout), + ) .await; - - // update price in cache with Auction source - if should_cache(&result) { - let now = Instant::now(); - self.insert( - *token, - CachedResult::new( - result.clone(), - now, - now, - current_accumulative_errors_count, - KeepPriceUpdated::Yes, - ), - ); - }; - - (*token, result) + (token, result) } }); futures::stream::iter(estimates) @@ -675,67 +694,37 @@ impl CachingNativePriceEstimator { tokens: &'a [Address], request_timeout: Duration, ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { - let estimates = tokens.iter().map(move |token| async move { - let current_accumulative_errors_count = { - // check if the price is cached by now - let now = Instant::now(); + let keep_updated = match self.require_updating_prices { + RequiresUpdatingPrices::Yes => KeepPriceUpdated::Yes, + RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, + }; - match self + let estimates = tokens.iter().map(move |token| { + let token = *token; + async move { + let result = self .cache - .get_cached_price(*token, now, self.require_updating_prices) - { - Some(cached) if cached.is_ready() => { - return (*token, cached.result); - } - Some(cached) => cached.accumulative_errors_count, - None => Default::default(), - } - }; - - let result = self - .fetch_and_cache_price(*token, request_timeout, current_accumulative_errors_count) - .await; - - (*token, result) + .estimate_with_cache_update( + token, + self.require_updating_prices, + keep_updated, + |_| self.fetch_price(token, request_timeout), + ) + .await; + (token, result) + } }); futures::stream::iter(estimates) .buffered(self.concurrent_requests) .boxed() } - /// Fetches a single price and caches it. - async fn fetch_and_cache_price( - &self, - token: Address, - timeout: Duration, - accumulative_errors_count: u32, - ) -> NativePriceEstimateResult { + /// Fetches a single price (without caching). + async fn fetch_price(&self, token: Address, timeout: Duration) -> NativePriceEstimateResult { let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); - - let result = self - .estimator + self.estimator .estimate_native_price(token_to_fetch, timeout) - .await; - - if should_cache(&result) { - let now = Instant::now(); - let continuously_update_price = match self.require_updating_prices { - RequiresUpdatingPrices::Yes => KeepPriceUpdated::Yes, - RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, - }; - self.cache.insert( - token, - CachedResult::new( - result.clone(), - now, - now, - accumulative_errors_count, - continuously_update_price, - ), - ); - } - - result + .await } } @@ -816,7 +805,16 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { return cached.result; } - self.0.fetch_and_cache_price(token, timeout, 0).await + // Quote source: cache the result but don't mark for active maintenance + self.0 + .cache + .estimate_with_cache_update( + token, + RequiresUpdatingPrices::DontCare, + KeepPriceUpdated::No, + |_| self.0.fetch_price(token, timeout), + ) + .await } .boxed() } From d93e55bd6b4d3062e54750cee6b76d09525d134d Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 30 Jan 2026 09:42:02 +0000 Subject: [PATCH 27/42] Review comments --- .../price_estimation/native_price_cache.rs | 122 +++++------------- 1 file changed, 33 insertions(+), 89 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 26fad4d326..b8c72bedb8 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -244,46 +244,24 @@ impl CacheStorage { /// state). /// /// Returns None if the price is not cached, is expired, or is not ready to - /// use. + /// use. Also updates cache access metrics (hits/misses). fn get_ready_to_use_cached_price( &self, token: Address, now: Instant, required_updating_price: RequiresUpdatingPrices, ) -> Option { - self.get_cached_price(token, now, required_updating_price) - .filter(|cached| cached.is_ready()) - } + let cached = self + .get_cached_price(token, now, required_updating_price) + .filter(|cached| cached.is_ready()); - /// Batch version of `get_ready_to_use_cached_price` that acquires the lock - /// once for all tokens, improving performance when looking up multiple - /// prices. - /// - /// Returns a HashMap of token addresses to their cached results (only for - /// tokens that have valid, ready-to-use cached prices). - fn get_ready_to_use_cached_prices( - &self, - tokens: &[Address], - now: Instant, - require_updating_price: RequiresUpdatingPrices, - ) -> HashMap { - let mut cache = self.cache.lock().unwrap(); - let mut results = HashMap::with_capacity(tokens.len()); + let label = if cached.is_some() { "hits" } else { "misses" }; + Metrics::get() + .native_price_cache_access + .with_label_values(&[label]) + .inc_by(1); - for token in tokens { - let cached_result = Self::get_cached_price_inner( - &mut cache, - *token, - now, - require_updating_price, - self.max_age, - ); - if let Some(cached) = cached_result.filter(|c| c.is_ready()) { - results.insert(*token, cached); - } - } - - results + cached } /// Insert or update a cached result. @@ -619,29 +597,13 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - let cached_results = - self.cache - .get_ready_to_use_cached_prices(tokens, now, self.require_updating_prices); - - let hits = cached_results.len(); - let misses = tokens.len().saturating_sub(hits); - let metrics = Metrics::get(); - if hits > 0 { - metrics - .native_price_cache_access - .with_label_values(&["hits"]) - .inc_by(hits as u64); - } - if misses > 0 { - metrics - .native_price_cache_access - .with_label_values(&["misses"]) - .inc_by(misses as u64); - } - - cached_results - .into_iter() - .map(|(token, cached)| (token, cached.result)) + tokens + .iter() + .filter_map(|token| { + self.cache + .get_ready_to_use_cached_price(*token, now, self.require_updating_prices) + .map(|cached| (*token, cached.result)) + }) .collect() } @@ -699,20 +661,17 @@ impl CachingNativePriceEstimator { RequiresUpdatingPrices::DontCare => KeepPriceUpdated::No, }; - let estimates = tokens.iter().map(move |token| { - let token = *token; - async move { - let result = self - .cache - .estimate_with_cache_update( - token, - self.require_updating_prices, - keep_updated, - |_| self.fetch_price(token, request_timeout), - ) - .await; - (token, result) - } + let estimates = tokens.iter().cloned().map(move |token| async move { + let result = self + .cache + .estimate_with_cache_update( + token, + self.require_updating_prices, + keep_updated, + |_| self.fetch_price(token, request_timeout), + ) + .await; + (token, result) }); futures::stream::iter(estimates) .buffered(self.concurrent_requests) @@ -737,17 +696,10 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - let cached = + if let Some(cached) = self.cache - .get_ready_to_use_cached_price(token, now, self.require_updating_prices); - - let label = if cached.is_some() { "hits" } else { "misses" }; - Metrics::get() - .native_price_cache_access - .with_label_values(&[label]) - .inc_by(1); - - if let Some(cached) = cached { + .get_ready_to_use_cached_price(token, now, self.require_updating_prices) + { return cached.result; } @@ -789,19 +741,11 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { async move { let now = Instant::now(); // Quote source doesn't upgrade or create entries, just read - let cached = self.0.cache.get_ready_to_use_cached_price( + if let Some(cached) = self.0.cache.get_ready_to_use_cached_price( token, now, RequiresUpdatingPrices::DontCare, - ); - - let label = if cached.is_some() { "hits" } else { "misses" }; - Metrics::get() - .native_price_cache_access - .with_label_values(&[label]) - .inc_by(1); - - if let Some(cached) = cached { + ) { return cached.result; } From 78fbcf27d71f709b775f9854252bf37556c4eec7 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 30 Jan 2026 10:25:45 +0000 Subject: [PATCH 28/42] Reduce duplication --- crates/autopilot/src/solvable_orders.rs | 18 ++- crates/shared/src/price_estimation/factory.rs | 16 +-- .../price_estimation/native_price_cache.rs | 113 ++++++++++++------ 3 files changed, 94 insertions(+), 53 deletions(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 81adf00b9f..7d36b084c5 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -959,9 +959,12 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new( Arc::new(native_price_estimator), - CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()), + CacheStorage::new_without_maintenance( + Duration::from_secs(10), + Default::default(), + Default::default(), + ), 3, - Default::default(), RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1051,6 +1054,7 @@ mod tests { let cache = CacheStorage::new_with_maintenance( Duration::from_secs(10), Default::default(), + Default::default(), shared::price_estimation::native_price_cache::MaintenanceConfig { estimator: maintenance_estimator.clone(), // Short interval to trigger background fetch quickly @@ -1065,7 +1069,6 @@ mod tests { maintenance_estimator, cache, 1, - Default::default(), RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); @@ -1157,10 +1160,13 @@ mod tests { let native_price_estimator = CachingNativePriceEstimator::new( Arc::new(native_price_estimator), - CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()), - 3, // Set to use native price approximations for the following tokens - HashMap::from([(token1, token_approx1), (token2, token_approx2)]), + CacheStorage::new_without_maintenance( + Duration::from_secs(10), + Default::default(), + HashMap::from([(token1, token_approx1), (token2, token_approx2)]), + ), + 3, RequiresUpdatingPrices::Yes, ); let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 218c21336a..3d9e3a2999 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -390,11 +390,19 @@ impl<'a> PriceEstimatorFactory<'a> { .await?, ); + let approximation_tokens = self + .args + .native_price_approximation_tokens + .iter() + .copied() + .collect(); + // Create cache with background maintenance, which only refreshes // Auction-sourced entries let cache = CacheStorage::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, + approximation_tokens, MaintenanceConfig { estimator: estimator.clone(), update_interval: self.args.native_price_cache_refresh, @@ -416,18 +424,10 @@ impl<'a> PriceEstimatorFactory<'a> { estimator: Arc, cache: Arc, ) -> Arc { - let approximation_tokens = self - .args - .native_price_approximation_tokens - .iter() - .copied() - .collect(); - Arc::new(CachingNativePriceEstimator::new( estimator, cache, self.args.native_price_cache_concurrent_requests, - approximation_tokens, RequiresUpdatingPrices::Yes, )) } diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index b8c72bedb8..dfd22041c6 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -96,13 +96,27 @@ pub struct CacheStorage { max_age: Duration, /// Tokens that should be prioritized during maintenance updates. high_priority: Mutex>, + // TODO remove when implementing a less hacky solution + /// Maps a requested token to an approximating token. If the system + /// wants to get the native price for the requested token the native + /// price of the approximating token should be fetched and returned instead. + /// This can be useful for tokens that are hard to route but are pegged to + /// the same underlying asset so approximating their native prices is deemed + /// safe (e.g. csUSDL => Dai). + /// It's very important that the 2 tokens have the same number of decimals. + /// After startup this is a read only value. + approximation_tokens: HashMap, } impl CacheStorage { /// Creates a new cache with the given max age for entries and initial /// prices. Entries are initialized with random ages to avoid expiration /// spikes. - fn new(max_age: Duration, initial_prices: HashMap) -> Arc { + fn new( + max_age: Duration, + initial_prices: HashMap, + approximation_tokens: HashMap, + ) -> Arc { let mut rng = rand::thread_rng(); let now = std::time::Instant::now(); @@ -132,6 +146,7 @@ impl CacheStorage { cache: Mutex::new(cache), max_age, high_priority: Default::default(), + approximation_tokens, }) } @@ -142,9 +157,10 @@ impl CacheStorage { pub fn new_with_maintenance( max_age: Duration, initial_prices: HashMap, + approximation_tokens: HashMap, config: MaintenanceConfig, ) -> Arc { - let cache = Self::new(max_age, initial_prices); + let cache = Self::new(max_age, initial_prices, approximation_tokens); spawn_maintenance_task(&cache, config); cache } @@ -157,8 +173,9 @@ impl CacheStorage { pub fn new_without_maintenance( max_age: Duration, initial_prices: HashMap, + approximation_tokens: HashMap, ) -> Arc { - Self::new(max_age, initial_prices) + Self::new(max_age, initial_prices, approximation_tokens) } /// Returns the max age configuration for this cache. @@ -166,6 +183,11 @@ impl CacheStorage { self.max_age } + /// Returns the approximation tokens mapping. + pub fn approximation_tokens(&self) -> &HashMap { + &self.approximation_tokens + } + /// Returns the number of entries in the cache. pub fn len(&self) -> usize { self.cache.lock().unwrap().len() @@ -365,13 +387,14 @@ impl CacheStorage { let estimates = tokens.iter().map(move |token| { let estimator = estimator.clone(); let token = *token; + let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); async move { let result = self .estimate_with_cache_update( token, RequiresUpdatingPrices::DontCare, KeepPriceUpdated::Yes, - |_| estimator.estimate_native_price(token, request_timeout), + |_| estimator.estimate_native_price(token_to_fetch, request_timeout), ) .await; (token, result) @@ -477,16 +500,6 @@ pub struct CachingNativePriceEstimator { cache: NativePriceCache, estimator: Arc, concurrent_requests: usize, - // TODO remove when implementing a less hacky solution - /// Maps a requested token to an approximating token. If the system - /// wants to get the native price for the requested token the native - /// price of the approximating token should be fetched and returned instead. - /// This can be useful for tokens that are hard to route but are pegged to - /// the same underlying asset so approximating their native prices is deemed - /// safe (e.g. csUSDL => Dai). - /// It's very important that the 2 tokens have the same number of decimals. - /// After startup this is a read only value. - approximation_tokens: HashMap, require_updating_prices: RequiresUpdatingPrices, } @@ -574,14 +587,12 @@ impl CachingNativePriceEstimator { estimator: Arc, cache: NativePriceCache, concurrent_requests: usize, - approximation_tokens: HashMap, require_updating_prices: RequiresUpdatingPrices, ) -> Self { Self { estimator, cache, concurrent_requests, - approximation_tokens, require_updating_prices, } } @@ -680,7 +691,11 @@ impl CachingNativePriceEstimator { /// Fetches a single price (without caching). async fn fetch_price(&self, token: Address, timeout: Duration) -> NativePriceEstimateResult { - let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); + let token_to_fetch = *self + .cache + .approximation_tokens() + .get(&token) + .unwrap_or(&token); self.estimator .estimate_native_price(token_to_fetch, timeout) .await @@ -793,13 +808,15 @@ mod tests { let prices = HashMap::from_iter((0..10).map(|t| (token(t), BigDecimal::try_from(1e18).unwrap()))); - let cache = - CacheStorage::new_without_maintenance(Duration::from_secs(MAX_AGE_SECS), prices); + let cache = CacheStorage::new_without_maintenance( + Duration::from_secs(MAX_AGE_SECS), + prices, + Default::default(), + ); let estimator = CachingNativePriceEstimator::new( Arc::new(inner), cache, 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -831,9 +848,12 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), + CacheStorage::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + Default::default(), + ), 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -866,13 +886,16 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), - 1, // set token approximations for tokens 1 and 2 - HashMap::from([ - (Address::with_last_byte(1), Address::with_last_byte(100)), - (Address::with_last_byte(2), Address::with_last_byte(200)), - ]), + CacheStorage::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + HashMap::from([ + (Address::with_last_byte(1), Address::with_last_byte(100)), + (Address::with_last_byte(2), Address::with_last_byte(200)), + ]), + ), + 1, RequiresUpdatingPrices::Yes, ); @@ -918,9 +941,12 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), + CacheStorage::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + Default::default(), + ), 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -986,9 +1012,12 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - CacheStorage::new_without_maintenance(Duration::from_millis(100), Default::default()), + CacheStorage::new_without_maintenance( + Duration::from_millis(100), + Default::default(), + Default::default(), + ), 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -1055,9 +1084,12 @@ mod tests { let estimator = CachingNativePriceEstimator::new( Arc::new(inner), - CacheStorage::new_without_maintenance(Duration::from_millis(30), Default::default()), + CacheStorage::new_without_maintenance( + Duration::from_millis(30), + Default::default(), + Default::default(), + ), 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -1105,6 +1137,7 @@ mod tests { let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), + Default::default(), MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), @@ -1119,7 +1152,6 @@ mod tests { Arc::new(on_demand), cache, 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -1168,6 +1200,7 @@ mod tests { let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), + Default::default(), MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), @@ -1182,7 +1215,6 @@ mod tests { Arc::new(on_demand), cache, 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -1236,6 +1268,7 @@ mod tests { let cache = CacheStorage::new_with_maintenance( Duration::from_millis(30), Default::default(), + Default::default(), MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), @@ -1250,7 +1283,6 @@ mod tests { Arc::new(on_demand), cache, 1, - Default::default(), RequiresUpdatingPrices::Yes, ); @@ -1286,8 +1318,11 @@ mod tests { // Create a cache and populate it directly with Auction-sourced entries // (since maintenance only updates Auction entries) - let cache = - CacheStorage::new_without_maintenance(Duration::from_secs(10), Default::default()); + let cache = CacheStorage::new_without_maintenance( + Duration::from_secs(10), + Default::default(), + Default::default(), + ); cache.insert( t0, CachedResult::new(Ok(0.), now, now, Default::default(), KeepPriceUpdated::Yes), From 71550befcd264a7a3c311056926a34745ce8637e Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 30 Jan 2026 19:43:37 +0000 Subject: [PATCH 29/42] Update docs --- crates/shared/src/price_estimation/native_price_cache.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index dfd22041c6..fb6a7d48d9 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -201,11 +201,10 @@ impl CacheStorage { /// Get a cached price with optional cache modifications. /// Returns None if the price is not cached or is expired. /// - /// The `lookup` parameter controls what modifications to perform: - /// - `ReadOnly`: No modifications, just check the cache - /// - `UpgradeOnly`: Upgrade Quote→Auction entries, but don't create missing - /// - `CreateForMaintenance`: Create missing entries with Auction source and - /// upgrade existing Quote→Auction entries + /// The `require_updating_price` parameter controls whether to mark the + /// token for active price maintenance: + /// - `DontCare`: Don't modify the token's maintenance flag + /// - `Yes`: Mark the token to require active price updates fn get_cached_price( &self, token: Address, From ea935d5e288c3d630076185f996cf0f600007495 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 30 Jan 2026 19:46:34 +0000 Subject: [PATCH 30/42] Update docs --- .../shared/src/price_estimation/native_price_cache.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index fb6a7d48d9..fdd1289bf7 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -71,7 +71,9 @@ pub struct MaintenanceConfig { /// How often to run the maintenance task. pub update_interval: Duration, /// Maximum number of prices to update per maintenance cycle. - /// None means unlimited. + /// None means unlimited. High-priority tokens are updated first, so if this + /// limit is smaller than the number of outdated high-priority tokens, + /// non-priority tokens won't be updated until the backlog clears. pub update_size: Option, /// How early before expiration to refresh prices. pub prefetch_time: Duration, @@ -95,6 +97,12 @@ pub struct CacheStorage { cache: Mutex>, max_age: Duration, /// Tokens that should be prioritized during maintenance updates. + /// + /// These tokens are updated before non-priority tokens during each + /// maintenance cycle. Note: If the number of outdated high-priority tokens + /// exceeds `MaintenanceConfig::update_size`, only that many will be updated + /// per cycle (in priority order), and non-priority tokens won't be updated + /// until the high-priority backlog clears. high_priority: Mutex>, // TODO remove when implementing a less hacky solution /// Maps a requested token to an approximating token. If the system From 4acf0eea5b71106bca549c554d1ce48c6bde3bf0 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 30 Jan 2026 19:52:34 +0000 Subject: [PATCH 31/42] Update size limit --- crates/shared/src/price_estimation/factory.rs | 2 +- .../src/price_estimation/native_price_cache.rs | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index 3d9e3a2999..e281fa7e43 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -406,7 +406,7 @@ impl<'a> PriceEstimatorFactory<'a> { MaintenanceConfig { estimator: estimator.clone(), update_interval: self.args.native_price_cache_refresh, - update_size: Some(self.args.native_price_cache_max_update_size), + update_size: self.args.native_price_cache_max_update_size, prefetch_time: self.args.native_price_prefetch_time, concurrent_requests: self.args.native_price_cache_concurrent_requests, quote_timeout: self.args.quote_timeout, diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index fdd1289bf7..1c77cfe937 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -71,10 +71,10 @@ pub struct MaintenanceConfig { /// How often to run the maintenance task. pub update_interval: Duration, /// Maximum number of prices to update per maintenance cycle. - /// None means unlimited. High-priority tokens are updated first, so if this + /// 0 means unlimited. High-priority tokens are updated first, so if this /// limit is smaller than the number of outdated high-priority tokens, /// non-priority tokens won't be updated until the backlog clears. - pub update_size: Option, + pub update_size: usize, /// How early before expiration to refresh prices. pub prefetch_time: Duration, /// Number of concurrent price fetch requests. @@ -429,7 +429,7 @@ struct CacheMaintenanceTask { /// Estimator used for maintenance updates. estimator: Arc, update_interval: Duration, - update_size: Option, + update_size: usize, prefetch_time: Duration, concurrent_requests: usize, quote_timeout: Duration, @@ -467,7 +467,9 @@ impl CacheMaintenanceTask { .native_price_cache_outdated_entries .set(i64::try_from(outdated_entries.len()).unwrap_or(i64::MAX)); - outdated_entries.truncate(self.update_size.unwrap_or(usize::MAX)); + if self.update_size > 0 { + outdated_entries.truncate(self.update_size); + } if outdated_entries.is_empty() { return; @@ -1148,7 +1150,7 @@ mod tests { MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), - update_size: Some(1), + update_size: 1, prefetch_time: Default::default(), concurrent_requests: 1, quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, @@ -1211,7 +1213,7 @@ mod tests { MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), - update_size: None, + update_size: 0, prefetch_time: Default::default(), concurrent_requests: 1, quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, @@ -1279,7 +1281,7 @@ mod tests { MaintenanceConfig { estimator: Arc::new(maintenance), update_interval: Duration::from_millis(50), - update_size: None, + update_size: 0, prefetch_time: Default::default(), concurrent_requests: BATCH_SIZE, quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, From f75b3c51dd6e65cfe50be1d53ea0da7517be42bb Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 2 Feb 2026 14:14:11 +0000 Subject: [PATCH 32/42] Review comments --- .../price_estimation/native_price_cache.rs | 133 +++++++++++------- 1 file changed, 83 insertions(+), 50 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 1c77cfe937..6fb34d04f0 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -213,25 +213,12 @@ impl CacheStorage { /// token for active price maintenance: /// - `DontCare`: Don't modify the token's maintenance flag /// - `Yes`: Mark the token to require active price updates - fn get_cached_price( - &self, - token: Address, - now: Instant, - require_updating_price: RequiresUpdatingPrices, - ) -> Option { - let mut cache = self.cache.lock().unwrap(); - Self::get_cached_price_inner(&mut cache, token, now, require_updating_price, self.max_age) - } - - /// Inner implementation of cache lookup that works with an already-locked - /// cache. This allows both single and batch lookups to share the same - /// logic. - fn get_cached_price_inner( + fn lookup_cached_price( cache: &mut HashMap, token: Address, now: Instant, - require_updating_price: RequiresUpdatingPrices, max_age: Duration, + require_updating_price: RequiresUpdatingPrices, ) -> Option { match cache.entry(token) { Entry::Occupied(mut entry) => { @@ -250,10 +237,11 @@ impl CacheStorage { } Entry::Vacant(entry) => { if require_updating_price == RequiresUpdatingPrices::Yes { - // Create an outdated cache entry so the background task keeping the cache warm - // will fetch the price during the next maintenance cycle. + // Create an outdated cache entry so the background task keeping the + // cache warm will fetch the price during the next maintenance cycle. // This should happen only for prices missing while building the auction. - // Otherwise malicious actors could easily cause the cache size to blow up. + // Otherwise malicious actors could easily cause the cache size to blow + // up. let outdated_timestamp = now.checked_sub(max_age).unwrap_or(now); tracing::trace!(?token, "create outdated price entry"); entry.insert(CachedResult::new( @@ -269,28 +257,65 @@ impl CacheStorage { } } - /// Get a cached price that is ready to use (not in error accumulation + /// Get cached prices that are ready to use (not in error accumulation /// state). /// - /// Returns None if the price is not cached, is expired, or is not ready to - /// use. Also updates cache access metrics (hits/misses). - fn get_ready_to_use_cached_price( + /// Returns a map of token -> cached result for tokens that have valid + /// cached prices. Missing tokens (not cached or expired) are not + /// included in the result. Also updates cache access metrics + /// (hits/misses). + /// + /// Note: This method does NOT create placeholder entries for missing + /// tokens. Use `lookup_cached_price` directly if you need that behavior + /// (e.g., in `estimate_with_cache_update` where the fetch immediately + /// follows). + fn get_ready_to_use_cached_prices( &self, - token: Address, + tokens: &[Address], now: Instant, - required_updating_price: RequiresUpdatingPrices, - ) -> Option { - let cached = self - .get_cached_price(token, now, required_updating_price) - .filter(|cached| cached.is_ready()); + ) -> HashMap { + let mut cache = self.cache.lock().unwrap(); + let max_age = self.max_age; + + let mut results = HashMap::new(); + let mut hits = 0u64; + let mut misses = 0u64; + + for &token in tokens { + let cached = match cache.get_mut(&token) { + Some(cached) => { + cached.requested_at = now; + let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; + is_recent.then_some(cached.clone()) + } + None => None, + }; + + if let Some(cached) = cached.filter(|c| c.is_ready()) { + results.insert(token, cached); + hits += 1; + } else { + misses += 1; + } + } + + drop(cache); // Release lock before metrics update - let label = if cached.is_some() { "hits" } else { "misses" }; - Metrics::get() - .native_price_cache_access - .with_label_values(&[label]) - .inc_by(1); + let metrics = Metrics::get(); + if hits > 0 { + metrics + .native_price_cache_access + .with_label_values(&["hits"]) + .inc_by(hits); + } + if misses > 0 { + metrics + .native_price_cache_access + .with_label_values(&["misses"]) + .inc_by(misses); + } - cached + results } /// Insert or update a cached result. @@ -355,7 +380,16 @@ impl CacheStorage { { let current_accumulative_errors_count = { let now = Instant::now(); - match self.get_cached_price(token, now, require_updating_price) { + let mut cache = self.cache.lock().unwrap(); + let cached = Self::lookup_cached_price( + &mut cache, + token, + now, + self.max_age, + require_updating_price, + ); + + match cached { Some(cached) if cached.is_ready() => return cached.result, Some(cached) => cached.accumulative_errors_count, None => Default::default(), @@ -617,13 +651,10 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - tokens - .iter() - .filter_map(|token| { - self.cache - .get_ready_to_use_cached_price(*token, now, self.require_updating_prices) - .map(|cached| (*token, cached.result)) - }) + self.cache + .get_ready_to_use_cached_prices(tokens, now) + .into_iter() + .map(|(token, cached)| (token, cached.result)) .collect() } @@ -720,9 +751,10 @@ impl NativePriceEstimating for CachingNativePriceEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - if let Some(cached) = - self.cache - .get_ready_to_use_cached_price(token, now, self.require_updating_prices) + if let Some(cached) = self + .cache + .get_ready_to_use_cached_prices(&[token], now) + .remove(&token) { return cached.result; } @@ -765,11 +797,12 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { async move { let now = Instant::now(); // Quote source doesn't upgrade or create entries, just read - if let Some(cached) = self.0.cache.get_ready_to_use_cached_price( - token, - now, - RequiresUpdatingPrices::DontCare, - ) { + if let Some(cached) = self + .0 + .cache + .get_ready_to_use_cached_prices(&[token], now) + .remove(&token) + { return cached.result; } From adecbca65f19a3e40293ac1ee04c653e72041399 Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 2 Feb 2026 14:18:26 +0000 Subject: [PATCH 33/42] Fix --- crates/autopilot/src/solvable_orders.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/autopilot/src/solvable_orders.rs b/crates/autopilot/src/solvable_orders.rs index 7d36b084c5..2000c98949 100644 --- a/crates/autopilot/src/solvable_orders.rs +++ b/crates/autopilot/src/solvable_orders.rs @@ -1059,7 +1059,7 @@ mod tests { estimator: maintenance_estimator.clone(), // Short interval to trigger background fetch quickly update_interval: Duration::from_millis(1), - update_size: None, + update_size: Default::default(), prefetch_time: Default::default(), concurrent_requests: 1, quote_timeout: HEALTHY_PRICE_ESTIMATION_TIME, From 122c4d2b9268d58e0e751d169bf183987ec78223 Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 2 Feb 2026 14:23:44 +0000 Subject: [PATCH 34/42] Reset metrics --- .../src/price_estimation/native_price_cache.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 6fb34d04f0..7a9cb9f7fc 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -61,6 +61,17 @@ impl Metrics { fn get() -> &'static Self { Metrics::instance(observe::metrics::get_storage_registry()).unwrap() } + + /// Resets counters on startup to ensure clean metrics for this run. + fn reset(&self) { + self.native_price_cache_access + .with_label_values(&["hits"]) + .reset(); + self.native_price_cache_access + .with_label_values(&["misses"]) + .reset(); + self.native_price_cache_background_updates.reset(); + } } /// Configuration for the background maintenance task that keeps the cache warm. @@ -449,6 +460,7 @@ impl CacheStorage { /// Spawns a background maintenance task for the given cache. fn spawn_maintenance_task(cache: &Arc, config: MaintenanceConfig) { + Metrics::get().reset(); let update_task = CacheMaintenanceTask::new(Arc::downgrade(cache), config) .run() .instrument(tracing::info_span!("native_price_cache_maintenance")); From 0528ec4f72bb4f7fb0c3e1e6e856edf14b1351b7 Mon Sep 17 00:00:00 2001 From: ilya Date: Mon, 2 Feb 2026 14:33:50 +0000 Subject: [PATCH 35/42] Fix --- .../price_estimation/native_price_cache.rs | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 7a9cb9f7fc..39ee475fa3 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -334,6 +334,32 @@ impl CacheStorage { self.cache.lock().unwrap().insert(token, result); } + /// Creates placeholder entries for tokens that are not in the cache. + /// These entries are immediately outdated so the maintenance task will + /// fetch them in the next cycle. + fn mark_tokens_for_maintenance(&self, tokens: &[Address]) { + if tokens.is_empty() { + return; + } + + let now = Instant::now(); + let outdated_timestamp = now.checked_sub(self.max_age).unwrap_or(now); + let mut cache = self.cache.lock().unwrap(); + + for &token in tokens { + if let Entry::Vacant(entry) = cache.entry(token) { + tracing::trace!(?token, "create outdated price entry for maintenance"); + entry.insert(CachedResult::new( + Ok(0.), + outdated_timestamp, + now, + Default::default(), + KeepPriceUpdated::Yes, + )); + } + } + } + /// Fetches all tokens that need to be updated sorted by the provided /// priority. fn prioritized_tokens_to_update( @@ -663,8 +689,19 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - self.cache - .get_ready_to_use_cached_prices(tokens, now) + let cached = self.cache.get_ready_to_use_cached_prices(tokens, now); + + // For Auction source, mark missing tokens for background maintenance + if self.require_updating_prices == RequiresUpdatingPrices::Yes { + let missing_tokens: Vec<_> = tokens + .iter() + .filter(|t| !cached.contains_key(*t)) + .copied() + .collect(); + self.cache.mark_tokens_for_maintenance(&missing_tokens); + } + + cached .into_iter() .map(|(token, cached)| (token, cached.result)) .collect() From d34e0a774ee879e5393041fdadbc3d3f5d7300a7 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 17:05:48 +0000 Subject: [PATCH 36/42] Upgrade existing items --- .../price_estimation/native_price_cache.rs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 39ee475fa3..8b89e20b50 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -276,6 +276,9 @@ impl CacheStorage { /// included in the result. Also updates cache access metrics /// (hits/misses). /// + /// The `require_updating_price` parameter controls whether to mark tokens + /// for active price maintenance (upgrading from Quote to Auction source). + /// /// Note: This method does NOT create placeholder entries for missing /// tokens. Use `lookup_cached_price` directly if you need that behavior /// (e.g., in `estimate_with_cache_update` where the fetch immediately @@ -284,6 +287,7 @@ impl CacheStorage { &self, tokens: &[Address], now: Instant, + require_updating_price: RequiresUpdatingPrices, ) -> HashMap { let mut cache = self.cache.lock().unwrap(); let max_age = self.max_age; @@ -293,16 +297,21 @@ impl CacheStorage { let mut misses = 0u64; for &token in tokens { - let cached = match cache.get_mut(&token) { - Some(cached) => { - cached.requested_at = now; - let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; - is_recent.then_some(cached.clone()) + let cached = cache.get_mut(&token).and_then(|cached| { + cached.requested_at = now; + + if cached.update_price_continuously == KeepPriceUpdated::No + && require_updating_price == RequiresUpdatingPrices::Yes + { + tracing::trace!(?token, "marking token for needing active maintenance"); + cached.update_price_continuously = KeepPriceUpdated::Yes; } - None => None, - }; - if let Some(cached) = cached.filter(|c| c.is_ready()) { + let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; + (is_recent && cached.is_ready()).then_some(cached.clone()) + }); + + if let Some(cached) = cached { results.insert(token, cached); hits += 1; } else { @@ -689,7 +698,9 @@ impl CachingNativePriceEstimator { tokens: &[Address], ) -> HashMap> { let now = Instant::now(); - let cached = self.cache.get_ready_to_use_cached_prices(tokens, now); + let cached = + self.cache + .get_ready_to_use_cached_prices(tokens, now, self.require_updating_prices); // For Auction source, mark missing tokens for background maintenance if self.require_updating_prices == RequiresUpdatingPrices::Yes { @@ -802,7 +813,7 @@ impl NativePriceEstimating for CachingNativePriceEstimator { let now = Instant::now(); if let Some(cached) = self .cache - .get_ready_to_use_cached_prices(&[token], now) + .get_ready_to_use_cached_prices(&[token], now, self.require_updating_prices) .remove(&token) { return cached.result; @@ -849,7 +860,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { if let Some(cached) = self .0 .cache - .get_ready_to_use_cached_prices(&[token], now) + .get_ready_to_use_cached_prices(&[token], now, RequiresUpdatingPrices::DontCare) .remove(&token) { return cached.result; From fda51c9809f789b26bddc3a0e74ef039ac566dff Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 17:11:36 +0000 Subject: [PATCH 37/42] Fix comments --- crates/shared/src/price_estimation/factory.rs | 15 ++--- .../price_estimation/native_price_cache.rs | 56 +++++++++---------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/crates/shared/src/price_estimation/factory.rs b/crates/shared/src/price_estimation/factory.rs index e281fa7e43..19f0cda2d1 100644 --- a/crates/shared/src/price_estimation/factory.rs +++ b/crates/shared/src/price_estimation/factory.rs @@ -365,10 +365,11 @@ impl<'a> PriceEstimatorFactory<'a> { /// Creates a native price estimator with a shared cache and background /// maintenance task. /// - /// The estimator is configured with Auction source, meaning entries are - /// actively maintained by the background task. For the quote competition - /// use, wrap the returned estimator with `QuoteSourceEstimator` to mark - /// prices as Quote source (cached but not actively maintained). + /// The estimator is configured with `RequiresUpdatingPrices::Yes`, meaning + /// entries are actively maintained by the background task. For quote + /// competition use, wrap the returned estimator with + /// `QuoteCompetitionEstimator` to mark prices with `KeepPriceUpdated::No` + /// (cached but not actively maintained). /// /// The `initial_prices` are used to seed the cache before the estimator /// starts. @@ -398,7 +399,7 @@ impl<'a> PriceEstimatorFactory<'a> { .collect(); // Create cache with background maintenance, which only refreshes - // Auction-sourced entries + // entries marked with `KeepPriceUpdated::Yes` let cache = CacheStorage::new_with_maintenance( self.args.native_price_cache_max_age, initial_prices, @@ -413,12 +414,12 @@ impl<'a> PriceEstimatorFactory<'a> { }, ); - // Wrap with caching layer using Auction source + // Wrap with caching layer Ok(self.wrap_with_cache(estimator, cache)) } /// Wraps a native price estimator with caching functionality. - /// Uses Auction source so entries are actively maintained. + /// Uses `RequiresUpdatingPrices::Yes` so entries are actively maintained. fn wrap_with_cache( &self, estimator: Arc, diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 8b89e20b50..e8313eecfc 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -77,7 +77,7 @@ impl Metrics { /// Configuration for the background maintenance task that keeps the cache warm. pub struct MaintenanceConfig { /// Estimator used for maintenance updates. - /// Maintenance only refreshes Auction-sourced entries. + /// Maintenance only refreshes entries marked with `KeepPriceUpdated::Yes`. pub estimator: Arc, /// How often to run the maintenance task. pub update_interval: Duration, @@ -277,7 +277,8 @@ impl CacheStorage { /// (hits/misses). /// /// The `require_updating_price` parameter controls whether to mark tokens - /// for active price maintenance (upgrading from Quote to Auction source). + /// for active price maintenance (upgrading `KeepPriceUpdated::No` to + /// `Yes`). /// /// Note: This method does NOT create placeholder entries for missing /// tokens. Use `lookup_cached_price` directly if you need that behavior @@ -463,7 +464,7 @@ impl CacheStorage { /// Estimates prices for the given tokens and updates the cache. /// Used by the background maintenance task. All tokens are processed using - /// the provided estimator and marked as Auction source. + /// the provided estimator and marked with `KeepPriceUpdated::Yes`. fn estimate_prices_and_update_cache_for_maintenance<'a>( &'a self, tokens: &'a [Address], @@ -503,8 +504,8 @@ fn spawn_maintenance_task(cache: &Arc, config: MaintenanceConfig) } /// Background task that keeps the cache warm by periodically refreshing prices. -/// Only refreshes Auction-sourced entries; Quote-sourced entries are cached -/// but not maintained. +/// Only refreshes entries with `KeepPriceUpdated::Yes`; entries with +/// `KeepPriceUpdated::No` are cached but not maintained. struct CacheMaintenanceTask { cache: Weak, /// Estimator used for maintenance updates. @@ -530,7 +531,7 @@ impl CacheMaintenanceTask { } /// Single run of the background updating process. - /// Only updates Auction-sourced entries; Quote-sourced entries are skipped. + /// Only updates entries with `KeepPriceUpdated::Yes`. async fn single_update(&self, cache: &Arc) { let metrics = Metrics::get(); metrics @@ -670,9 +671,8 @@ impl CachingNativePriceEstimator { /// prices on-demand for cache misses. Background maintenance (keeping the /// cache warm) is handled by the cache itself, not by this estimator. /// - /// The `source` parameter identifies which estimator type this is, so that - /// the maintenance task knows which estimator to use when refreshing - /// entries fetched by this estimator. + /// The `require_updating_prices` parameter controls whether entries fetched + /// by this estimator should be actively maintained by the background task. pub fn new( estimator: Arc, cache: NativePriceCache, @@ -689,10 +689,10 @@ impl CachingNativePriceEstimator { /// Only returns prices that are currently cached. Missing prices will get /// prioritized to get fetched during the next cycles of the maintenance - /// background task (only for Auction source). + /// background task (only if `require_updating_prices == Yes`). /// - /// If this estimator has Auction source and a cached entry has Quote - /// source, the entry is upgraded to Auction source. + /// If `require_updating_prices == Yes` and a cached entry has + /// `KeepPriceUpdated::No`, it is upgraded to `KeepPriceUpdated::Yes`. fn get_cached_prices( &self, tokens: &[Address], @@ -702,7 +702,7 @@ impl CachingNativePriceEstimator { self.cache .get_ready_to_use_cached_prices(tokens, now, self.require_updating_prices); - // For Auction source, mark missing tokens for background maintenance + // Mark missing tokens for background maintenance if self.require_updating_prices == RequiresUpdatingPrices::Yes { let missing_tokens: Vec<_> = tokens .iter() @@ -760,8 +760,8 @@ impl CachingNativePriceEstimator { /// request because they can take a long time and some other task might /// have fetched some requested price in the meantime. /// - /// If this estimator has Auction source and the cached entry has Quote - /// source, the entry is upgraded to Auction source. + /// If `require_updating_prices == Yes` and a cached entry has + /// `KeepPriceUpdated::No`, it is upgraded to `KeepPriceUpdated::Yes`. fn estimate_prices_and_update_cache<'a>( &'a self, tokens: &'a [Address], @@ -829,20 +829,20 @@ impl NativePriceEstimating for CachingNativePriceEstimator { } } -/// Wrapper around `CachingNativePriceEstimator` that marks all requests as -/// Quote source. Used for the autopilot API endpoints where prices should be -/// cached but not actively maintained by the background task. +/// Wrapper around `CachingNativePriceEstimator` that marks all entries with +/// `KeepPriceUpdated::No`. Used for the autopilot API endpoints where prices +/// should be cached but not actively maintained by the background task. #[derive(Clone)] pub struct QuoteCompetitionEstimator(Arc); impl QuoteCompetitionEstimator { - /// Creates a new QuoteSourceEstimator wrapping the given estimator. + /// Creates a new `QuoteCompetitionEstimator` wrapping the given estimator. /// - /// Prices fetched through this wrapper will be cached with Quote source, - /// meaning they won't be actively refreshed by the background maintenance - /// task. However, if the same token is later requested for auction - /// purposes, the entry will be upgraded to Auction source and become - /// actively maintained. + /// Prices fetched through this wrapper will be cached with + /// `KeepPriceUpdated::No`, meaning they won't be actively refreshed by the + /// background maintenance task. However, if the same token is later + /// requested with `RequiresUpdatingPrices::Yes`, the entry will be upgraded + /// to `KeepPriceUpdated::Yes` and become actively maintained. pub fn new(estimator: Arc) -> Self { Self(estimator) } @@ -856,7 +856,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { ) -> futures::future::BoxFuture<'_, NativePriceEstimateResult> { async move { let now = Instant::now(); - // Quote source doesn't upgrade or create entries, just read + // Don't upgrade or create entries, just read from cache if let Some(cached) = self .0 .cache @@ -866,7 +866,7 @@ impl NativePriceEstimating for QuoteCompetitionEstimator { return cached.result; } - // Quote source: cache the result but don't mark for active maintenance + // Cache the result but don't mark for active maintenance self.0 .cache .estimate_with_cache_update( @@ -1418,8 +1418,8 @@ mod tests { let t1 = Address::with_last_byte(1); let now = Instant::now(); - // Create a cache and populate it directly with Auction-sourced entries - // (since maintenance only updates Auction entries) + // Create a cache and populate it directly with `KeepPriceUpdated::Yes` + // entries (since maintenance only updates those) let cache = CacheStorage::new_without_maintenance( Duration::from_secs(10), Default::default(), From f112027276e9e8e937ef1ff0c3ce5f35e40965eb Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 17:23:36 +0000 Subject: [PATCH 38/42] Single lock --- .../price_estimation/native_price_cache.rs | 149 ++++++++++++------ 1 file changed, 97 insertions(+), 52 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index e8313eecfc..7fb60dc94d 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -290,13 +290,12 @@ impl CacheStorage { now: Instant, require_updating_price: RequiresUpdatingPrices, ) -> HashMap { - let mut cache = self.cache.lock().unwrap(); let max_age = self.max_age; - let mut results = HashMap::new(); let mut hits = 0u64; let mut misses = 0u64; + let mut cache = self.cache.lock().unwrap(); for &token in tokens { let cached = cache.get_mut(&token).and_then(|cached| { cached.requested_at = now; @@ -340,10 +339,37 @@ impl CacheStorage { } /// Insert or update a cached result. + /// + /// Note: This locks the cache. Do not call in a loop; prefer batch + /// operations instead. fn insert(&self, token: Address, result: CachedResult) { self.cache.lock().unwrap().insert(token, result); } + /// Insert or update multiple cached results in a single lock acquisition. + fn insert_batch(&self, results: impl IntoIterator) { + let mut cache = self.cache.lock().unwrap(); + for (token, result) in results { + cache.insert(token, result); + } + } + + /// Get accumulative error counts for multiple tokens in a single lock. + /// Returns a map of token -> error count. Tokens not in cache return 0. + fn get_accumulative_errors(&self, tokens: &[Address]) -> HashMap { + let cache = self.cache.lock().unwrap(); + tokens + .iter() + .map(|&token| { + let count = cache + .get(&token) + .map(|c| c.accumulative_errors_count) + .unwrap_or_default(); + (token, count) + }) + .collect() + } + /// Creates placeholder entries for tokens that are not in the cache. /// These entries are immediately outdated so the maintenance task will /// fetch them in the next cycle. @@ -370,14 +396,10 @@ impl CacheStorage { } } - /// Fetches all tokens that need to be updated sorted by the provided - /// priority. - fn prioritized_tokens_to_update( - &self, - max_age: Duration, - now: Instant, - high_priority: &IndexSet
, - ) -> Vec
{ + /// Fetches all tokens that need to be updated sorted by priority. + /// High-priority tokens (from `self.high_priority`) are returned first. + fn prioritized_tokens_to_update(&self, max_age: Duration, now: Instant) -> Vec
{ + let high_priority = self.high_priority.lock().unwrap(); let mut outdated: Vec<_> = self .cache .lock() @@ -465,32 +487,61 @@ impl CacheStorage { /// Estimates prices for the given tokens and updates the cache. /// Used by the background maintenance task. All tokens are processed using /// the provided estimator and marked with `KeepPriceUpdated::Yes`. - fn estimate_prices_and_update_cache_for_maintenance<'a>( - &'a self, - tokens: &'a [Address], - estimator: &'a Arc, + /// + /// This method batches lock acquisitions: one lock to get error counts, + /// then concurrent fetches without locking, then one lock to insert + /// results. + async fn estimate_prices_and_update_cache_for_maintenance( + &self, + tokens: &[Address], + estimator: &Arc, concurrent_requests: usize, request_timeout: Duration, - ) -> futures::stream::BoxStream<'a, (Address, NativePriceEstimateResult)> { - let estimates = tokens.iter().map(move |token| { - let estimator = estimator.clone(); - let token = *token; - let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); - async move { - let result = self - .estimate_with_cache_update( - token, - RequiresUpdatingPrices::DontCare, - KeepPriceUpdated::Yes, - |_| estimator.estimate_native_price(token_to_fetch, request_timeout), - ) - .await; - (token, result) - } - }); - futures::stream::iter(estimates) + ) -> usize { + if tokens.is_empty() { + return 0; + } + + let error_counts = self.get_accumulative_errors(tokens); + let futures: Vec<_> = tokens + .iter() + .map(|&token| { + let estimator = estimator.clone(); + let token_to_fetch = *self.approximation_tokens.get(&token).unwrap_or(&token); + let error_count = error_counts.get(&token).copied().unwrap_or_default(); + async move { + let result = estimator + .estimate_native_price(token_to_fetch, request_timeout) + .await; + (token, result, error_count) + } + }) + .collect(); + + let results: Vec<_> = futures::stream::iter(futures) .buffered(concurrent_requests) - .boxed() + .collect() + .await; + + let now = Instant::now(); + let to_insert = results + .iter() + .filter(|(_, result, _)| should_cache(result)) + .map(|(token, result, error_count)| { + ( + *token, + CachedResult::new( + result.clone(), + now, + now, + *error_count, + KeepPriceUpdated::Yes, + ), + ) + }); + self.insert_batch(to_insert); + + results.len() } } @@ -539,9 +590,7 @@ impl CacheMaintenanceTask { .set(i64::try_from(cache.len()).unwrap_or(i64::MAX)); let max_age = cache.max_age().saturating_sub(self.prefetch_time); - let high_priority = cache.high_priority.lock().unwrap().clone(); - let mut outdated_entries = - cache.prioritized_tokens_to_update(max_age, Instant::now(), &high_priority); + let mut outdated_entries = cache.prioritized_tokens_to_update(max_age, Instant::now()); tracing::trace!(tokens = ?outdated_entries, first_n = ?self.update_size, "outdated auction prices to fetch"); @@ -557,14 +606,14 @@ impl CacheMaintenanceTask { return; } - let stream = cache.estimate_prices_and_update_cache_for_maintenance( - &outdated_entries, - &self.estimator, - self.concurrent_requests, - self.quote_timeout, - ); - - let updates_count = stream.count().await as u64; + let updates_count = cache + .estimate_prices_and_update_cache_for_maintenance( + &outdated_entries, + &self.estimator, + self.concurrent_requests, + self.quote_timeout, + ) + .await as u64; metrics .native_price_cache_background_updates .inc_by(updates_count); @@ -1436,17 +1485,13 @@ mod tests { let now = now + Duration::from_secs(1); - let high_priority: IndexSet
= std::iter::once(t0).collect(); - cache.replace_high_priority(high_priority.clone()); - let tokens = - cache.prioritized_tokens_to_update(Duration::from_secs(0), now, &high_priority); + cache.replace_high_priority(std::iter::once(t0).collect()); + let tokens = cache.prioritized_tokens_to_update(Duration::from_secs(0), now); assert_eq!(tokens[0], t0); assert_eq!(tokens[1], t1); - let high_priority: IndexSet
= std::iter::once(t1).collect(); - cache.replace_high_priority(high_priority.clone()); - let tokens = - cache.prioritized_tokens_to_update(Duration::from_secs(0), now, &high_priority); + cache.replace_high_priority(std::iter::once(t1).collect()); + let tokens = cache.prioritized_tokens_to_update(Duration::from_secs(0), now); assert_eq!(tokens[0], t1); assert_eq!(tokens[1], t0); } From d79a221095b530f80fe04ca0ac313e3fd5701e02 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 17:36:11 +0000 Subject: [PATCH 39/42] Upgrade entires in a single place --- .../price_estimation/native_price_cache.rs | 101 +++++++----------- 1 file changed, 40 insertions(+), 61 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 7fb60dc94d..9e153341ee 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -277,13 +277,11 @@ impl CacheStorage { /// (hits/misses). /// /// The `require_updating_price` parameter controls whether to mark tokens - /// for active price maintenance (upgrading `KeepPriceUpdated::No` to - /// `Yes`). - /// - /// Note: This method does NOT create placeholder entries for missing - /// tokens. Use `lookup_cached_price` directly if you need that behavior - /// (e.g., in `estimate_with_cache_update` where the fetch immediately - /// follows). + /// for active price maintenance: + /// - `DontCare`: Don't modify the token's maintenance flag + /// - `Yes`: Mark the token to require active price updates. For existing + /// entries, upgrades `KeepPriceUpdated::No` to `Yes`. For missing tokens, + /// creates placeholder entries so the maintenance task will fetch them. fn get_ready_to_use_cached_prices( &self, tokens: &[Address], @@ -291,31 +289,48 @@ impl CacheStorage { require_updating_price: RequiresUpdatingPrices, ) -> HashMap { let max_age = self.max_age; + let outdated_timestamp = now.checked_sub(max_age).unwrap_or(now); let mut results = HashMap::new(); let mut hits = 0u64; let mut misses = 0u64; let mut cache = self.cache.lock().unwrap(); for &token in tokens { - let cached = cache.get_mut(&token).and_then(|cached| { - cached.requested_at = now; - - if cached.update_price_continuously == KeepPriceUpdated::No - && require_updating_price == RequiresUpdatingPrices::Yes - { - tracing::trace!(?token, "marking token for needing active maintenance"); - cached.update_price_continuously = KeepPriceUpdated::Yes; + match cache.entry(token) { + Entry::Occupied(mut entry) => { + let cached = entry.get_mut(); + cached.requested_at = now; + + if cached.update_price_continuously == KeepPriceUpdated::No + && require_updating_price == RequiresUpdatingPrices::Yes + { + tracing::trace!(?token, "marking token for needing active maintenance"); + cached.update_price_continuously = KeepPriceUpdated::Yes; + } + + let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; + if is_recent && cached.is_ready() { + results.insert(token, cached.clone()); + hits += 1; + } else { + misses += 1; + } + } + Entry::Vacant(entry) => { + if require_updating_price == RequiresUpdatingPrices::Yes { + // Create an outdated cache entry so the background task keeping the + // cache warm will fetch the price during the next maintenance cycle. + tracing::trace!(?token, "create outdated price entry for maintenance"); + entry.insert(CachedResult::new( + Ok(0.), + outdated_timestamp, + now, + Default::default(), + KeepPriceUpdated::Yes, + )); + } + misses += 1; } - - let is_recent = now.saturating_duration_since(cached.updated_at) < max_age; - (is_recent && cached.is_ready()).then_some(cached.clone()) - }); - - if let Some(cached) = cached { - results.insert(token, cached); - hits += 1; - } else { - misses += 1; } } @@ -370,32 +385,6 @@ impl CacheStorage { .collect() } - /// Creates placeholder entries for tokens that are not in the cache. - /// These entries are immediately outdated so the maintenance task will - /// fetch them in the next cycle. - fn mark_tokens_for_maintenance(&self, tokens: &[Address]) { - if tokens.is_empty() { - return; - } - - let now = Instant::now(); - let outdated_timestamp = now.checked_sub(self.max_age).unwrap_or(now); - let mut cache = self.cache.lock().unwrap(); - - for &token in tokens { - if let Entry::Vacant(entry) = cache.entry(token) { - tracing::trace!(?token, "create outdated price entry for maintenance"); - entry.insert(CachedResult::new( - Ok(0.), - outdated_timestamp, - now, - Default::default(), - KeepPriceUpdated::Yes, - )); - } - } - } - /// Fetches all tokens that need to be updated sorted by priority. /// High-priority tokens (from `self.high_priority`) are returned first. fn prioritized_tokens_to_update(&self, max_age: Duration, now: Instant) -> Vec
{ @@ -751,16 +740,6 @@ impl CachingNativePriceEstimator { self.cache .get_ready_to_use_cached_prices(tokens, now, self.require_updating_prices); - // Mark missing tokens for background maintenance - if self.require_updating_prices == RequiresUpdatingPrices::Yes { - let missing_tokens: Vec<_> = tokens - .iter() - .filter(|t| !cached.contains_key(*t)) - .copied() - .collect(); - self.cache.mark_tokens_for_maintenance(&missing_tokens); - } - cached .into_iter() .map(|(token, cached)| (token, cached.result)) From 756b85614162fa27b3b84d567d046d04fd1d2fc9 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 17:55:51 +0000 Subject: [PATCH 40/42] Redundant comments --- crates/shared/src/price_estimation/native_price_cache.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 9e153341ee..1e5a01396f 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -20,8 +20,6 @@ use { tracing::{Instrument, instrument}, }; -// This could be a bool but lets keep it as an enum for clarity. -// Arguably this should not implement Default for the same argument... /// Determines whether the background maintenance task should /// keep the token price up to date automatically. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -31,7 +29,6 @@ pub enum KeepPriceUpdated { No, } -// This could be a bool but lets keep it as an enum for clarity. /// Determines whether we need the price of the token to be /// actively kept up to date by the maintenance task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] From 7ec76868ce3fa1f0f0dc94d8e14d489c51850893 Mon Sep 17 00:00:00 2001 From: ilya Date: Tue, 3 Feb 2026 18:42:07 +0000 Subject: [PATCH 41/42] Avoid keep updated downgrade --- .../price_estimation/native_price_cache.rs | 101 +++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 1e5a01396f..480c3453fd 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -433,9 +433,10 @@ impl CacheStorage { F: FnOnce(u32) -> Fut, Fut: std::future::Future, { - let current_accumulative_errors_count = { + let (current_accumulative_errors_count, existing_keep_updated) = { let now = Instant::now(); let mut cache = self.cache.lock().unwrap(); + let cached = Self::lookup_cached_price( &mut cache, token, @@ -446,8 +447,19 @@ impl CacheStorage { match cached { Some(cached) if cached.is_ready() => return cached.result, - Some(cached) => cached.accumulative_errors_count, - None => Default::default(), + Some(cached) => ( + cached.accumulative_errors_count, + cached.update_price_continuously, + ), + None => { + // Entry might exist but be expired - preserve its flag if so. + // If entry doesn't exist, use the caller's preference. + let existing_keep_updated = cache + .get(&token) + .map(|c| c.update_price_continuously) + .unwrap_or(KeepPriceUpdated::No); + (Default::default(), existing_keep_updated) + } } }; @@ -455,6 +467,14 @@ impl CacheStorage { if should_cache(&result) { let now = Instant::now(); + // Preserve Yes if existing entry had it, otherwise use the requested + // keep_updated. This prevents downgrading auction-related tokens + // when QuoteCompetitionEstimator requests them after expiration. + let final_keep_updated = if existing_keep_updated == KeepPriceUpdated::Yes { + KeepPriceUpdated::Yes + } else { + keep_updated + }; self.insert( token, CachedResult::new( @@ -462,7 +482,7 @@ impl CacheStorage { now, now, current_accumulative_errors_count, - keep_updated, + final_keep_updated, ), ); } @@ -1471,4 +1491,77 @@ mod tests { assert_eq!(tokens[0], t1); assert_eq!(tokens[1], t0); } + + #[tokio::test] + async fn quote_competition_estimator_preserves_keep_updated_yes() { + // This test verifies that when QuoteCompetitionEstimator requests a token + // that was previously marked with KeepPriceUpdated::Yes, the flag is preserved + // even after the cache entry expires and needs to be re-fetched. + + let mut inner = MockNativePriceEstimating::new(); + // First call: auction-related estimator fetches the price + inner + .expect_estimate_native_price() + .times(1) + .returning(|_, _| async { Ok(1.0) }.boxed()); + // Second call: QuoteCompetitionEstimator re-fetches after expiration + inner + .expect_estimate_native_price() + .times(1) + .returning(|_, _| async { Ok(2.0) }.boxed()); + + let cache = CacheStorage::new_without_maintenance( + Duration::from_millis(50), + Default::default(), + Default::default(), + ); + + // Create auction-related estimator (marks entries with KeepPriceUpdated::Yes) + let auction_estimator = CachingNativePriceEstimator::new( + Arc::new(inner), + cache.clone(), + 1, + RequiresUpdatingPrices::Yes, + ); + + // Create QuoteCompetitionEstimator (uses KeepPriceUpdated::No) + let quote_estimator = QuoteCompetitionEstimator::new(Arc::new(auction_estimator)); + + let t0 = token(0); + + // Step 1: Auction estimator fetches the price, marking it with Yes + let result = quote_estimator + .0 + .estimate_native_price(t0, HEALTHY_PRICE_ESTIMATION_TIME) + .await; + assert_eq!(result.unwrap().to_i64().unwrap(), 1); + + // Verify the entry has KeepPriceUpdated::Yes + { + let cache_guard = cache.cache.lock().unwrap(); + let entry = cache_guard.get(&t0).unwrap(); + assert_eq!(entry.update_price_continuously, KeepPriceUpdated::Yes); + } + + // Step 2: Wait for the cache entry to expire + tokio::time::sleep(Duration::from_millis(60)).await; + + // Step 3: QuoteCompetitionEstimator requests the same token (after expiration) + // This would previously downgrade the entry to KeepPriceUpdated::No + let result = quote_estimator + .estimate_native_price(t0, HEALTHY_PRICE_ESTIMATION_TIME) + .await; + assert_eq!(result.unwrap().to_i64().unwrap(), 2); + + // Step 4: Verify the entry STILL has KeepPriceUpdated::Yes (not downgraded) + { + let cache_guard = cache.cache.lock().unwrap(); + let entry = cache_guard.get(&t0).unwrap(); + assert_eq!( + entry.update_price_continuously, + KeepPriceUpdated::Yes, + "QuoteCompetitionEstimator should not downgrade KeepPriceUpdated::Yes to No" + ); + } + } } From 79e2f85790a84f87611fe49ad546c9e4d0b1e825 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 4 Feb 2026 12:43:50 +0000 Subject: [PATCH 42/42] Metrics --- .../price_estimation/native_price_cache.rs | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/shared/src/price_estimation/native_price_cache.rs b/crates/shared/src/price_estimation/native_price_cache.rs index 480c3453fd..13c25d26b4 100644 --- a/crates/shared/src/price_estimation/native_price_cache.rs +++ b/crates/shared/src/price_estimation/native_price_cache.rs @@ -43,8 +43,9 @@ pub enum RequiresUpdatingPrices { #[derive(prometheus_metric_storage::MetricStorage)] struct Metrics { - /// native price cache hits misses - #[metric(labels("result"))] + /// native price cache hits misses by result and caller type + /// Labels: result=hits|misses, caller=auction|quote + #[metric(labels("result", "caller"))] native_price_cache_access: IntCounterVec, /// number of items in cache native_price_cache_size: IntGauge, @@ -52,6 +53,12 @@ struct Metrics { native_price_cache_background_updates: IntCounter, /// number of items in cache that are outdated native_price_cache_outdated_entries: IntGauge, + /// number of entries actively maintained by background task + /// (KeepPriceUpdated::Yes) + native_price_cache_maintained_entries: IntGauge, + /// number of entries passively cached but not maintained + /// (KeepPriceUpdated::No, i.e. quote-only tokens) + native_price_cache_passive_entries: IntGauge, } impl Metrics { @@ -61,12 +68,14 @@ impl Metrics { /// Resets counters on startup to ensure clean metrics for this run. fn reset(&self) { - self.native_price_cache_access - .with_label_values(&["hits"]) - .reset(); - self.native_price_cache_access - .with_label_values(&["misses"]) - .reset(); + for caller in &["auction", "quote"] { + self.native_price_cache_access + .with_label_values(&["hits", caller]) + .reset(); + self.native_price_cache_access + .with_label_values(&["misses", caller]) + .reset(); + } self.native_price_cache_background_updates.reset(); } } @@ -214,6 +223,20 @@ impl CacheStorage { self.cache.lock().unwrap().is_empty() } + /// Returns counts of (maintained, passive) entries. + /// Maintained entries have `KeepPriceUpdated::Yes` and are actively + /// refreshed by the background task. Passive entries have + /// `KeepPriceUpdated::No` and are only cached (quote-only tokens). + fn count_by_maintenance_flag(&self) -> (usize, usize) { + let cache = self.cache.lock().unwrap(); + let maintained = cache + .values() + .filter(|c| c.update_price_continuously == KeepPriceUpdated::Yes) + .count(); + let passive = cache.len() - maintained; + (maintained, passive) + } + /// Get a cached price with optional cache modifications. /// Returns None if the price is not cached or is expired. /// @@ -333,17 +356,21 @@ impl CacheStorage { drop(cache); // Release lock before metrics update + let caller = match require_updating_price { + RequiresUpdatingPrices::Yes => "auction", + RequiresUpdatingPrices::DontCare => "quote", + }; let metrics = Metrics::get(); if hits > 0 { metrics .native_price_cache_access - .with_label_values(&["hits"]) + .with_label_values(&["hits", caller]) .inc_by(hits); } if misses > 0 { metrics .native_price_cache_access - .with_label_values(&["misses"]) + .with_label_values(&["misses", caller]) .inc_by(misses); } @@ -595,6 +622,14 @@ impl CacheMaintenanceTask { .native_price_cache_size .set(i64::try_from(cache.len()).unwrap_or(i64::MAX)); + let (maintained, passive) = cache.count_by_maintenance_flag(); + metrics + .native_price_cache_maintained_entries + .set(i64::try_from(maintained).unwrap_or_default()); + metrics + .native_price_cache_passive_entries + .set(i64::try_from(passive).unwrap_or_default()); + let max_age = cache.max_age().saturating_sub(self.prefetch_time); let mut outdated_entries = cache.prioritized_tokens_to_update(max_age, Instant::now());