Skip to main content

xet_data/processing/
file_upload_session.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3#[cfg(not(target_family = "wasm"))]
4use std::fs::File;
5#[cfg(not(target_family = "wasm"))]
6use std::io::Read;
7use std::mem::{swap, take};
8#[cfg(not(target_family = "wasm"))]
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13#[cfg(not(target_family = "wasm"))]
14use bytes::Bytes;
15use more_asserts::*;
16use tokio::sync::Mutex;
17use tokio::task::{JoinHandle, JoinSet};
18#[cfg(target_family = "wasm")]
19use tokio_with_wasm::alias as tokio;
20#[cfg(not(target_family = "wasm"))]
21use tracing::Span;
22use tracing::{Instrument, info_span, instrument};
23use xet_client::cas_client::{Client, ProgressCallback};
24use xet_core_structures::metadata_shard::file_structs::MDBFileInfo;
25use xet_core_structures::xorb_object::SerializedXorbObject;
26use xet_runtime::core::XetContext;
27use xet_runtime::utils::UniqueId;
28
29use super::XetFileInfo;
30use super::configurations::TranslatorConfig;
31use super::file_cleaner::{Sha256Policy, SingleFileCleaner};
32use super::remote_client_interface::create_remote_client;
33use super::shard_interface::SessionShardInterface;
34use crate::deduplication::constants::{MAX_XORB_BYTES, MAX_XORB_CHUNKS};
35use crate::deduplication::{DataAggregator, DeduplicationMetrics, RawXorbData};
36use crate::error::{DataError, Result};
37use crate::progress_tracking::upload_tracking::{CompletionTracker, FileXorbDependency};
38use crate::progress_tracking::{GroupProgressReport, ItemProgressReport, UploadGroupProgress};
39
40/// Manages the translation of files between the
41/// MerkleDB / pointer file format and the materialized version.
42///
43/// This class handles the clean operations.  It's meant to be a single atomic session
44/// that succeeds or fails as a unit;  i.e. all files get uploaded on finalization, and all shards
45/// and xorbs needed to reconstruct those files are properly uploaded and registered.
46pub struct FileUploadSession {
47    pub(crate) ctx: XetContext,
48    pub(crate) client: Arc<dyn Client + Send + Sync>,
49    pub(crate) shard_interface: SessionShardInterface,
50
51    /// Tracking upload completion between xorbs and files.
52    pub(crate) completion_tracker: Arc<CompletionTracker>,
53
54    /// Deduplicated data shared across files.
55    current_session_data: Mutex<DataAggregator>,
56
57    /// Metrics for deduplication
58    deduplication_metrics: Mutex<DeduplicationMetrics>,
59
60    /// Internal worker
61    xorb_upload_tasks: Mutex<JoinSet<Result<()>>>,
62
63    /// Set to true after finalize() has been called.
64    finalized: AtomicBool,
65}
66
67// Constructors
68impl FileUploadSession {
69    pub async fn new(config: Arc<TranslatorConfig>) -> Result<Arc<FileUploadSession>> {
70        FileUploadSession::new_impl(config, false).await
71    }
72
73    pub async fn dry_run(config: Arc<TranslatorConfig>) -> Result<Arc<FileUploadSession>> {
74        FileUploadSession::new_impl(config, true).await
75    }
76
77    async fn new_impl(config: Arc<TranslatorConfig>, dry_run: bool) -> Result<Arc<FileUploadSession>> {
78        let ctx = config.ctx.clone();
79        let session_id = config
80            .session
81            .session_id
82            .as_ref()
83            .map(Cow::Borrowed)
84            .unwrap_or_else(|| Cow::Owned(UniqueId::new().to_string()));
85
86        let progress = UploadGroupProgress::with_speed_config(
87            ctx.config.data.progress_update_speed_sampling_window,
88            ctx.config.data.progress_update_speed_min_observations,
89        );
90        let completion_tracker = Arc::new(CompletionTracker::new(progress));
91
92        let client = create_remote_client(&config, &session_id, dry_run).await?;
93
94        #[cfg(target_family = "wasm")]
95        let shard_interface = SessionShardInterface::new(&ctx, config.clone(), client.clone(), dry_run).await?;
96
97        #[cfg(not(target_family = "wasm"))]
98        let shard_interface =
99            SessionShardInterface::new(&ctx, config.clone(), client.clone(), completion_tracker.clone(), dry_run)
100                .await?;
101
102        Ok(Arc::new(Self {
103            ctx,
104            shard_interface,
105            client,
106            completion_tracker,
107            current_session_data: Mutex::new(DataAggregator::default()),
108            deduplication_metrics: Mutex::new(DeduplicationMetrics::default()),
109            xorb_upload_tasks: Mutex::new(JoinSet::new()),
110            finalized: AtomicBool::new(false),
111        }))
112    }
113
114    #[cfg(not(target_family = "wasm"))]
115    pub async fn upload_files(
116        self: &Arc<Self>,
117        files_and_sha256: impl IntoIterator<Item = (impl AsRef<Path>, Sha256Policy)> + Send,
118    ) -> Result<Vec<XetFileInfo>> {
119        self.check_not_finalized()?;
120        let mut cleaning_tasks: Vec<JoinHandle<_>> = vec![];
121
122        for (f, sha256) in files_and_sha256.into_iter() {
123            let file_path = f.as_ref().to_owned();
124            let file_name: Arc<str> = Arc::from(file_path.to_string_lossy());
125
126            let file_size = std::fs::metadata(&file_path)?.len();
127
128            let file_id =
129                self.completion_tracker
130                    .register_new_file(UniqueId::new(), file_name.clone(), Some(file_size));
131
132            let ingestion_concurrency_limiter = self.ctx.common.file_ingestion_semaphore.clone();
133            let ingestion_block_size = *self.ctx.config.data.ingestion_block_size;
134            let session = self.clone();
135
136            cleaning_tasks.push(tokio::spawn(async move {
137                // Enable tracing to record this file's ingestion speed.
138                let span = info_span!(
139                    "clean_file_task",
140                    "file.name" = file_name.to_string(),
141                    "file.len" = file_size,
142                    "file.new_bytes" = tracing::field::Empty,
143                    "file.deduped_bytes" = tracing::field::Empty,
144                    "file.defrag_prevented_dedup_bytes" = tracing::field::Empty,
145                    "file.new_chunks" = tracing::field::Empty,
146                    "file.deduped_chunks" = tracing::field::Empty,
147                    "file.defrag_prevented_dedup_chunks" = tracing::field::Empty,
148                );
149                // First, get a permit to process this file.
150                let _processing_permit = ingestion_concurrency_limiter.acquire().await?;
151
152                async move {
153                    let mut reader = File::open(&file_path)?;
154
155                    // Start the clean process for each file.
156                    let mut cleaner = SingleFileCleaner::new(Some(file_name), file_id, sha256, session);
157                    let mut bytes_read = 0;
158
159                    while bytes_read < file_size {
160                        // Allocate a block of bytes, read into it.
161                        let bytes_left = file_size - bytes_read;
162                        let n_bytes_read = ingestion_block_size.min(bytes_left) as usize;
163
164                        // Read in the data here; we are assuming the file doesn't change size
165                        // on the disk while we are reading it.
166
167                        // We allocate the buffer anew on each loop as it's converted without copying
168                        // to a Bytes object, and thus we avoid further copies downstream.  We also
169                        // guarantee that the buffer is filled completely with the read_exact. Therefore,
170                        // we can use an unsafe trick here to allocate the vector without initializing it
171                        // to a specific value and avoid that clearing.
172                        let mut buffer = Vec::with_capacity(n_bytes_read);
173                        #[allow(clippy::uninit_vec)]
174                        unsafe {
175                            buffer.set_len(n_bytes_read);
176                        }
177
178                        // Read it in.
179                        reader.read_exact(&mut buffer)?;
180
181                        bytes_read += buffer.len() as u64;
182
183                        cleaner.add_data_from_bytes(Bytes::from(buffer)).await?;
184                    }
185
186                    // Finish and return the result.
187                    let (xfi, metrics) = cleaner.finish().await?;
188
189                    // Record dedup information.
190                    let span = Span::current();
191                    span.record("file.new_bytes", metrics.new_bytes);
192                    span.record("file.deduped_bytes", metrics.deduped_bytes);
193                    span.record("file.defrag_prevented_dedup_bytes", metrics.defrag_prevented_dedup_bytes);
194                    span.record("file.new_chunks", metrics.new_chunks);
195                    span.record("file.deduped_chunks", metrics.deduped_chunks);
196                    span.record("file.defrag_prevented_dedup_chunks", metrics.defrag_prevented_dedup_chunks);
197
198                    Result::Ok(xfi)
199                }
200                .instrument(span)
201                .await
202            }));
203        }
204
205        // Join all the cleaning tasks.
206        let mut ret = Vec::with_capacity(cleaning_tasks.len());
207
208        for task in cleaning_tasks {
209            ret.push(task.await??);
210        }
211
212        Ok(ret)
213    }
214
215    /// Start to clean one file. When cleaning multiple files, each file should
216    /// be associated with one Cleaner. This allows to launch multiple clean task
217    /// simultaneously.
218    ///
219    /// The caller is responsible for memory usage management, the parameter "buffer_size"
220    /// indicates the maximum number of Vec<u8> in the internal buffer.
221    ///
222    /// If a sha256 is provided via [`Sha256Policy::Provided`], the value will be directly
223    /// used in shard upload to avoid redundant computation. [`Sha256Policy::Skip`] skips
224    /// SHA-256 computation entirely and no metadata_ext is included in the shard.
225    pub fn start_clean(
226        self: &Arc<Self>,
227        tracking_name: Option<Arc<str>>,
228        size: Option<u64>,
229        sha256: Sha256Policy,
230    ) -> Result<(UniqueId, SingleFileCleaner)> {
231        self.check_not_finalized()?;
232        let id = UniqueId::new();
233        let cleaner = self.start_clean_with_id(id, tracking_name, size, sha256);
234        Ok((id, cleaner))
235    }
236
237    fn start_clean_with_id(
238        self: &Arc<Self>,
239        id: UniqueId,
240        tracking_name: Option<Arc<str>>,
241        size: Option<u64>,
242        sha256: Sha256Policy,
243    ) -> SingleFileCleaner {
244        let file_id = self
245            .completion_tracker
246            .register_new_file(id, tracking_name.clone().unwrap_or_default(), size);
247        SingleFileCleaner::new(tracking_name, file_id, sha256, self.clone())
248    }
249
250    /// Spawns a task that reads `file_path` and uploads it.
251    ///
252    /// Returns the tracking ID and a join handle for the spawned task.
253    #[cfg(not(target_family = "wasm"))]
254    pub async fn spawn_upload_from_path(
255        self: &Arc<Self>,
256        file_path: PathBuf,
257        sha256: Sha256Policy,
258    ) -> Result<(UniqueId, JoinHandle<Result<(XetFileInfo, DeduplicationMetrics)>>)> {
259        self.check_not_finalized()?;
260        let file_size = std::fs::metadata(&file_path)?.len();
261        let tracking_name: Arc<str> = Arc::from(file_path.to_string_lossy().as_ref());
262        let (id, cleaner) = self.start_clean(Some(tracking_name), Some(file_size), sha256)?;
263
264        let session = self.clone();
265        let runtime = self.ctx.runtime.clone();
266        let semaphore = self.ctx.common.file_ingestion_semaphore.clone();
267        let handle = runtime.spawn(async move {
268            let _permit = semaphore.acquire().await?;
269            Self::feed_file_to_cleaner(&session, cleaner, &file_path).await
270        });
271
272        Ok((id, handle))
273    }
274
275    /// Spawns a task that uploads `bytes` as a single file.
276    ///
277    /// Returns the tracking ID and a join handle for the spawned task.
278    pub async fn spawn_upload_bytes(
279        self: &Arc<Self>,
280        bytes: Vec<u8>,
281        sha256: Sha256Policy,
282        tracking_name: Option<Arc<str>>,
283    ) -> Result<(UniqueId, JoinHandle<Result<(XetFileInfo, DeduplicationMetrics)>>)> {
284        self.check_not_finalized()?;
285        let (id, mut cleaner) = self.start_clean(tracking_name, Some(bytes.len() as u64), sha256)?;
286
287        let semaphore = self.ctx.common.file_ingestion_semaphore.clone();
288        // Route through XetRuntime on native so the task participates in SIGINT
289        // shutdown and FD accounting. On wasm, XetRuntime has no handle (the
290        // wasm `XetRuntime::new` stub leaves `handle_ref` empty), so spawn via
291        // the `tokio_with_wasm::alias as tokio` shim instead.
292        #[cfg(not(target_family = "wasm"))]
293        let handle = self.ctx.runtime.spawn(async move {
294            let _permit = semaphore.acquire().await?;
295            cleaner.add_data(&bytes).await?;
296            cleaner.finish().await
297        });
298        #[cfg(target_family = "wasm")]
299        let handle = tokio::task::spawn(async move {
300            let _permit = semaphore.acquire().await?;
301            cleaner.add_data(&bytes).await?;
302            cleaner.finish().await
303        });
304
305        Ok((id, handle))
306    }
307
308    #[cfg(not(target_family = "wasm"))]
309    async fn feed_file_to_cleaner(
310        _session: &Arc<Self>,
311        mut cleaner: SingleFileCleaner,
312        file_path: &Path,
313    ) -> Result<(XetFileInfo, DeduplicationMetrics)> {
314        let mut reader = File::open(file_path)?;
315        let filesize = reader.metadata()?.len();
316        let mut buffer = vec![0u8; u64::min(filesize, *_session.ctx.config.data.ingestion_block_size) as usize];
317
318        loop {
319            let n = reader.read(&mut buffer)?;
320            if n == 0 {
321                break;
322            }
323            cleaner.add_data(&buffer[..n]).await?;
324        }
325        cleaner.finish().await
326    }
327
328    /// Registers a new xorb for upload, returning true if the xorb was added to the upload queue and false
329    /// if it was already in the queue and didn't need to be uploaded again.
330    #[instrument(skip_all, name="FileUploadSession::register_new_xorb_for_upload", fields(xorb_len = xorb.num_bytes()))]
331    pub(crate) async fn register_new_xorb(
332        self: &Arc<Self>,
333        xorb: RawXorbData,
334        file_dependencies: &[FileXorbDependency],
335    ) -> Result<bool> {
336        // First check the current xorb upload tasks to see if any can be cleaned up.
337        {
338            let mut upload_tasks = self.xorb_upload_tasks.lock().await;
339            while let Some(result) = upload_tasks.try_join_next() {
340                result??;
341            }
342        }
343
344        let xorb_hash = xorb.hash();
345
346        // Register that this xorb is part of this session and set up completion tracking.
347        //
348        // In some circumstances, we can cut to instances of the same xorb, namely when there are two files
349        // with the same starting data that get processed simultaneously.  When this happens, we only upload
350        // the first one, returning early here.
351        let xorb_is_new = self.completion_tracker.register_new_xorb(xorb_hash, xorb.num_bytes() as u64);
352
353        // Make sure we add in all the dependencies.  This should happen after the xorb is registered but before
354        // we start the upload.
355        self.completion_tracker.register_dependencies(file_dependencies);
356
357        if !xorb_is_new {
358            return Ok(false);
359        }
360
361        // No need to process an empty xorb.  But check this after the session_xorbs tracker
362        // to make sure the reporting is correct.
363        if xorb.num_bytes() == 0 {
364            self.completion_tracker.register_xorb_upload_completion(xorb_hash);
365            return Ok(true);
366        }
367
368        // This xorb is in the session upload queue, so other threads can go ahead and dedup against it.
369        // No session shard data gets uploaded until all the xorbs have been successfully uploaded, so
370        // this is safe.
371        let xorb_info = Arc::new(xorb.xorb_info.clone());
372        self.shard_interface.add_xorb_block(xorb_info.clone()).await?;
373
374        // Serialize the object; this can be relatively expensive, so run it on a compute thread.
375        // XORBs are sent without footer - the server/client reconstructs it from chunk data.
376        let runtime = self.ctx.runtime.clone();
377        let compression_policy = self.ctx.config.xorb.compression_policy.clone();
378        let compression_scheme_retest_interval = self.ctx.config.xorb.compression_scheme_retest_interval;
379        let xorb_obj = runtime
380            .spawn_blocking(move || {
381                SerializedXorbObject::from_xorb(
382                    xorb,
383                    false,
384                    compression_policy.as_str(),
385                    compression_scheme_retest_interval,
386                )
387            })
388            .await??;
389
390        let session = self.clone();
391        let upload_permit = self.client.acquire_upload_permit().await?;
392        let cas_prefix = self.ctx.config.data.default_prefix.clone();
393        let completion_tracker = self.completion_tracker.clone();
394        let xorb_hash = xorb_obj.hash;
395        let raw_num_bytes = xorb_obj.raw_num_bytes;
396        let progress_callback: ProgressCallback = Arc::new(move |delta, _completed, total| {
397            let raw_delta = (delta * raw_num_bytes).checked_div(total).unwrap_or(0);
398            if raw_delta > 0 {
399                completion_tracker
400                    .clone()
401                    .register_xorb_upload_progress_background(xorb_hash, raw_delta);
402            }
403        });
404
405        self.xorb_upload_tasks.lock().await.spawn(
406            async move {
407                let n_bytes_transmitted = session
408                    .client
409                    .upload_xorb(&cas_prefix, xorb_obj, Some(progress_callback), upload_permit)
410                    .await?;
411
412                // Register that the xorb has been uploaded.
413                session.completion_tracker.register_xorb_upload_completion(xorb_hash);
414
415                // Record the number of bytes uploaded.
416                session.deduplication_metrics.lock().await.xorb_bytes_uploaded += n_bytes_transmitted;
417
418                // Add this as a completed cas block so that future sessions can resume quickly.
419                session.shard_interface.add_uploaded_xorb_block(xorb_info).await?;
420
421                Ok(())
422            }
423            .instrument(info_span!("FileUploadSession::upload_xorb_task", xorb.hash = xorb_hash.hex())),
424        );
425
426        Ok(true)
427    }
428
429    /// Meant to be called by the finalize() method of the SingleFileCleaner
430    #[instrument(skip_all, name="FileUploadSession::register_single_file_clean_completion", fields(num_bytes = file_data.num_bytes(), num_chunks = file_data.num_chunks()))]
431    pub(crate) async fn register_single_file_clean_completion(
432        self: &Arc<Self>,
433        mut file_data: DataAggregator,
434        dedup_metrics: &DeduplicationMetrics,
435    ) -> Result<()> {
436        // Merge in the remaining file data; uploading a new xorb if need be.
437        {
438            let mut current_session_data = self.current_session_data.lock().await;
439
440            #[cfg(feature = "simulation")]
441            let xorb_cut_bytes = self
442                .ctx
443                .config
444                .xorb
445                .simulation_max_bytes
446                .map(|bs| bs.as_u64().min(*MAX_XORB_BYTES as u64) as usize)
447                .unwrap_or(*MAX_XORB_BYTES);
448            #[cfg(not(feature = "simulation"))]
449            let xorb_cut_bytes = *MAX_XORB_BYTES;
450            #[cfg(feature = "simulation")]
451            let xorb_cut_chunks = self
452                .ctx
453                .config
454                .xorb
455                .simulation_max_chunks
456                .unwrap_or(*MAX_XORB_CHUNKS)
457                .min(*MAX_XORB_CHUNKS);
458            #[cfg(not(feature = "simulation"))]
459            let xorb_cut_chunks = *MAX_XORB_CHUNKS;
460
461            // Do we need to cut one of these to a xorb?
462            if current_session_data.num_bytes() + file_data.num_bytes() > xorb_cut_bytes
463                || current_session_data.num_chunks() + file_data.num_chunks() > xorb_cut_chunks
464            {
465                // Cut the larger one as a xorb, uploading it and registering the files.
466                if current_session_data.num_bytes() > file_data.num_bytes() {
467                    swap(&mut *current_session_data, &mut file_data);
468                }
469
470                // Now file data is larger
471                debug_assert_le!(current_session_data.num_bytes(), file_data.num_bytes());
472
473                // Actually upload this outside the lock
474                drop(current_session_data);
475
476                self.process_aggregated_data_as_xorb(file_data).await?;
477            } else {
478                current_session_data.merge_in(file_data);
479            }
480        }
481
482        #[cfg(debug_assertions)]
483        {
484            let current_session_data = self.current_session_data.lock().await;
485            debug_assert_le!(current_session_data.num_bytes(), *MAX_XORB_BYTES);
486            debug_assert_le!(current_session_data.num_chunks(), *MAX_XORB_CHUNKS);
487        }
488
489        // Now, aggregate the new dedup metrics.
490        self.deduplication_metrics.lock().await.merge_in(dedup_metrics);
491
492        Ok(())
493    }
494
495    /// Like `register_single_file_clean_completion`, but does NOT register the MDBFileInfo
496    /// in the session shard. Returns the finalized MDBFileInfo instead.
497    /// Used by composition flows where only the final composed file should appear in the shard.
498    pub(crate) async fn register_single_file_clean_completion_detached(
499        self: &Arc<Self>,
500        file_data: DataAggregator,
501        dedup_metrics: &DeduplicationMetrics,
502    ) -> Result<MDBFileInfo> {
503        // Always cut a dedicated xorb for detached files. This avoids mixing with other
504        // files in current_session_data whose MDBFileInfo must still be registered.
505        let file_infos = self.process_aggregated_data_as_xorb_detached(file_data).await?;
506
507        self.deduplication_metrics.lock().await.merge_in(dedup_metrics);
508
509        debug_assert_eq!(file_infos.len(), 1);
510        file_infos
511            .into_iter()
512            .next()
513            .ok_or_else(|| DataError::InternalError("detached completion produced no file info".into()))
514    }
515
516    /// Process the aggregated data, uploading the data as a xorb and registering the files
517    async fn process_aggregated_data_as_xorb(self: &Arc<Self>, data_agg: DataAggregator) -> Result<()> {
518        self.process_aggregated_data_as_xorb_impl(data_agg, true).await.map(|_| ())
519    }
520
521    /// Upload the xorb data but do NOT register file reconstruction info in the shard.
522    /// Returns the finalized MDBFileInfo for each file in the aggregator.
523    async fn process_aggregated_data_as_xorb_detached(
524        self: &Arc<Self>,
525        data_agg: DataAggregator,
526    ) -> Result<Vec<MDBFileInfo>> {
527        self.process_aggregated_data_as_xorb_impl(data_agg, false).await
528    }
529
530    async fn process_aggregated_data_as_xorb_impl(
531        self: &Arc<Self>,
532        data_agg: DataAggregator,
533        register_files: bool,
534    ) -> Result<Vec<MDBFileInfo>> {
535        let (xorb, new_files) = data_agg.finalize();
536        let xorb_hash = xorb.hash();
537
538        debug_assert_le!(xorb.num_bytes(), *MAX_XORB_BYTES);
539        debug_assert_le!(xorb.data.len(), *MAX_XORB_CHUNKS);
540
541        let mut new_dependencies = Vec::with_capacity(new_files.len());
542        let mut file_infos = Vec::with_capacity(new_files.len());
543
544        for (file_id, fi, bytes_in_xorb) in new_files {
545            new_dependencies.push(FileXorbDependency {
546                file_id,
547                xorb_hash,
548                n_bytes: bytes_in_xorb,
549                is_external: false,
550            });
551
552            if register_files {
553                self.shard_interface.add_file_reconstruction_info(fi).await?;
554            } else {
555                file_infos.push(fi);
556            }
557        }
558
559        // Register the xorb and start the upload process.
560        self.register_new_xorb(xorb, &new_dependencies).await?;
561
562        Ok(file_infos)
563    }
564
565    /// Register a xorb dependencies that is given as part of the dedup process.
566    pub(crate) fn register_xorb_dependencies(self: &Arc<Self>, xorb_dependencies: &[FileXorbDependency]) {
567        self.completion_tracker.register_dependencies(xorb_dependencies);
568    }
569
570    /// Finalize everything.
571    #[instrument(skip_all, name="FileUploadSession::finalize", fields(session.id))]
572    async fn finalize_impl(
573        self: Arc<Self>,
574        return_files: bool,
575    ) -> Result<(DeduplicationMetrics, Vec<MDBFileInfo>, GroupProgressReport)> {
576        if self.finalized.swap(true, Ordering::AcqRel) {
577            return Err(DataError::InvalidOperation("FileUploadSession already finalized".to_string()));
578        }
579
580        // Register the remaining xorbs for upload.
581        let data_agg = take(&mut *self.current_session_data.lock().await);
582        self.process_aggregated_data_as_xorb(data_agg).await?;
583
584        // Finalize the xorb uploads. Must join before snapshotting
585        // `deduplication_metrics`: each xorb-upload task records its
586        // transmitted-byte count via `self.deduplication_metrics.lock()` only
587        // *after* its CAS request resolves. Taking the metric before joining
588        // leaves the session with an empty `DeduplicationMetrics` that
589        // absorbs (and silently drops) those late writes — manifests on wasm
590        // (single-threaded; tasks rarely complete before the `await` above
591        // returns) as `xorb_bytes_uploaded == 0`.
592        let mut upload_tasks = take(&mut *self.xorb_upload_tasks.lock().await);
593
594        while let Some(result) = upload_tasks.join_next().await {
595            result??;
596        }
597
598        let mut metrics = take(&mut *self.deduplication_metrics.lock().await);
599
600        let all_file_info = if return_files {
601            self.shard_interface.session_file_info_list().await?
602        } else {
603            Vec::new()
604        };
605
606        // Upload and register the current shards in the session, moving them
607        // to the cache.
608        metrics.shard_bytes_uploaded = self.shard_interface.upload_and_register_session_shards().await?;
609        metrics.total_bytes_uploaded = metrics.shard_bytes_uploaded + metrics.xorb_bytes_uploaded;
610
611        #[cfg(debug_assertions)]
612        {
613            self.completion_tracker.assert_complete();
614        }
615
616        let report = self.report();
617        Ok((metrics, all_file_info, report))
618    }
619
620    // Wait until everything currently in process is completed and uploaded, cutting a xorb for the remaining bit.
621    // However, does not clean up the session so add_data can be called again.  Finalize must be called later.
622    //
623    // Used for testing.  Should be called only after all add_data calls have completed.
624    pub async fn checkpoint(self: &Arc<Self>) -> Result<()> {
625        // Cut the current data present as a xorb, upload it.
626        let data_agg = take(&mut *self.current_session_data.lock().await);
627        self.process_aggregated_data_as_xorb(data_agg).await?;
628
629        // Wait for all inflight xorb uploads to complete.
630        {
631            let mut upload_tasks = self.xorb_upload_tasks.lock().await;
632
633            while let Some(result) = upload_tasks.join_next().await {
634                result??;
635            }
636        }
637
638        Ok(())
639    }
640
641    /// Register a pre-composed file reconstruction plan (MDBFileInfo) with this session.
642    /// Used for append-aware writes where the caller builds the reconstruction plan
643    /// from existing segments + newly uploaded segments.
644    pub async fn register_composed_file(self: &Arc<Self>, file_info: MDBFileInfo) -> Result<()> {
645        self.check_not_finalized()?;
646        self.shard_interface.add_file_reconstruction_info(file_info).await
647    }
648
649    fn check_not_finalized(&self) -> Result<()> {
650        if self.finalized.load(Ordering::Acquire) {
651            return Err(DataError::InvalidOperation("FileUploadSession already finalized".to_string()));
652        }
653        Ok(())
654    }
655
656    pub fn client(&self) -> Arc<dyn Client + Send + Sync> {
657        Arc::clone(&self.client)
658    }
659
660    pub fn progress(&self) -> &Arc<UploadGroupProgress> {
661        self.completion_tracker.progress()
662    }
663
664    pub fn report(&self) -> GroupProgressReport {
665        self.completion_tracker.progress().report()
666    }
667
668    pub fn item_report(&self, id: UniqueId) -> Option<ItemProgressReport> {
669        self.completion_tracker.progress().item_report(id)
670    }
671
672    pub fn item_reports(&self) -> HashMap<UniqueId, ItemProgressReport> {
673        self.completion_tracker.progress().item_reports()
674    }
675
676    pub async fn finalize(self: Arc<Self>) -> Result<DeduplicationMetrics> {
677        Ok(self.finalize_impl(false).await?.0)
678    }
679
680    pub async fn finalize_with_report(self: Arc<Self>) -> Result<(DeduplicationMetrics, GroupProgressReport)> {
681        let (metrics, _file_info, report) = self.finalize_impl(false).await?;
682        Ok((metrics, report))
683    }
684
685    pub async fn finalize_with_file_info(self: Arc<Self>) -> Result<(DeduplicationMetrics, Vec<MDBFileInfo>)> {
686        let (metrics, file_info, _report) = self.finalize_impl(true).await?;
687        Ok((metrics, file_info))
688    }
689}
690
691#[cfg(all(test, not(target_family = "wasm")))]
692mod tests {
693    use std::fs::{File, OpenOptions};
694    use std::io::{Read, Write};
695    use std::path::Path;
696
697    use xet_runtime::core::XetContext;
698
699    use crate::processing::{FileDownloadSession, FileUploadSession, XetFileInfo};
700
701    /// Cleans (converts) a regular file into a pointer file.
702    ///
703    /// * `input_path`: path to the original file
704    /// * `output_path`: path to write the pointer file
705    async fn test_clean_file(cas_path: &Path, input_path: &Path, output_path: &Path) {
706        let read_data = read(input_path).unwrap().to_vec();
707
708        let mut pf_out = Box::new(
709            OpenOptions::new()
710                .create(true)
711                .write(true)
712                .truncate(true)
713                .open(output_path)
714                .unwrap(),
715        );
716
717        let ctx = XetContext::default().unwrap();
718        let upload_session = FileUploadSession::new(TranslatorConfig::local_config(&ctx, cas_path).unwrap().into())
719            .await
720            .unwrap();
721
722        let (_id, mut cleaner) = upload_session
723            .start_clean(Some("test".into()), Some(read_data.len() as u64), Sha256Policy::Compute)
724            .unwrap();
725
726        // Read blocks from the source file and hand them to the cleaning handle
727        cleaner.add_data(&read_data[..]).await.unwrap();
728
729        let (xet_file_info, _metrics) = cleaner.finish().await.unwrap();
730        upload_session.finalize().await.unwrap();
731
732        pf_out
733            .write_all(serde_json::to_string(&xet_file_info).unwrap().as_bytes())
734            .unwrap();
735    }
736
737    /// Smudges (hydrates) a pointer file back into the original data.
738    ///
739    /// * `pointer_path`: path to the pointer file
740    /// * `output_path`: path to write the hydrated/original file
741    async fn test_smudge_file(cas_path: &Path, pointer_path: &Path, output_path: &Path) {
742        let mut reader = File::open(pointer_path).unwrap();
743
744        let mut input = String::new();
745        reader.read_to_string(&mut input).unwrap();
746
747        let xet_file = serde_json::from_str::<XetFileInfo>(&input).unwrap();
748
749        let ctx = XetContext::default().unwrap();
750        let config = TranslatorConfig::local_config(&ctx, cas_path).unwrap();
751        let session = FileDownloadSession::new(config.into(), None).await.unwrap();
752
753        let (_id, _n_bytes) = session.download_file(&xet_file, output_path).await.unwrap();
754    }
755
756    use std::fs::{read, write};
757
758    use tempfile::tempdir;
759
760    use super::*;
761
762    #[test]
763    fn test_clean_smudge_round_trip() {
764        let temp = tempdir().unwrap();
765        let original_data = b"Hello, world!";
766
767        let ctx = XetContext::default().unwrap();
768
769        ctx.runtime
770            .bridge_sync(async move {
771                let cas_path = temp.path().join("cas");
772
773                // 1. Write an original file in the temp directory
774                let original_path = temp.path().join("original.txt");
775                write(&original_path, original_data).unwrap();
776
777                // 2. Clean it (convert it to a pointer file)
778                let pointer_path = temp.path().join("pointer.txt");
779                test_clean_file(&cas_path, &original_path, &pointer_path).await;
780
781                // 3. Smudge it (hydrate the pointer file) to a new file
782                let hydrated_path = temp.path().join("hydrated.txt");
783                test_smudge_file(&cas_path, &pointer_path, &hydrated_path).await;
784
785                // 4. Verify that the round-tripped file matches the original
786                let result_data = read(hydrated_path).unwrap();
787                assert_eq!(original_data.to_vec(), result_data);
788            })
789            .unwrap();
790    }
791
792    #[test]
793    fn test_clean_skip_sha256_no_metadata_ext() {
794        let temp = tempdir().unwrap();
795        let data = b"Hello, skip sha256!";
796
797        let ctx = XetContext::default().unwrap();
798
799        ctx.runtime
800            .bridge_sync(async move {
801                let cas_path = temp.path().join("cas");
802
803                let session_ctx = XetContext::default().unwrap();
804                let upload_session =
805                    FileUploadSession::new(TranslatorConfig::local_config(&session_ctx, &cas_path).unwrap().into())
806                        .await
807                        .unwrap();
808
809                let (_id, mut cleaner) = upload_session
810                    .start_clean(Some("test".into()), Some(data.len() as u64), Sha256Policy::Skip)
811                    .unwrap();
812                cleaner.add_data(data).await.unwrap();
813                let _ = cleaner.finish().await.unwrap();
814
815                // Verify that the shard has no metadata_ext (no SHA-256).
816                let (_metrics, file_infos) = upload_session.finalize_with_file_info().await.unwrap();
817                assert_eq!(file_infos.len(), 1);
818                assert!(file_infos[0].metadata_ext.is_none(), "Skip should produce no metadata_ext");
819            })
820            .unwrap();
821    }
822}