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