Skip to main content

xet_data/file_reconstruction/reconstruction_terms/
manager.rs

1use std::collections::VecDeque;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4#[cfg(not(target_family = "wasm"))]
5use std::time::Instant;
6
7use more_asserts::*;
8use tokio::task::JoinHandle;
9#[cfg(target_family = "wasm")]
10use tokio_with_wasm::alias as tokio;
11use tracing::{debug, info};
12#[cfg(target_family = "wasm")]
13use web_time::Instant;
14use xet_client::cas_client::Client;
15use xet_client::cas_types::FileRange;
16use xet_core_structures::ExpWeightedMovingAvg;
17use xet_core_structures::merklehash::MerkleHash;
18use xet_runtime::config::ReconstructionConfig;
19use xet_runtime::core::XetContext;
20
21use super::super::FileReconstructionError;
22use super::super::error::Result;
23use super::file_term::{FileTerm, retrieve_file_term_block};
24use crate::progress_tracking::ItemProgressUpdater;
25
26type RawFetchedFileTerms = Result<Option<(Vec<FileTerm>, u64, u64)>>;
27
28/// Manages the iteration over file terms during reconstruction, with adaptive prefetching.
29/// Prefetches reconstruction blocks ahead of consumption based on observed completion rates
30/// to minimize download latency while controlling memory usage.
31pub struct ReconstructionTermManager {
32    ctx: XetContext,
33    config: Arc<ReconstructionConfig>,
34    client: Arc<dyn Client>,
35    file_hash: MerkleHash,
36    requested_byte_range: FileRange,
37    last_block_info: Option<(Instant, FileRange)>,
38    known_final_byte_position: Arc<AtomicU64>,
39    prefetched_byte_position: u64,
40    current_active_byte_position: u64,
41    prefetch_queue: VecDeque<JoinHandle<RawFetchedFileTerms>>,
42    completion_rate_estimator: ExpWeightedMovingAvg,
43    progress_updater: Option<Arc<ItemProgressUpdater>>,
44    total_bytes_reported: u64,
45    total_transfer_bytes_reported: u64,
46}
47
48impl ReconstructionTermManager {
49    pub async fn new(
50        ctx: XetContext,
51        config: Arc<ReconstructionConfig>,
52        client: Arc<dyn Client>,
53        file_hash: MerkleHash,
54        file_byte_range: FileRange,
55        progress_updater: Option<Arc<ItemProgressUpdater>>,
56    ) -> Result<Self> {
57        let completion_rate_estimator =
58            ExpWeightedMovingAvg::new_count_decay(config.completion_rate_estimator_half_life);
59
60        let requested_byte_range = file_byte_range;
61
62        let mut s = Self {
63            ctx,
64            config,
65            client,
66            file_hash,
67            requested_byte_range,
68            last_block_info: None,
69            prefetched_byte_position: requested_byte_range.start,
70            current_active_byte_position: requested_byte_range.start,
71            prefetch_queue: VecDeque::new(),
72            known_final_byte_position: Arc::new(AtomicU64::new(requested_byte_range.end)),
73            completion_rate_estimator,
74            progress_updater,
75            total_bytes_reported: 0,
76            total_transfer_bytes_reported: 0,
77        };
78
79        // Start things by prefetching two smaller blocks to get things started.  This way,
80        // once the first block is finished, we have a second block to start processing -- and
81        // an estimate of the completion time based on the first one.  This helps us to get
82        // a better estimate of the completion time.
83        let initial_fetch_size = s.config.min_reconstruction_fetch_size.as_u64();
84        s.prefetch_block(initial_fetch_size).await?;
85        s.prefetch_block(2 * initial_fetch_size).await?;
86
87        debug!(
88            %file_hash,
89            prefetch_queue_size = s.prefetch_queue.len(),
90            "Initial prefetch blocks queued"
91        );
92
93        Ok(s)
94    }
95
96    /// Returns the next block of file terms, or None if reconstruction is complete.
97    /// Updates the completion rate estimator based on the time since the last call.
98    pub async fn next_file_terms(&mut self) -> Result<Option<Vec<FileTerm>>> {
99        // Update completion rate estimator if we have timing info from a previous block.
100        if let Some((block_start_time, block_range)) = self.last_block_info.take() {
101            let completion_time = Instant::now().duration_since(block_start_time).as_secs_f64();
102            let block_size = block_range.end - block_range.start;
103
104            if block_size != 0 {
105                self.completion_rate_estimator
106                    .update((block_size as f64) / completion_time.max(1e-6));
107            }
108
109            info!(
110                file_hash = %self.file_hash,
111                block_start = block_range.start,
112                block_end = block_range.end,
113                block_size = block_size,
114                completion_time = completion_time,
115                "Updated completion rate estimate based on previous block completion time (seconds)."
116            );
117        }
118
119        // Check the prefetch buffer to possibly prefetch the next block.
120        self.check_prefetch_buffer().await?;
121
122        let Some(next_block_jh) = self.prefetch_queue.pop_front() else {
123            // If there are no more prefetched terms then we're done.
124            // Note: we check against known_final_byte_position since requested_byte_range.end
125            // may be u64::MAX if the full file was requested.
126            debug_assert_ge!(self.prefetched_byte_position, self.known_final_byte_position.load(Ordering::Relaxed));
127            return Ok(None);
128        };
129
130        let maybe_next_block = next_block_jh
131            .await
132            .map_err(|e| FileReconstructionError::InternalError(format!("Join error: {e}")))??;
133
134        if let Some((file_terms, new_bytes, new_transfer_bytes)) = maybe_next_block {
135            // Extract the download domain from the first file term's URL.
136            let domain = file_terms
137                .first()
138                .and_then(|t| t.url_info.xorb_block_retrieval_urls.try_read().ok())
139                .and_then(|urls| {
140                    urls.1
141                        .first()
142                        .and_then(|(url, _)| url::Url::parse(url).ok())
143                        .and_then(|u| u.host_str().map(str::to_owned))
144                });
145
146            // Calculate the byte range of this block from the file terms.
147            let block_start = file_terms.first().map(|t| t.byte_range.start).unwrap_or(0);
148            let block_end = file_terms.last().map(|t| t.byte_range.end).unwrap_or(0);
149
150            // Record timing info for the next call.
151            self.last_block_info = Some((Instant::now(), FileRange::new(block_start, block_end)));
152
153            // Update the current active byte position.
154            self.current_active_byte_position = block_end;
155
156            info!(
157                file_hash = %self.file_hash,
158                domain = domain.as_deref().unwrap_or("unknown"),
159                block_start = block_start,
160                block_end = block_end,
161                block_size = file_terms.len(),
162                "Received block of file terms from prefetch queue"
163            );
164
165            if let Some(progress_updater) = &self.progress_updater {
166                self.total_bytes_reported = self.total_bytes_reported.saturating_add(new_bytes);
167                self.total_transfer_bytes_reported =
168                    self.total_transfer_bytes_reported.saturating_add(new_transfer_bytes);
169                progress_updater.update_item_size(self.total_bytes_reported, false);
170                progress_updater.update_transfer_size(self.total_transfer_bytes_reported);
171            }
172
173            Ok(Some(file_terms))
174        } else {
175            // We've completed the iteration, so record the final byte position.
176            self.known_final_byte_position
177                .store(self.prefetched_byte_position, Ordering::Relaxed);
178
179            if let Some(progress_updater) = &self.progress_updater {
180                progress_updater.update_item_size(self.total_bytes_reported, true);
181            }
182
183            info!(
184                file_hash = %self.file_hash,
185                prefetched_byte_position = self.prefetched_byte_position,
186                "Completed prefetch queue; end of file reached."
187            );
188
189            Ok(None)
190        }
191    }
192
193    fn is_done_fetching(&self) -> bool {
194        self.prefetched_byte_position >= self.known_final_byte_position.load(Ordering::Relaxed)
195    }
196
197    /// Checks the prefetch queue to ensure that we have enough incoming to keep everything happy.
198    async fn check_prefetch_buffer(&mut self) -> Result<()> {
199        // If we're done, then there's nothing more to do.
200        if self.is_done_fetching() {
201            return Ok(());
202        }
203
204        // How long we expect for a reconstruction block to complete.
205        let target_completion_time = self.config.target_block_completion_time.as_secs_f64();
206
207        // We choose a next block size to complete within minutes based on the
208        // current observations of how long it takes.
209        let completion_rate = self.completion_rate_estimator.value();
210
211        // The target prefetch buffer size.  We want to make sure at least
212        // this much has been prefetched.
213        let prefetch_buffer_target_size = target_completion_time * completion_rate;
214
215        // We need to maintain a minimum amount in the prefetch buffer.
216        let min_prefetch_buffer_size = self.config.min_prefetch_buffer.as_u64() as f64;
217        let prefetch_buffer_size = prefetch_buffer_target_size.max(min_prefetch_buffer_size);
218
219        // The current prefetch buffer size; we want to expand this by the target size.
220        let current_prefetch_buffer_size = self.prefetched_byte_position - self.current_active_byte_position;
221
222        // If we're already at or above the target prefetch buffer size, then don't prefetch more
223        // unless the queue is empty.
224        if !self.prefetch_queue.is_empty() && prefetch_buffer_size <= current_prefetch_buffer_size as f64 {
225            return Ok(());
226        }
227
228        // Let's see what we need to prefetch here.
229        let next_prefetch_target_block_size = (prefetch_buffer_size - current_prefetch_buffer_size as f64) as u64;
230
231        let min_fetch_size = self.config.min_reconstruction_fetch_size.as_u64();
232        let max_fetch_size = self.config.max_reconstruction_fetch_size.as_u64().max(min_fetch_size);
233        let next_prefetch_block_size = next_prefetch_target_block_size.clamp(min_fetch_size, max_fetch_size);
234
235        // Okay, now add this to the prefetch queue.
236        self.prefetch_block(next_prefetch_block_size).await
237    }
238
239    async fn prefetch_block(&mut self, block_size: u64) -> Result<()> {
240        let block_size = block_size.clamp(
241            self.config.min_reconstruction_fetch_size.as_u64(),
242            self.config.max_reconstruction_fetch_size.as_u64(),
243        );
244
245        // First, check the block range to see if we're over the requested range.
246        let mut prefetch_block_range =
247            FileRange::new(self.prefetched_byte_position, self.prefetched_byte_position + block_size);
248
249        // Get the end of the known range, if it is known.  If it's unknown, this is u64::MAX.
250        let last_byte_position = self
251            .known_final_byte_position
252            .load(Ordering::Relaxed)
253            .min(self.requested_byte_range.end);
254
255        // Clamp to the requested range.
256        if prefetch_block_range.end > last_byte_position {
257            prefetch_block_range.end = last_byte_position;
258        }
259
260        // Check if we should extend this one to the end.
261        let min_fetch_size = self.config.min_reconstruction_fetch_size.as_u64();
262        if prefetch_block_range.end + min_fetch_size > self.requested_byte_range.end {
263            prefetch_block_range.end = self.requested_byte_range.end;
264        }
265
266        // It's possible that the start is at or past the end of the requested range; in that case, do nothing.
267        // This also handles empty files where start >= end.
268        if prefetch_block_range.start >= prefetch_block_range.end {
269            debug!(
270                file_hash = %self.file_hash,
271                "Prefetch block skipped - already at or past end of requested range"
272            );
273            return Ok(());
274        }
275
276        let actual_block_size = prefetch_block_range.end - prefetch_block_range.start;
277        info!(
278            file_hash = %self.file_hash,
279            prefetch_range = ?(prefetch_block_range.start, prefetch_block_range.end),
280            requested_block_size = block_size,
281            actual_block_size,
282            queue_depth = self.prefetch_queue.len() + 1,
283            "Scheduling prefetch block"
284        );
285
286        // Update the prefetched position now.
287        self.prefetched_byte_position = prefetch_block_range.end;
288
289        // Add the prefetch task to the queue.
290        let known_final_byte_position = self.known_final_byte_position.clone();
291        let client = self.client.clone();
292        let file_hash = self.file_hash;
293        let runtime = self.ctx.clone();
294
295        let jh = tokio::task::spawn(async move {
296            let result = retrieve_file_term_block(&runtime, client, file_hash, prefetch_block_range).await;
297
298            // See if we're done with the file.
299            if let Ok(Some((ref returned_range, transfer_bytes, ref file_terms))) = result {
300                // See if the returned range is less than the requested range; if so, then
301                // we know we've reached the end of the file.
302                debug_assert_eq!(returned_range.start, prefetch_block_range.start);
303
304                if returned_range.end < prefetch_block_range.end {
305                    known_final_byte_position.store(returned_range.end, Ordering::Relaxed);
306                }
307
308                let new_bytes = returned_range.end.saturating_sub(returned_range.start);
309                Ok(Some((file_terms.clone(), new_bytes, transfer_bytes)))
310            } else if let Ok(None) = result {
311                // If the returned block is None, then we're beyond the end of the file; update the known final byte
312                // position to the start of the prefetch block if it hasn't been set yet (which it might
313                // have been in a separate block).
314                known_final_byte_position.fetch_min(prefetch_block_range.start, Ordering::Relaxed);
315                Ok(None)
316            } else {
317                result.map(|r| {
318                    r.map(|(returned_range, transfer_bytes, file_terms)| {
319                        let new_bytes = returned_range.end.saturating_sub(returned_range.start);
320                        (file_terms, new_bytes, transfer_bytes)
321                    })
322                })
323            }
324        });
325
326        self.prefetch_queue.push_back(jh);
327
328        Ok(())
329    }
330}