Skip to main content

xet_data/file_reconstruction/reconstruction_terms/
xorb_block.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use bytes::Bytes;
5use tokio::sync::{Mutex, OnceCell};
6use xet_client::cas_client::{Client, ProgressCallback};
7use xet_client::cas_types::ChunkRange;
8#[cfg(not(target_family = "wasm"))]
9use xet_client::cas_types::Key;
10use xet_client::chunk_cache::ChunkCache;
11use xet_core_structures::merklehash::MerkleHash;
12use xet_runtime::core::XetContext;
13use xet_runtime::utils::UniqueId;
14
15use super::super::error::Result;
16use super::retrieval_urls::{TermBlockRetrievalURLs, XorbURLProvider};
17use crate::progress_tracking::ItemProgressUpdater;
18
19/// Downloaded and decompressed data for a xorb block, including chunk boundary offsets.
20///
21/// A single `XorbBlockData` may hold data from multiple disjoint chunk ranges
22/// (V2 multi-range fetch). The chunks are concatenated in range order, and
23/// `chunk_offsets` maps each chunk index to its byte position within `data`.
24pub struct XorbBlockData {
25    /// Pairs of (chunk_index, byte_offset) mapping each chunk to its start position
26    /// within `data`. Because the block can span multiple disjoint chunk ranges,
27    /// storing the chunk index alongside the offset avoids ambiguity.
28    pub chunk_offsets: Vec<(usize, usize)>,
29
30    /// The concatenated decompressed chunk data for all ranges in this block.
31    pub data: Bytes,
32}
33
34/// A reference from a file term back to the xorb block it belongs to.
35/// Used by `determine_size_if_possible` to check whether the block's total
36/// uncompressed size can be inferred from the terms that reference it.
37#[derive(Debug)]
38pub struct XorbReference {
39    /// The chunk range within the xorb that this file term covers.
40    pub term_chunks: ChunkRange,
41    /// The uncompressed byte size of this term's data.
42    pub uncompressed_size: usize,
43}
44
45/// A downloadable xorb block identified by hash and chunk ranges, with cached data.
46///
47/// A block may contain multiple disjoint chunk ranges from the same xorb (V2 multi-range).
48/// Multiple file terms may reference the same block. Downloaded data is cached in `data`
49/// so that the first term to request it triggers the download, and subsequent terms
50/// reuse the cached result.
51pub struct XorbBlock {
52    pub xorb_hash: MerkleHash,
53    /// The chunk ranges fetched for this block. For V1 this is a single range;
54    /// for V2 multi-range fetches this may contain multiple disjoint ranges.
55    pub chunk_ranges: Vec<ChunkRange>,
56    /// Index into the parent `TermBlockRetrievalURLs` for URL lookup.
57    pub xorb_block_index: usize,
58    /// All file-term references covered by this block, sorted by chunk range start.
59    /// Populated during `retrieve_file_term_block` and used to compute `uncompressed_size_if_known`.
60    pub references: Vec<XorbReference>,
61    /// Expected total decompressed size across all chunk ranges, if it can be determined
62    /// from the references. Passed to clients as a debug assertion hint.
63    pub uncompressed_size_if_known: Option<usize>,
64    pub data: OnceCell<Arc<XorbBlockData>>,
65}
66
67impl PartialEq for XorbBlock {
68    fn eq(&self, other: &Self) -> bool {
69        self.xorb_hash == other.xorb_hash
70            && self.chunk_ranges == other.chunk_ranges
71            && self.xorb_block_index == other.xorb_block_index
72    }
73}
74
75impl Eq for XorbBlock {}
76
77/// Builds chunk offset pairs from chunk ranges and a flat byte-offset slice.
78fn build_chunk_offsets(chunk_ranges: &[ChunkRange], byte_offsets: &[u32]) -> Vec<(usize, usize)> {
79    let mut chunk_offsets = Vec::new();
80    let mut offset_idx = 0;
81    for range in chunk_ranges {
82        for chunk_idx in range.start..range.end {
83            chunk_offsets.push((chunk_idx as usize, byte_offsets[offset_idx] as usize));
84            offset_idx += 1;
85        }
86    }
87    chunk_offsets
88}
89
90impl XorbBlock {
91    /// Retrieve the xorb block data from the client, caching it for subsequent calls.
92    ///
93    /// Uses single-flight: the first caller acquires a CAS download permit and downloads
94    /// the data; concurrent callers wait on the same result without acquiring permits or
95    /// duplicating work. If the download fails, the cell remains empty and a later caller
96    /// can retry.
97    pub async fn retrieve_data(
98        self: Arc<Self>,
99        ctx: XetContext,
100        client: Arc<dyn Client>,
101        url_info: Arc<TermBlockRetrievalURLs>,
102        progress_updater: Option<Arc<ItemProgressUpdater>>,
103        #[cfg_attr(target_family = "wasm", allow(unused_variables))] chunk_cache: Option<Arc<dyn ChunkCache>>,
104    ) -> Result<Arc<XorbBlockData>> {
105        let xorb_block_index = self.xorb_block_index;
106        let uncompressed_size_if_known = self.uncompressed_size_if_known;
107        let chunk_ranges = self.chunk_ranges.clone();
108
109        self.data
110            .get_or_try_init(|| async {
111                // Try the on-disk chunk cache before hitting the network.
112                // NOTE: cache key uses only the first ChunkRange. This works when each
113                // XorbBlock has a single range, but will need rework if multi-range
114                // blocks (multiple disjoint chunk ranges per block) are cached.
115                // Wasm has no disk-backed ChunkCache; skip the cache read entirely.
116                #[cfg(not(target_family = "wasm"))]
117                if let Some(ref cache) = chunk_cache {
118                    let cache_key = Key {
119                        prefix: ctx.config.data.default_prefix.clone(),
120                        hash: self.xorb_hash,
121                    };
122                    let chunk_range = chunk_ranges.first().copied().unwrap_or_default();
123
124                    if let Ok(Some(cache_range)) = cache.get(&cache_key, &chunk_range).await {
125                        // Report cached bytes as completed so progress tracking stays consistent.
126                        if let Some(ref updater) = progress_updater {
127                            let (_, _, http_ranges) = url_info.get_retrieval_url(xorb_block_index).await;
128                            let transfer_bytes: u64 = http_ranges.iter().map(|r| r.length()).sum();
129                            updater.report_transfer_progress(transfer_bytes);
130                        }
131                        let chunk_offsets = build_chunk_offsets(&chunk_ranges, &cache_range.offsets);
132                        let data = Bytes::from(cache_range.data);
133                        return Ok(Arc::new(XorbBlockData { chunk_offsets, data }));
134                    }
135                }
136
137                // Cache miss or no cache configured - download from CAS.
138                let permit = client.acquire_download_permit().await?;
139
140                let url_provider = XorbURLProvider {
141                    ctx: ctx.clone(),
142                    client: client.clone(),
143                    url_info,
144                    xorb_block_index,
145                    last_acquisition_id: Mutex::new(UniqueId::null()),
146                };
147
148                // Progress callback reports only transfer (network) bytes during get_file_term_data.
149                // Decompressed bytes are reported by the data writer when written to disk.
150                let progress_callback: Option<ProgressCallback> = progress_updater.as_ref().map(|updater| {
151                    let updater = updater.clone();
152                    Arc::new(move |delta: u64, _completed: u64, _total: u64| {
153                        updater.report_transfer_progress(delta);
154                    }) as ProgressCallback
155                });
156
157                let (data, chunk_byte_offsets) = client
158                    .get_file_term_data(Box::new(url_provider), permit, progress_callback, uncompressed_size_if_known)
159                    .await?;
160
161                // Store in chunk cache (best-effort, non-blocking).
162                #[cfg(not(target_family = "wasm"))]
163                if let Some(cache) = chunk_cache {
164                    let cache_key = Key {
165                        prefix: ctx.config.data.default_prefix.clone(),
166                        hash: self.xorb_hash,
167                    };
168                    let chunk_range = chunk_ranges.first().copied().unwrap_or_default();
169                    let data = data.clone();
170                    let chunk_byte_offsets = chunk_byte_offsets.clone();
171                    tokio::spawn(async move {
172                        if let Err(err) = cache.put(&cache_key, &chunk_range, &chunk_byte_offsets, &data).await {
173                            tracing::warn!("chunk cache put failed: {err}");
174                        }
175                    });
176                }
177
178                let chunk_offsets = build_chunk_offsets(&chunk_ranges, &chunk_byte_offsets);
179
180                Ok(Arc::new(XorbBlockData { chunk_offsets, data }))
181            })
182            .await
183            .cloned()
184    }
185
186    /// Determines the total uncompressed size of the xorb block from the reference terms,
187    /// if possible.
188    ///
189    /// Uses a forward-chaining DP: starting from the first chunk range's start,
190    /// we track which chunk positions are "reachable" (i.e., fully covered by a
191    /// contiguous chain of terms) along with the accumulated uncompressed size.
192    ///
193    /// For multi-range blocks with disjoint chunk ranges (e.g. `[0,3)` and `[5,8)`),
194    /// the gaps between ranges are inserted as zero-cost bridges. This lets the DP
195    /// traverse the full set of ranges in a single pass — a gap `[3,5)` contributes
196    /// no data but connects the end of one range to the start of the next.
197    ///
198    /// Returns `Some(total_size)` if every range is fully covered, `None` otherwise.
199    ///
200    /// The `terms` slice must be sorted by `term_chunks.start`.
201    pub fn determine_size_if_possible(xorb_ranges: &[ChunkRange], terms: &[XorbReference]) -> Option<usize> {
202        debug_assert!(
203            terms.windows(2).all(|w| w[0].term_chunks.start <= w[1].term_chunks.start),
204            "terms must be sorted by chunk range start"
205        );
206
207        debug_assert!(
208            terms.iter().all(|term| xorb_ranges
209                .iter()
210                .any(|r| term.term_chunks.start >= r.start && term.term_chunks.end <= r.end)),
211            "all terms must fall within one of the xorb ranges"
212        );
213
214        if xorb_ranges.is_empty() {
215            return Some(0);
216        }
217
218        // Build a lookup from range-end -> next-range-start for gap bridging.
219        // E.g. for ranges [0,3) and [5,8), maps 3 -> 5, meaning once chunk 3
220        // is reachable we can bridge to chunk 5 at zero cost.
221        let gap_bridges: BTreeMap<u32, u32> = xorb_ranges
222            .windows(2)
223            .filter(|pair| pair[0].end < pair[1].start)
224            .map(|pair| (pair[0].end, pair[1].start))
225            .collect();
226
227        // DP map: chunk position -> accumulated uncompressed size to reach that position.
228        // Seed with the start of the first range.
229        let mut reachable: BTreeMap<u32, usize> = BTreeMap::new();
230        reachable.insert(xorb_ranges[0].start, 0);
231
232        // Process terms in sorted order, extending reachable positions.
233        for term in terms {
234            if let Some(&accumulated) = reachable.get(&term.term_chunks.start) {
235                let new_end = term.term_chunks.end;
236                let new_size = accumulated + term.uncompressed_size;
237
238                reachable.entry(new_end).or_insert(new_size);
239
240                // If this term reaches the end of a range that has a gap bridge,
241                // make the start of the next range reachable at the same accumulated size.
242                if let Some(&bridge_target) = gap_bridges.get(&new_end) {
243                    reachable.entry(bridge_target).or_insert(new_size);
244                }
245            }
246        }
247
248        // The block is fully covered if we can reach the end of the last range.
249        reachable.get(&xorb_ranges.last().unwrap().end).copied()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use xet_client::cas_types::ChunkRange;
256
257    use super::*;
258
259    fn build_refs(pairs: &[(ChunkRange, usize)]) -> Vec<XorbReference> {
260        pairs
261            .iter()
262            .map(|(range, size)| XorbReference {
263                term_chunks: *range,
264                uncompressed_size: *size,
265            })
266            .collect()
267    }
268
269    #[test]
270    fn test_single_term_exact_match() {
271        let ranges = &[ChunkRange::new(0, 5)];
272        let terms = build_refs(&[(ChunkRange::new(0, 5), 1000)]);
273        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
274    }
275
276    #[test]
277    fn test_two_terms_chained() {
278        let ranges = &[ChunkRange::new(0, 5)];
279        let terms = build_refs(&[(ChunkRange::new(0, 3), 600), (ChunkRange::new(3, 5), 400)]);
280        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
281    }
282
283    #[test]
284    fn test_three_terms_chained() {
285        let ranges = &[ChunkRange::new(0, 6)];
286        let terms = build_refs(&[
287            (ChunkRange::new(0, 2), 200),
288            (ChunkRange::new(2, 4), 300),
289            (ChunkRange::new(4, 6), 500),
290        ]);
291        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
292    }
293
294    #[test]
295    fn test_gap_in_chain() {
296        let ranges = &[ChunkRange::new(0, 6)];
297        let terms = build_refs(&[(ChunkRange::new(0, 2), 200), (ChunkRange::new(4, 6), 500)]);
298        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
299    }
300
301    #[test]
302    fn test_does_not_start_at_xorb_start() {
303        let ranges = &[ChunkRange::new(0, 5)];
304        let terms = build_refs(&[(ChunkRange::new(1, 5), 800)]);
305        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
306    }
307
308    #[test]
309    fn test_does_not_end_at_xorb_end() {
310        let ranges = &[ChunkRange::new(0, 5)];
311        let terms = build_refs(&[(ChunkRange::new(0, 3), 600)]);
312        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
313    }
314
315    #[test]
316    fn test_empty_terms() {
317        let ranges = &[ChunkRange::new(0, 5)];
318        let terms: Vec<XorbReference> = vec![];
319        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
320    }
321
322    #[test]
323    fn test_overlapping_terms_with_exact_cover() {
324        // Terms [0..3, 1..4, 3..5] - the chain 0..3, 3..5 covers 0..5.
325        // The overlapping term 1..4 should be skipped.
326        let ranges = &[ChunkRange::new(0, 5)];
327        let terms = build_refs(&[
328            (ChunkRange::new(0, 3), 600),
329            (ChunkRange::new(1, 4), 700),
330            (ChunkRange::new(3, 5), 400),
331        ]);
332        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
333    }
334
335    #[test]
336    fn test_duplicate_terms_first_covers() {
337        // Two identical terms covering the full range.
338        let ranges = &[ChunkRange::new(0, 5)];
339        let terms = build_refs(&[(ChunkRange::new(0, 5), 1000), (ChunkRange::new(0, 5), 1000)]);
340        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
341    }
342
343    #[test]
344    fn test_nonzero_xorb_start() {
345        let ranges = &[ChunkRange::new(3, 8)];
346        let terms = build_refs(&[(ChunkRange::new(3, 5), 400), (ChunkRange::new(5, 8), 600)]);
347        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(1000));
348    }
349
350    #[test]
351    fn test_nonzero_xorb_start_no_match() {
352        let ranges = &[ChunkRange::new(3, 8)];
353        let terms = build_refs(&[(ChunkRange::new(3, 5), 400)]);
354        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
355    }
356
357    #[test]
358    fn test_single_chunk_range() {
359        let ranges = &[ChunkRange::new(0, 1)];
360        let terms = build_refs(&[(ChunkRange::new(0, 1), 42)]);
361        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(42));
362    }
363
364    #[test]
365    fn test_chain_with_overlapping_inner_terms() {
366        let ranges = &[ChunkRange::new(2, 8)];
367        // The overlapping term [3,6) is within the range but doesn't form
368        // a better chain than [2,5) + [5,8), so it's harmlessly ignored.
369        let terms = build_refs(&[
370            (ChunkRange::new(2, 5), 500),
371            (ChunkRange::new(3, 6), 999),
372            (ChunkRange::new(5, 8), 300),
373        ]);
374        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(800));
375    }
376
377    #[test]
378    fn test_partial_overlap_no_cover() {
379        // Terms partially overlap but don't form a contiguous chain covering the full range.
380        let ranges = &[ChunkRange::new(0, 10)];
381        let terms = build_refs(&[
382            (ChunkRange::new(0, 4), 400),
383            (ChunkRange::new(3, 7), 400),
384            (ChunkRange::new(6, 10), 400),
385        ]);
386        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
387    }
388
389    #[test]
390    fn test_same_start_short_then_long_covering_full() {
391        // Short range first, then a long range that covers the full xorb.
392        let ranges = &[ChunkRange::new(0, 5)];
393        let terms = build_refs(&[(ChunkRange::new(0, 3), 300), (ChunkRange::new(0, 5), 500)]);
394        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(500));
395    }
396
397    #[test]
398    fn test_same_start_short_then_long_with_chain() {
399        // Short range first, then a longer range, where the short range can also chain.
400        // Chain via 0..3 + 3..6 = 600
401        let ranges = &[ChunkRange::new(0, 6)];
402        let terms = build_refs(&[
403            (ChunkRange::new(0, 2), 200),
404            (ChunkRange::new(0, 3), 300),
405            (ChunkRange::new(3, 6), 300),
406        ]);
407        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(600));
408    }
409
410    #[test]
411    fn test_same_start_multiple_duplicates_chain_through_second() {
412        // Multiple terms at start 0 with different lengths; only the middle one chains.
413        // Chain via 0..4 + 4..6 = 600
414        let ranges = &[ChunkRange::new(0, 6)];
415        let terms = build_refs(&[
416            (ChunkRange::new(0, 2), 200),
417            (ChunkRange::new(0, 4), 400),
418            (ChunkRange::new(0, 5), 500),
419            (ChunkRange::new(4, 6), 200),
420        ]);
421        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(600));
422    }
423
424    #[test]
425    fn test_same_start_at_midpoint() {
426        // Duplicate starts at a midpoint in the chain, not just at the beginning.
427        // Chain via 0..3 + 3..6 + 6..8 = 800
428        let ranges = &[ChunkRange::new(0, 8)];
429        let terms = build_refs(&[
430            (ChunkRange::new(0, 3), 300),
431            (ChunkRange::new(3, 5), 200),
432            (ChunkRange::new(3, 6), 300),
433            (ChunkRange::new(6, 8), 200),
434        ]);
435        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(800));
436    }
437
438    #[test]
439    fn test_same_start_none_covers() {
440        // Multiple terms at start 0, but none chain to cover the full range.
441        let ranges = &[ChunkRange::new(0, 10)];
442        let terms = build_refs(&[
443            (ChunkRange::new(0, 2), 200),
444            (ChunkRange::new(0, 4), 400),
445            (ChunkRange::new(0, 6), 600),
446        ]);
447        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
448    }
449
450    #[test]
451    fn test_same_start_two_groups_chained() {
452        // Two groups of duplicate-start terms that chain together.
453        // Chain via 0..3 + 3..6 = 600
454        let ranges = &[ChunkRange::new(0, 6)];
455        let terms = build_refs(&[
456            (ChunkRange::new(0, 2), 200),
457            (ChunkRange::new(0, 3), 300),
458            (ChunkRange::new(3, 5), 200),
459            (ChunkRange::new(3, 6), 300),
460        ]);
461        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(600));
462    }
463
464    #[test]
465    fn test_multiple_disjoint_ranges_both_covered() {
466        let ranges = &[ChunkRange::new(0, 3), ChunkRange::new(5, 8)];
467        let terms = build_refs(&[(ChunkRange::new(0, 3), 300), (ChunkRange::new(5, 8), 400)]);
468        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), Some(700));
469    }
470
471    #[test]
472    fn test_multiple_disjoint_ranges_one_uncovered() {
473        let ranges = &[ChunkRange::new(0, 3), ChunkRange::new(5, 8)];
474        let terms = build_refs(&[(ChunkRange::new(0, 3), 300)]);
475        assert_eq!(XorbBlock::determine_size_if_possible(ranges, &terms), None);
476    }
477}