@@ -221,18 +221,19 @@ impl PoolsCheckpointHandler {
221221 } )
222222 }
223223
224- /// For a given list of token pairs, fetches the pools for the ones that
225- /// exist in the checkpoint. For the ones that don't exist, flag as
226- /// missing and expect to exist after the next maintenance run.
227- fn get ( & self , token_pairs : & HashSet < TokenPair > ) -> ( HashMap < Address , Arc < PoolInfo > > , u64 ) {
224+ /// Returns cached pools for the pairs, plus the ids of any that exist but
225+ /// aren't cached yet. Misses are recorded for the next maintenance run; the
226+ /// quote path fetches them on demand.
227+ fn get (
228+ & self ,
229+ token_pairs : & HashSet < TokenPair > ,
230+ ) -> ( HashMap < Address , Arc < PoolInfo > > , Vec < Address > , u64 ) {
228231 let mut pool_ids = token_pairs
229232 . iter ( )
230233 . filter_map ( |pair| self . pools_by_token_pair . get ( pair) )
231234 . flatten ( )
232235 . peekable ( ) ;
233236
234- tracing:: trace!( "get checkpoint for pool_ids: {:?}" , pool_ids) ;
235-
236237 match pool_ids. peek ( ) {
237238 Some ( _) => {
238239 let mut pools_checkpoint = self . pools_checkpoint . lock ( ) . unwrap ( ) ;
@@ -245,57 +246,93 @@ impl PoolsCheckpointHandler {
245246 }
246247 None => true ,
247248 } )
249+ . copied ( )
248250 . collect :: < Vec < _ > > ( ) ;
249251
250252 tracing:: trace!(
251253 "cache hit: {:?}, cache miss: {:?}" ,
252254 existing_pools. keys( ) ,
253255 missing_pools
254256 ) ;
255- pools_checkpoint. missing_pools . extend ( missing_pools) ;
256- ( existing_pools, pools_checkpoint. block_number )
257+ pools_checkpoint
258+ . missing_pools
259+ . extend ( missing_pools. iter ( ) . copied ( ) ) ;
260+ ( existing_pools, missing_pools, pools_checkpoint. block_number )
257261 }
258262 None => Default :: default ( ) ,
259263 }
260264 }
261265
262- /// Fetches state/ticks for missing pools and moves them from
263- /// `missing_pools` to `pools`
264- async fn update_missing_pools ( & self ) -> Result < ( ) > {
265- let ( missing_pools, block_number) = {
266- let checkpoint = self . pools_checkpoint . lock ( ) . unwrap ( ) ;
267- if checkpoint. missing_pools . is_empty ( ) {
268- return Ok ( ( ) ) ;
269- }
270- ( checkpoint. missing_pools . clone ( ) , checkpoint. block_number )
271- } ;
272- tracing:: debug!( "currently missing pools are {:?}" , missing_pools) ;
273-
274- let pool_ids = missing_pools. into_iter ( ) . collect :: < Vec < _ > > ( ) ;
275- let start = std:: time:: Instant :: now ( ) ;
266+ /// Fetches and converts the given pools from the source at `target_block`,
267+ /// skipping any that can't be converted yet (e.g. no ticks). Doesn't touch
268+ /// the cache. Pass the checkpoint block to cache for replay, or 0 to take
269+ /// the indexer's current snapshot without blocking.
270+ async fn fetch_pools (
271+ & self ,
272+ pool_ids : & [ Address ] ,
273+ target_block : u64 ,
274+ ) -> Result < Vec < ( Address , Arc < PoolInfo > ) > > {
275+ if pool_ids. is_empty ( ) {
276+ return Ok ( Vec :: new ( ) ) ;
277+ }
276278 let pools_with_ticks = self
277279 . source
278- . get_pools_with_ticks_by_ids ( & pool_ids, block_number)
279- . await ;
280- tracing:: debug!(
281- requested_pools = pool_ids. len( ) ,
282- time = ?start. elapsed( ) ,
283- request_successful = pools_with_ticks. is_ok( ) ,
284- "fetched pool ticks"
285- ) ;
286- let pools_with_ticks = pools_with_ticks?;
280+ . get_pools_with_ticks_by_ids ( pool_ids, target_block)
281+ . await ?;
282+ Ok ( pools_with_ticks
283+ . pools
284+ . into_iter ( )
285+ . filter_map ( |pool| {
286+ let id = pool. id ;
287+ match PoolInfo :: try_from ( pool) {
288+ Ok ( info) => Some ( ( id, Arc :: new ( info) ) ) ,
289+ Err ( err) => {
290+ tracing:: debug!( ?id, ?err, "skipping pool missing tick data" ) ;
291+ None
292+ }
293+ }
294+ } )
295+ . collect ( ) )
296+ }
287297
288- let mut checkpoint = self . pools_checkpoint . lock ( ) . unwrap ( ) ;
289- for pool in pools_with_ticks. pools {
290- checkpoint. missing_pools . remove ( & pool. id ) ;
291- checkpoint. pools . insert ( pool. id , Arc :: new ( pool. try_into ( ) ?) ) ;
298+ /// Fetches the given pools at the indexer's current block, without waiting.
299+ /// The checkpoint block can sit ahead of the indexer (it tracks latest
300+ /// minus the reorg buffer, the indexer serves finalized), so waiting
301+ /// for it would hang the quote. Returns empty on failure so the quote
302+ /// falls back to cached pools.
303+ async fn fetch_current ( & self , pool_ids : & [ Address ] ) -> Vec < ( Address , Arc < PoolInfo > ) > {
304+ match self . fetch_pools ( pool_ids, 0 ) . await {
305+ Ok ( pools) => pools,
306+ Err ( err) => {
307+ tracing:: debug!(
308+ ?err,
309+ "on-demand pool fetch failed; serving cached pools only"
310+ ) ;
311+ Vec :: new ( )
312+ }
292313 }
314+ }
293315
294- tracing:: debug!( "number of cached pools is {}" , checkpoint. pools. len( ) ) ;
316+ /// Fetches the pools flagged missing by `get` at the checkpoint block and
317+ /// caches them. Runs from periodic maintenance.
318+ async fn update_missing_pools ( & self ) -> Result < ( ) > {
319+ let ( missing, block_number) = {
320+ let checkpoint = self . pools_checkpoint . lock ( ) . unwrap ( ) ;
321+ (
322+ checkpoint. missing_pools . iter ( ) . copied ( ) . collect :: < Vec < _ > > ( ) ,
323+ checkpoint. block_number ,
324+ )
325+ } ;
326+ let fetched = self . fetch_pools ( & missing, block_number) . await ?;
327+ let mut checkpoint = self . pools_checkpoint . lock ( ) . unwrap ( ) ;
328+ for ( id, info) in fetched {
329+ checkpoint. missing_pools . remove ( & id) ;
330+ checkpoint. pools . insert ( id, info) ;
331+ }
295332 if !checkpoint. missing_pools . is_empty ( ) {
296333 tracing:: warn!(
297- "not all missing pools updated: {:?}" ,
298- checkpoint . missing_pools
334+ remaining = checkpoint . missing_pools . len ( ) ,
335+ "not all missing pools updated"
299336 ) ;
300337 }
301338 Ok ( ( ) )
@@ -421,14 +458,28 @@ impl PoolFetching for UniswapV3PoolFetcher {
421458
422459 // this is the only place where this function uses checkpoint - no data racing
423460 // between maintenance
424- let ( mut checkpoint, checkpoint_block_number) = self . checkpoint . get ( token_pairs) ;
461+ let ( mut checkpoint, missing, checkpoint_block_number) = self . checkpoint . get ( token_pairs) ;
462+
463+ // No pools registered for these pairs: `get` returns block 0, so skip the
464+ // replay below (it would otherwise scan events from block 1) and return.
465+ if checkpoint. is_empty ( ) && missing. is_empty ( ) {
466+ return Ok ( Vec :: new ( ) ) ;
467+ }
425468
426469 if block_number > checkpoint_block_number {
427470 let block_range = RangeInclusive :: try_new ( checkpoint_block_number + 1 , block_number) ?;
428471 let events = self . events . lock ( ) . await . store ( ) . get_events ( block_range) ;
429472 append_events ( & mut checkpoint, events) ;
430473 }
431474
475+ // The warm cache only holds the top pools by raw liquidity, so plenty of
476+ // real pairs are absent. Fetch those on demand instead of waiting for the
477+ // next maintenance tick. They come back current, so merge them after the
478+ // replay rather than replaying them.
479+ if !missing. is_empty ( ) {
480+ checkpoint. extend ( self . checkpoint . fetch_current ( & missing) . await ) ;
481+ }
482+
432483 // return only pools which current liquidity is positive
433484 Ok ( checkpoint
434485 . into_values ( )
@@ -766,4 +817,194 @@ mod tests {
766817 ] )
767818 ) ;
768819 }
820+
821+ /// Serves a fixed set of pools (with ticks) from
822+ /// `get_pools_with_ticks_by_ids` so the on-demand fetch path can be
823+ /// exercised without a real source. `served_block` models the indexer's
824+ /// head: a request for a higher `target_block` fails, mirroring the real
825+ /// client's `wait_until` blocking on a block the indexer hasn't reached.
826+ struct StubSource {
827+ with_ticks : HashMap < Address , PoolData > ,
828+ served_block : u64 ,
829+ }
830+
831+ impl StubSource {
832+ fn new ( pools : impl IntoIterator < Item = PoolData > ) -> Self {
833+ Self {
834+ with_ticks : pools. into_iter ( ) . map ( |p| ( p. id , p) ) . collect ( ) ,
835+ served_block : u64:: MAX ,
836+ }
837+ }
838+ }
839+
840+ #[ async_trait:: async_trait]
841+ impl V3PoolDataSource for StubSource {
842+ async fn get_registered_pools (
843+ & self ,
844+ _target_block : u64 ,
845+ ) -> Result < crate :: uniswap_v3:: graph_api:: RegisteredPools > {
846+ Ok ( Default :: default ( ) )
847+ }
848+
849+ async fn get_pools_with_ticks_by_ids (
850+ & self ,
851+ ids : & [ Address ] ,
852+ target_block : u64 ,
853+ ) -> Result < crate :: uniswap_v3:: graph_api:: PoolsWithTicks > {
854+ anyhow:: ensure!(
855+ target_block <= self . served_block,
856+ "indexer at {} hasn't reached target block {target_block}" ,
857+ self . served_block,
858+ ) ;
859+ let pools = ids
860+ . iter ( )
861+ . filter_map ( |id| self . with_ticks . get ( id) . cloned ( ) )
862+ . collect ( ) ;
863+ Ok ( crate :: uniswap_v3:: graph_api:: PoolsWithTicks {
864+ fetched_block_number : self . served_block ,
865+ pools,
866+ } )
867+ }
868+ }
869+
870+ fn pool_with_ticks ( id : Address , token0 : Address , token1 : Address ) -> PoolData {
871+ PoolData {
872+ id,
873+ token0 : Token {
874+ id : token0,
875+ decimals : 6 ,
876+ } ,
877+ token1 : Token {
878+ id : token1,
879+ decimals : 18 ,
880+ } ,
881+ fee_tier : U256 :: from ( 3000 ) ,
882+ liquidity : U256 :: from ( 1_000_000u64 ) ,
883+ sqrt_price : U256 :: from ( 1u64 ) ,
884+ tick : BigInt :: from ( 0 ) ,
885+ ticks : Some ( vec ! [ crate :: uniswap_v3:: graph_api:: TickData {
886+ tick_idx: BigInt :: from( -100 ) ,
887+ liquidity_net: BigInt :: from( 1_000 ) ,
888+ pool_address: id,
889+ } ] ) ,
890+ block_number : 100 ,
891+ }
892+ }
893+
894+ fn handler ( source : StubSource , checkpoint : PoolsCheckpoint ) -> PoolsCheckpointHandler {
895+ PoolsCheckpointHandler {
896+ source : Arc :: new ( source) ,
897+ pools_by_token_pair : HashMap :: new ( ) ,
898+ pools_checkpoint : Mutex :: new ( checkpoint) ,
899+ }
900+ }
901+
902+ /// A pool registered for a pair but absent from the warm cache (the
903+ /// raw-liquidity-ranked top-N) is flagged missing by `get` and resolved by
904+ /// a single on-demand `fetch_current`, without waiting for a
905+ /// maintenance cycle.
906+ #[ tokio:: test]
907+ async fn on_demand_fetch_serves_uncached_pool ( ) {
908+ let token0 = Address :: with_last_byte ( 1 ) ;
909+ let token1 = Address :: with_last_byte ( 2 ) ;
910+ let pair = TokenPair :: new ( token0, token1) . unwrap ( ) ;
911+ let pool = Address :: with_last_byte ( 9 ) ;
912+
913+ let mut handler = handler (
914+ StubSource :: new ( [ pool_with_ticks ( pool, token0, token1) ] ) ,
915+ PoolsCheckpoint {
916+ pools : HashMap :: new ( ) ,
917+ block_number : 100 ,
918+ missing_pools : HashSet :: new ( ) ,
919+ } ,
920+ ) ;
921+ handler. pools_by_token_pair = HashMap :: from ( [ ( pair, vec ! [ pool] ) ] ) ;
922+
923+ // Cold: not cached, flagged missing.
924+ let ( cached, missing, _) = handler. get ( & HashSet :: from ( [ pair] ) ) ;
925+ assert ! ( cached. is_empty( ) ) ;
926+ assert_eq ! ( missing, vec![ pool] ) ;
927+
928+ // On-demand fetch resolves it.
929+ let fetched = handler. fetch_current ( & missing) . await ;
930+ assert_eq ! ( fetched. len( ) , 1 ) ;
931+ assert_eq ! ( fetched[ 0 ] . 0 , pool) ;
932+ }
933+
934+ /// The on-demand path must not block on the checkpoint block (which can sit
935+ /// persistently ahead of the indexer's served block); it fetches at the
936+ /// indexer's current block. A source that errors for any block above its
937+ /// head still yields the pool via `fetch_current`.
938+ #[ tokio:: test]
939+ async fn on_demand_does_not_wait_for_future_block ( ) {
940+ let token0 = Address :: with_last_byte ( 1 ) ;
941+ let token1 = Address :: with_last_byte ( 2 ) ;
942+ let pool = Address :: with_last_byte ( 9 ) ;
943+
944+ let mut source = StubSource :: new ( [ pool_with_ticks ( pool, token0, token1) ] ) ;
945+ source. served_block = 50 ; // indexer behind the checkpoint below
946+
947+ let handler = handler (
948+ source,
949+ PoolsCheckpoint {
950+ pools : HashMap :: new ( ) ,
951+ block_number : 100 , // checkpoint ahead of the indexer
952+ missing_pools : HashSet :: new ( ) ,
953+ } ,
954+ ) ;
955+
956+ // fetch_current uses target 0, so it succeeds despite served_block <
957+ // checkpoint.
958+ let fetched = handler. fetch_current ( & [ pool] ) . await ;
959+ assert_eq ! ( fetched. len( ) , 1 ) ;
960+ assert_eq ! ( fetched[ 0 ] . 0 , pool) ;
961+
962+ // Fetching at the checkpoint block would fail (indexer hasn't reached it).
963+ assert ! ( handler. fetch_pools( & [ pool] , 100 ) . await . is_err( ) ) ;
964+ }
965+
966+ /// Unknown pairs have no registered pools, so `get` reports block 0 with
967+ /// nothing cached or missing — the signal `fetch` uses to skip the replay.
968+ #[ test]
969+ fn get_reports_zero_block_for_unknown_pairs ( ) {
970+ let handler = handler (
971+ StubSource :: new ( [ ] ) ,
972+ PoolsCheckpoint {
973+ pools : HashMap :: new ( ) ,
974+ block_number : 100 ,
975+ missing_pools : HashSet :: new ( ) ,
976+ } ,
977+ ) ;
978+ let pair = TokenPair :: new ( Address :: with_last_byte ( 1 ) , Address :: with_last_byte ( 2 ) ) . unwrap ( ) ;
979+ let ( cached, missing, block) = handler. get ( & HashSet :: from ( [ pair] ) ) ;
980+ assert ! ( cached. is_empty( ) ) ;
981+ assert ! ( missing. is_empty( ) ) ;
982+ assert_eq ! ( block, 0 ) ;
983+ }
984+
985+ /// A pool that can't be converted (e.g. ticks not yet available) is skipped
986+ /// rather than failing the whole batch; the convertible pool is returned.
987+ #[ tokio:: test]
988+ async fn fetch_pools_skips_unconvertible_pool ( ) {
989+ let token0 = Address :: with_last_byte ( 1 ) ;
990+ let token1 = Address :: with_last_byte ( 2 ) ;
991+ let good = Address :: with_last_byte ( 9 ) ;
992+ let bad = Address :: with_last_byte ( 10 ) ;
993+
994+ let mut bad_pool = pool_with_ticks ( bad, token0, token1) ;
995+ bad_pool. ticks = None ; // PoolInfo::try_from fails on missing ticks
996+
997+ let handler = handler (
998+ StubSource :: new ( [ pool_with_ticks ( good, token0, token1) , bad_pool] ) ,
999+ PoolsCheckpoint {
1000+ pools : HashMap :: new ( ) ,
1001+ block_number : 100 ,
1002+ missing_pools : HashSet :: new ( ) ,
1003+ } ,
1004+ ) ;
1005+
1006+ let fetched = handler. fetch_pools ( & [ good, bad] , 0 ) . await . unwrap ( ) ;
1007+ assert_eq ! ( fetched. len( ) , 1 ) ;
1008+ assert_eq ! ( fetched[ 0 ] . 0 , good) ;
1009+ }
7691010}
0 commit comments