11use std:: collections:: HashMap ;
22use std:: sync:: { Arc , Mutex } ;
3+ use std:: time:: { Duration , Instant } ;
34
45use bytes:: Bytes ;
56use cas_client:: adaptive_concurrency:: ConnectionPermit ;
67use cas_client:: { Client , ProgressCallback , URLProvider } ;
7- use xorb_object:: SerializedXorbObject ;
8- use cas_types:: { BatchQueryReconstructionResponse , FileRange , QueryReconstructionResponse } ;
8+ use cas_types:: {
9+ BatchQueryReconstructionResponse , FileRange , HexMerkleHash , QueryReconstructionResponse , XorbReconstructionTerm ,
10+ } ;
911use mdb_shard:: file_structs:: MDBFileInfo ;
1012use merklehash:: MerkleHash ;
11- use tracing :: debug ;
13+ use xorb_object :: SerializedXorbObject ;
1214
1315type Result < T > = std:: result:: Result < T , cas_client:: CasClientError > ;
1416
1517const MAX_CACHE_ENTRIES : usize = 1024 ;
18+ /// Presigned URLs returned by the CAS server expire after 1 hour.
19+ /// Evict cache entries just before expiry to avoid serving stale URLs.
20+ const CACHE_TTL : Duration = Duration :: from_secs ( 59 * 60 ) ;
1621
17- type CacheKey = ( MerkleHash , Option < FileRange > ) ;
22+ struct CacheEntry {
23+ response : QueryReconstructionResponse ,
24+ inserted_at : Instant ,
25+ }
26+
27+ impl CacheEntry {
28+ fn new ( response : QueryReconstructionResponse ) -> Self {
29+ Self {
30+ response,
31+ inserted_at : Instant :: now ( ) ,
32+ }
33+ }
34+
35+ fn is_valid ( & self , ttl : Duration ) -> bool {
36+ self . inserted_at . elapsed ( ) < ttl
37+ }
38+ }
1839
1940// TODO: move this into xet-core (cas_client or file_reconstruction) so all consumers benefit.
2041pub struct CachedXetClient {
2142 inner : Arc < dyn Client > ,
22- cache : Mutex < HashMap < CacheKey , QueryReconstructionResponse > > ,
43+ /// Only full-file reconstruction plans are cached (key = file hash, no range).
44+ /// Range responses are derived on the fly via `derive_range_response`.
45+ cache : Mutex < HashMap < MerkleHash , CacheEntry > > ,
46+ ttl : Duration ,
2347}
2448
2549impl CachedXetClient {
2650 pub fn new ( inner : Arc < dyn Client > ) -> Arc < Self > {
51+ Self :: new_with_ttl ( inner, CACHE_TTL )
52+ }
53+
54+ fn new_with_ttl ( inner : Arc < dyn Client > , ttl : Duration ) -> Arc < Self > {
2755 Arc :: new ( Self {
2856 inner,
2957 cache : Mutex :: new ( HashMap :: new ( ) ) ,
58+ ttl,
3059 } )
3160 }
3261}
3362
63+ /// Derive a range-scoped `QueryReconstructionResponse` from a cached full-file response.
64+ ///
65+ /// The full-file response lists all terms in file order with their unpacked byte lengths.
66+ /// We walk the terms, track cumulative byte offsets, and keep only terms that overlap
67+ /// `[range.start, range.end)`. The `offset_into_first_range` is the byte offset within
68+ /// the first overlapping term at which the requested range starts.
69+ ///
70+ /// # Limitation: over-fetch vs CAS server trimming (P2)
71+ ///
72+ /// When the CAS server handles a range query directly, it trims each term's `ChunkRange`
73+ /// at chunk granularity: it advances `range.start` past whole chunks whose data falls
74+ /// entirely before the requested offset, and cuts `range.end` after the last needed chunk.
75+ /// This produces tight presigned S3 URLs covering only the necessary compressed bytes.
76+ ///
77+ /// This function cannot do the same trimming because `XorbReconstructionTerm` only carries
78+ /// `unpacked_length` for the whole term, not per-chunk sizes. As a result, derived responses
79+ /// keep the full `ChunkRange` of each overlapping term — the caller downloads and decompresses
80+ /// up to one extra chunk at the start and one at the end per term.
81+ ///
82+ /// In practice the over-fetch is small: at most ~2 × chunk_size (~128 KB) per term per
83+ /// range query. For typical safetensors workloads this is negligible (<1% of total data).
84+ ///
85+ /// TODO: fix P2 — add `chunk_uncompressed_sizes: Vec<u32>` (and `chunk_compressed_sizes`)
86+ /// to `XorbReconstructionTerm` in xet-core/xetcas so that chunk-level trimming can be
87+ /// replicated client-side, reducing over-fetch to zero.
88+ fn derive_range_response ( full : & QueryReconstructionResponse , range : FileRange ) -> QueryReconstructionResponse {
89+ let mut cur_offset: u64 = 0 ;
90+ let mut result_terms: Vec < XorbReconstructionTerm > = Vec :: new ( ) ;
91+ let mut offset_into_first: u64 = 0 ;
92+ let mut needed_hashes: std:: collections:: HashSet < HexMerkleHash > = std:: collections:: HashSet :: new ( ) ;
93+
94+ for term in & full. terms {
95+ let term_start = cur_offset;
96+ let term_end = cur_offset + term. unpacked_length as u64 ;
97+
98+ if term_end <= range. start {
99+ cur_offset = term_end;
100+ continue ;
101+ }
102+ if term_start >= range. end {
103+ break ;
104+ }
105+
106+ if result_terms. is_empty ( ) {
107+ offset_into_first = range. start . saturating_sub ( term_start) ;
108+ }
109+ needed_hashes. insert ( term. hash ) ;
110+ result_terms. push ( term. clone ( ) ) ;
111+ cur_offset = term_end;
112+ }
113+
114+ let fetch_info = full
115+ . fetch_info
116+ . iter ( )
117+ . filter ( |( k, _) | needed_hashes. contains ( * k) )
118+ . map ( |( k, v) | ( * k, v. clone ( ) ) )
119+ . collect ( ) ;
120+
121+ QueryReconstructionResponse {
122+ offset_into_first_range : offset_into_first,
123+ terms : result_terms,
124+ fetch_info,
125+ }
126+ }
127+
34128#[ async_trait:: async_trait]
35129impl Client for CachedXetClient {
36130 async fn get_reconstruction (
37131 & self ,
38132 file_id : & MerkleHash ,
39133 bytes_range : Option < FileRange > ,
40134 ) -> Result < Option < QueryReconstructionResponse > > {
41- let key = ( * file_id, bytes_range) ;
42-
43- {
44- let cache = self . cache . lock ( ) . expect ( "cache poisoned" ) ;
45- if let Some ( response) = cache. get ( & key) {
46- debug ! ( "reconstruction cache hit for {file_id}" ) ;
47- return Ok ( Some ( response. clone ( ) ) ) ;
135+ // Clone the full-file plan under a brief lock, then derive outside the lock so
136+ // concurrent pread() calls from N threads don't serialize on this mutex.
137+ let cached_full = {
138+ let mut cache = self . cache . lock ( ) . expect ( "cache poisoned" ) ;
139+ match cache. get ( file_id) {
140+ Some ( entry) if entry. is_valid ( self . ttl ) => Some ( entry. response . clone ( ) ) ,
141+ Some ( _) => {
142+ // Expired — evict so the next caller fetches fresh URLs from CAS.
143+ cache. remove ( file_id) ;
144+ None
145+ }
146+ None => None ,
147+ }
148+ } ; // ← lock released here
149+
150+ if let Some ( full) = cached_full {
151+ if let Some ( range) = bytes_range {
152+ let resp = derive_range_response ( & full, range) ;
153+ // Mirror the CAS server's EOF contract:
154+ // - Non-empty file, range past EOF → None
155+ // - Empty file, range.start == 0 → Some(empty_terms) (only valid range on empty file)
156+ // - Empty file, range.start > 0 → None (past EOF on empty file)
157+ if resp. terms . is_empty ( ) && ( !full. terms . is_empty ( ) || range. start > 0 ) {
158+ return Ok ( None ) ;
159+ }
160+ tracing:: debug!(
161+ "recon: DERV file={:.8} range={:?} terms={}" ,
162+ file_id,
163+ bytes_range,
164+ resp. terms. len( )
165+ ) ;
166+ return Ok ( Some ( resp) ) ;
167+ } else {
168+ tracing:: debug!( "recon: HIT file={:.8} range=None terms={}" , file_id, full. terms. len( ) ) ;
169+ return Ok ( Some ( full) ) ;
48170 }
49171 }
50172
173+ tracing:: debug!( "recon: CAS file={:.8} range={:?}" , file_id, bytes_range) ;
51174 let result = self . inner . get_reconstruction ( file_id, bytes_range) . await ?;
52175
53- if let Some ( ref response) = result {
176+ // Only cache full-file plans. Range responses are never stored: they are always
177+ // derived from the full-file plan, and the full-file plan is warmed at open() time.
178+ if bytes_range. is_none ( )
179+ && let Some ( response) = & result
180+ {
54181 let mut cache = self . cache . lock ( ) . expect ( "cache poisoned" ) ;
55182 if cache. len ( ) >= MAX_CACHE_ENTRIES {
56183 cache. clear ( ) ;
57184 }
58- cache. insert ( key , response. clone ( ) ) ;
185+ cache. insert ( * file_id , CacheEntry :: new ( response. clone ( ) ) ) ;
59186 }
60187
61188 Ok ( result)
@@ -134,7 +261,7 @@ mod tests {
134261
135262 struct MockClient {
136263 mode : MockMode ,
137- calls : Mutex < HashMap < CacheKey , usize > > ,
264+ calls : Mutex < HashMap < ( MerkleHash , Option < FileRange > ) , usize > > ,
138265 total_calls : AtomicUsize ,
139266 barrier : Option < Arc < tokio:: sync:: Barrier > > ,
140267 }
@@ -158,7 +285,7 @@ mod tests {
158285 }
159286 }
160287
161- fn call_count ( & self , key : CacheKey ) -> usize {
288+ fn call_count ( & self , key : ( MerkleHash , Option < FileRange > ) ) -> usize {
162289 self . calls
163290 . lock ( )
164291 . expect ( "calls lock poisoned" )
@@ -297,23 +424,60 @@ mod tests {
297424 }
298425
299426 #[ tokio:: test]
300- async fn cache_key_includes_bytes_range ( ) {
427+ async fn range_derived_from_full_file_plan ( ) {
301428 let inner_impl = Arc :: new ( MockClient :: new ( MockMode :: ReturnSome ) ) ;
302429 let inner: Arc < dyn Client > = inner_impl. clone ( ) ;
303430 let client = CachedXetClient :: new ( inner) ;
304431 let key = hash_for ( 4 ) ;
305432 let r = Some ( FileRange :: new ( 10 , 20 ) ) ;
306433
434+ // Fetch full-file plan first (caches it).
307435 client. get_reconstruction ( & key, None ) . await . unwrap ( ) ;
308436 client. get_reconstruction ( & key, None ) . await . unwrap ( ) ;
437+
438+ // Range queries are derived from the cached full-file plan — never hit backend.
309439 client. get_reconstruction ( & key, r) . await . unwrap ( ) ;
310- client. get_reconstruction ( & key, r) . await . unwrap ( ) ;
440+ client
441+ . get_reconstruction ( & key, Some ( FileRange :: new ( 20 , 30 ) ) )
442+ . await
443+ . unwrap ( ) ;
311444
312445 assert_eq ! ( inner_impl. call_count( ( key, None ) ) , 1 ) ;
313- assert_eq ! ( inner_impl. call_count( ( key, r) ) , 1 ) ;
446+ assert_eq ! ( inner_impl. call_count( ( key, r) ) , 0 ) ; // derived, never hit backend
447+ assert_eq ! ( inner_impl. total_calls( ) , 1 ) ;
448+ }
449+
450+ #[ tokio:: test]
451+ async fn range_query_hits_backend_when_no_full_file_cached ( ) {
452+ let inner_impl = Arc :: new ( MockClient :: new ( MockMode :: ReturnSome ) ) ;
453+ let inner: Arc < dyn Client > = inner_impl. clone ( ) ;
454+ let client = CachedXetClient :: new ( inner) ;
455+ let key = hash_for ( 40 ) ;
456+ let r = Some ( FileRange :: new ( 10 , 20 ) ) ;
457+
458+ // Range query with no full-file plan cached → hits backend.
459+ // The response is NOT cached (range responses are never stored).
460+ client. get_reconstruction ( & key, r) . await . unwrap ( ) ;
461+ client. get_reconstruction ( & key, r) . await . unwrap ( ) ;
462+
463+ assert_eq ! ( inner_impl. call_count( ( key, r) ) , 2 ) ; // no cache, each hits backend
314464 assert_eq ! ( inner_impl. total_calls( ) , 2 ) ;
315465 }
316466
467+ #[ tokio:: test]
468+ async fn expired_entries_are_evicted_and_refetched ( ) {
469+ let inner_impl = Arc :: new ( MockClient :: new ( MockMode :: ReturnSome ) ) ;
470+ let inner: Arc < dyn Client > = inner_impl. clone ( ) ;
471+ let client = CachedXetClient :: new_with_ttl ( inner, Duration :: ZERO ) ;
472+ let key = hash_for ( 50 ) ;
473+
474+ // With TTL=0 every entry is immediately expired — each call hits the backend.
475+ client. get_reconstruction ( & key, None ) . await . unwrap ( ) ;
476+ client. get_reconstruction ( & key, None ) . await . unwrap ( ) ;
477+
478+ assert_eq ! ( inner_impl. call_count( ( key, None ) ) , 2 ) ;
479+ }
480+
317481 #[ tokio:: test]
318482 async fn cache_clears_when_capacity_is_reached ( ) {
319483 let inner_impl = Arc :: new ( MockClient :: new ( MockMode :: ReturnSome ) ) ;
0 commit comments