Skip to main content

xet_data/processing/
test_utils.rs

1use std::fs::{File, create_dir_all, read_dir};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use itertools::multizip;
7use rand::prelude::*;
8use tempfile::TempDir;
9use xet_client::cas_client::{Client, LocalClient};
10#[cfg(feature = "simulation")]
11use xet_client::cas_client::{LocalTestServer, LocalTestServerBuilder};
12use xet_runtime::core::XetContext;
13
14use super::configurations::TranslatorConfig;
15use super::data_client::clean_file;
16use super::file_cleaner::Sha256Policy;
17use super::{FileDownloadSession, FileUploadSession, XetFileInfo};
18
19/// Describes how hydration (download/smudge) should be performed during a test.
20///
21/// Each variant exercises a different reconstruction path:
22/// - `DirectClient`: Uses `LocalClient` directly (no HTTP server).
23/// - `ServerV2`: Uses `LocalTestServer` with default V2 reconstruction.
24/// - `ServerV1Fallback`: Uses `LocalTestServer` with V2 disabled, forcing V1 fallback.
25/// - `ServerMaxRanges2`: Uses `LocalTestServer` with `max_ranges_per_fetch=2`, forcing multi-range fetch splitting in
26///   V2 responses.
27#[derive(Debug, Clone, Copy)]
28pub enum HydrationMode {
29    DirectClient,
30    #[cfg(feature = "simulation")]
31    ServerV2,
32    #[cfg(feature = "simulation")]
33    ServerV1Fallback,
34    #[cfg(feature = "simulation")]
35    ServerMaxRanges2,
36}
37
38impl HydrationMode {
39    pub fn all() -> &'static [HydrationMode] {
40        &[
41            HydrationMode::DirectClient,
42            #[cfg(feature = "simulation")]
43            HydrationMode::ServerV2,
44            #[cfg(feature = "simulation")]
45            HydrationMode::ServerV1Fallback,
46            #[cfg(feature = "simulation")]
47            HydrationMode::ServerMaxRanges2,
48        ]
49    }
50
51    pub fn uses_server(&self) -> bool {
52        match self {
53            HydrationMode::DirectClient => false,
54            #[cfg(feature = "simulation")]
55            _ => true,
56        }
57    }
58}
59
60impl std::fmt::Display for HydrationMode {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            HydrationMode::DirectClient => write!(f, "direct_client"),
64            #[cfg(feature = "simulation")]
65            HydrationMode::ServerV2 => write!(f, "server_v2"),
66            #[cfg(feature = "simulation")]
67            HydrationMode::ServerV1Fallback => write!(f, "server_v1_fallback"),
68            #[cfg(feature = "simulation")]
69            HydrationMode::ServerMaxRanges2 => write!(f, "server_max_ranges_2"),
70        }
71    }
72}
73
74/// Creates or overwrites a single file in `dir` with `size` bytes of random data.
75/// Panics on any I/O error. Returns the total number of bytes written (=`size`).
76pub fn create_random_file(path: impl AsRef<Path>, size: usize, seed: u64) -> usize {
77    let path = path.as_ref();
78
79    let dir = path.parent().unwrap();
80
81    // Make sure the directory exists, or create it.
82    create_dir_all(dir).unwrap();
83
84    let mut rng = StdRng::seed_from_u64(seed);
85
86    // Build the path to the file, create the file, and write random data.
87    let mut file = File::create(path).unwrap();
88
89    let mut buffer = vec![0_u8; size];
90    rng.fill_bytes(&mut buffer);
91
92    file.write_all(&buffer).unwrap();
93
94    size
95}
96
97/// Creates a collection of random files, each with a deterministic seed.
98/// the total number of bytes written for all files combined.
99pub fn create_random_files(dir: impl AsRef<Path>, files: &[(impl AsRef<str>, usize)], seed: u64) -> usize {
100    let dir = dir.as_ref();
101
102    let mut total_bytes = 0;
103    let mut rng = SmallRng::seed_from_u64(seed);
104
105    for (file_name, size) in files {
106        total_bytes += create_random_file(dir.join(file_name.as_ref()), *size, rng.random());
107    }
108    total_bytes
109}
110
111/// Creates or overwrites a single file in `dir` with consecutive segments determined by the list of [(size, seed)].
112/// Panics on any I/O error. Returns the total number of bytes written (=`size`).
113pub fn create_random_multipart_file(path: impl AsRef<Path>, segments: &[(usize, u64)]) -> usize {
114    let path = path.as_ref();
115    let dir = path.parent().unwrap();
116
117    // Make sure the directory exists, or create it.
118    create_dir_all(dir).unwrap();
119
120    // Build the path to the file, create the file, and write random data.
121    let mut file = File::create(path).unwrap();
122
123    let mut total_size = 0;
124    for &(size, seed) in segments {
125        let mut rng = StdRng::seed_from_u64(seed);
126
127        let mut buffer = vec![0_u8; size];
128        rng.fill_bytes(&mut buffer);
129        file.write_all(&buffer).unwrap();
130        total_size += size;
131    }
132    total_size
133}
134
135/// Panics if `dir1` and `dir2` differ in terms of files or file contents.
136/// Uses `unwrap()` everywhere; intended for test-only use.
137pub fn verify_directories_match(dir1: impl AsRef<Path>, dir2: impl AsRef<Path>) {
138    let dir1 = dir1.as_ref();
139    let dir2 = dir2.as_ref();
140
141    let mut files_in_dir1 = Vec::new();
142    for entry in read_dir(dir1).unwrap() {
143        let entry = entry.unwrap();
144        assert!(entry.file_type().unwrap().is_file());
145        files_in_dir1.push(entry.file_name());
146    }
147
148    let mut files_in_dir2 = Vec::new();
149    for entry in read_dir(dir2).unwrap() {
150        let entry = entry.unwrap();
151        assert!(entry.file_type().unwrap().is_file());
152        files_in_dir2.push(entry.file_name());
153    }
154
155    files_in_dir1.sort();
156    files_in_dir2.sort();
157
158    if files_in_dir1 != files_in_dir2 {
159        panic!(
160            "Directories differ: file sets are not the same.\n \
161             dir1: {files_in_dir1:?}\n dir2: {files_in_dir2:?}"
162        );
163    }
164
165    // Compare file contents byte-for-byte
166    for file_name in &files_in_dir1 {
167        let path1 = dir1.join(file_name);
168        let path2 = dir2.join(file_name);
169
170        let mut buf1 = Vec::new();
171        let mut buf2 = Vec::new();
172
173        File::open(&path1).unwrap().read_to_end(&mut buf1).unwrap();
174        File::open(&path2).unwrap().read_to_end(&mut buf2).unwrap();
175
176        if buf1 != buf2 {
177            panic!(
178                "File contents differ for {file_name:?}\n \
179                 dir1 path: {path1:?}\n dir2 path: {path2:?}"
180            );
181        }
182    }
183}
184
185pub struct HydrateDehydrateTest {
186    _temp_dir: TempDir,
187    pub cas_dir: PathBuf,
188    pub src_dir: PathBuf,
189    pub ptr_dir: PathBuf,
190    pub dest_dir: PathBuf,
191    ctx: XetContext,
192    use_test_server: bool,
193    /// Kept alive so the test server stays running for the duration of the test.
194    #[cfg(feature = "simulation")]
195    test_server: Option<LocalTestServer>,
196}
197
198impl Default for HydrateDehydrateTest {
199    fn default() -> Self {
200        Self::new(false)
201    }
202}
203
204impl HydrateDehydrateTest {
205    /// Creates a new test harness with the specified options.
206    ///
207    /// # Arguments
208    /// * `use_test_server` - If true, uses a LocalTestServer (RemoteClient over HTTP); otherwise uses LocalClient
209    ///   directly.
210    pub fn new(use_test_server: bool) -> Self {
211        let _temp_dir = TempDir::new().unwrap();
212        let temp_path = _temp_dir.path();
213
214        let cas_dir = temp_path.join("cas");
215        let src_dir = temp_path.join("src");
216        let ptr_dir = temp_path.join("pointers");
217        let dest_dir = temp_path.join("dest");
218
219        std::fs::create_dir_all(&cas_dir).unwrap();
220        std::fs::create_dir_all(&src_dir).unwrap();
221        std::fs::create_dir_all(&ptr_dir).unwrap();
222        std::fs::create_dir_all(&dest_dir).unwrap();
223
224        Self {
225            cas_dir,
226            src_dir,
227            ptr_dir,
228            dest_dir,
229            ctx: XetContext::default().expect("xet context"),
230            _temp_dir,
231            use_test_server,
232            #[cfg(feature = "simulation")]
233            test_server: None,
234        }
235    }
236
237    /// Creates a new test harness configured for a specific hydration mode.
238    pub fn for_mode(mode: HydrationMode) -> Self {
239        Self::new(mode.uses_server())
240    }
241
242    /// Applies hydration mode configuration to the test server.
243    /// Must be called after `dehydrate()` and before `hydrate()`.
244    pub async fn apply_hydration_mode(&mut self, mode: HydrationMode) {
245        match mode {
246            HydrationMode::DirectClient => {},
247            #[cfg(feature = "simulation")]
248            HydrationMode::ServerV2 => {
249                self.ensure_server_created().await;
250            },
251            #[cfg(feature = "simulation")]
252            HydrationMode::ServerV1Fallback => {
253                self.ensure_server_created().await;
254                self.test_server.as_ref().unwrap().client().disable_v2_endpoints(404);
255            },
256            #[cfg(feature = "simulation")]
257            HydrationMode::ServerMaxRanges2 => {
258                self.ensure_server_created().await;
259                self.test_server.as_ref().unwrap().client().set_max_ranges_per_fetch(2);
260            },
261        }
262    }
263
264    /// Ensures the test server is running, creating it if necessary.
265    /// Call this before configuring the server (e.g., disabling V2 or setting max ranges).
266    #[cfg(feature = "simulation")]
267    pub async fn ensure_server_created(&mut self) {
268        if self.use_test_server && self.test_server.is_none() {
269            let local_client = LocalClient::new(self.ctx.clone(), self.cas_dir.join("xet/xorbs"))
270                .await
271                .unwrap();
272            self.test_server = Some(LocalTestServerBuilder::new().with_client(local_client).start().await);
273        }
274    }
275
276    /// Returns a reference to the test server, if one has been created.
277    #[cfg(feature = "simulation")]
278    pub fn test_server(&self) -> Option<&LocalTestServer> {
279        self.test_server.as_ref()
280    }
281
282    /// Lazily initializes the test server (if needed) and returns a CAS client.
283    async fn get_or_create_client(&mut self) -> Arc<dyn Client> {
284        if self.use_test_server {
285            #[cfg(feature = "simulation")]
286            {
287                if self.test_server.is_none() {
288                    let local_client = LocalClient::new(self.ctx.clone(), self.cas_dir.join("xet/xorbs"))
289                        .await
290                        .unwrap();
291                    self.test_server = Some(LocalTestServerBuilder::new().with_client(local_client).start().await);
292                }
293                self.test_server.as_ref().unwrap().remote_client().clone() as Arc<dyn Client>
294            }
295            #[cfg(not(feature = "simulation"))]
296            {
297                panic!("test server requires the 'simulation' feature");
298            }
299        } else {
300            LocalClient::new(self.ctx.clone(), self.cas_dir.join("xet/xorbs"))
301                .await
302                .unwrap() as Arc<dyn Client>
303        }
304    }
305
306    pub async fn new_upload_session(&self) -> Arc<FileUploadSession> {
307        let config = Arc::new(TranslatorConfig::local_config(&self.ctx, &self.cas_dir).unwrap());
308        FileUploadSession::new(config.clone()).await.unwrap()
309    }
310
311    pub async fn clean_all_files(&self, upload_session: &Arc<FileUploadSession>, sequential: bool) {
312        create_dir_all(&self.ptr_dir).unwrap();
313
314        if sequential {
315            for entry in read_dir(&self.src_dir).unwrap() {
316                let entry = entry.unwrap();
317                let out_file = self.ptr_dir.join(entry.file_name());
318                let upload_session = upload_session.clone();
319
320                if sequential {
321                    let (pf, metrics) = clean_file(upload_session.clone(), entry.path(), Sha256Policy::Compute)
322                        .await
323                        .unwrap();
324                    assert_eq!({ metrics.total_bytes }, entry.metadata().unwrap().len());
325                    std::fs::write(out_file, pf.as_pointer_file().unwrap().as_bytes()).unwrap();
326
327                    // Force a checkpoint after every file.
328                    upload_session.checkpoint().await.unwrap();
329                }
330            }
331        } else {
332            let files: Vec<PathBuf> = read_dir(&self.src_dir)
333                .unwrap()
334                .map(|entry| self.src_dir.join(entry.unwrap().file_name()))
335                .collect();
336
337            let files_and_sha256 = multizip((files.iter(), std::iter::repeat_with(|| Sha256Policy::Compute)));
338
339            let clean_results = upload_session.upload_files(files_and_sha256).await.unwrap();
340
341            for (i, xf) in clean_results.into_iter().enumerate() {
342                std::fs::write(self.ptr_dir.join(files[i].file_name().unwrap()), serde_json::to_string(&xf).unwrap())
343                    .unwrap();
344            }
345        }
346    }
347
348    pub async fn dehydrate(&mut self, sequential: bool) {
349        let upload_session = self.new_upload_session().await;
350        self.clean_all_files(&upload_session, sequential).await;
351
352        upload_session.finalize().await.unwrap();
353    }
354
355    pub async fn hydrate(&mut self) {
356        let client = self.get_or_create_client().await;
357        let session = FileDownloadSession::from_client(&self.ctx, client, None);
358
359        for entry in read_dir(&self.ptr_dir).unwrap() {
360            let entry = entry.unwrap();
361            let out_filename = self.dest_dir.join(entry.file_name());
362
363            let xf: XetFileInfo = serde_json::from_reader(File::open(entry.path()).unwrap()).unwrap();
364            let (_id, _) = session.download_file(&xf, &out_filename).await.unwrap();
365        }
366    }
367
368    pub async fn hydrate_partitioned_writers(&mut self, partitions: usize) {
369        let client = self.get_or_create_client().await;
370        let session = FileDownloadSession::from_client(&self.ctx, client, None);
371
372        for entry in read_dir(&self.ptr_dir).unwrap() {
373            let entry = entry.unwrap();
374            let out_filename = self.dest_dir.join(entry.file_name());
375            let xf: XetFileInfo = serde_json::from_reader(File::open(entry.path()).unwrap()).unwrap();
376            let file_size = xf.file_size().expect("file size required for partitioned hydration");
377
378            let out_file = File::create(&out_filename).unwrap();
379            out_file.set_len(file_size).unwrap();
380
381            if file_size == 0 {
382                continue;
383            }
384
385            let partition_count = partitions.max(1) as u64;
386            let mut tasks = Vec::new();
387
388            for idx in 0..partition_count {
389                let start = (idx * file_size) / partition_count;
390                let end = ((idx + 1) * file_size) / partition_count;
391
392                if start == end {
393                    continue;
394                }
395
396                let session = session.clone();
397                let xf = xf.clone();
398                let out_filename = out_filename.clone();
399                tasks.push(tokio::spawn(async move {
400                    let mut writer = std::fs::OpenOptions::new().write(true).open(out_filename).unwrap();
401                    writer.seek(SeekFrom::Start(start)).unwrap();
402                    session.download_to_writer(&xf, start..end, writer).await
403                }));
404            }
405
406            for task in tasks {
407                task.await.unwrap().unwrap();
408            }
409        }
410    }
411
412    pub async fn hydrate_stream(&mut self) {
413        let client = self.get_or_create_client().await;
414        let session = FileDownloadSession::from_client(&self.ctx, client, None);
415
416        for entry in read_dir(&self.ptr_dir).unwrap() {
417            let entry = entry.unwrap();
418            let out_filename = self.dest_dir.join(entry.file_name());
419
420            let xf: XetFileInfo = serde_json::from_reader(File::open(entry.path()).unwrap()).unwrap();
421            let (_id, mut stream) = session.download_stream(&xf, None).await.unwrap();
422
423            let mut file = File::create(&out_filename).unwrap();
424            while let Some(chunk) = stream.next().await.unwrap() {
425                file.write_all(&chunk).unwrap();
426            }
427        }
428    }
429
430    pub fn verify_src_dest_match(&self) {
431        verify_directories_match(&self.src_dir, &self.dest_dir);
432    }
433}
434
435/// Provides a test environment with a config suitable for `FileUploadSession` / `FileDownloadSession`.
436///
437/// When the `simulation` feature is enabled the environment spins up a `LocalTestServer` and
438/// returns a server-backed config; otherwise it falls back to `LocalClient` via `local_config`.
439pub struct TestEnvironment {
440    _temp_dir: TempDir,
441    pub base_dir: PathBuf,
442    pub ctx: XetContext,
443    pub config: Arc<super::configurations::TranslatorConfig>,
444    #[cfg(feature = "simulation")]
445    _server: Option<LocalTestServer>,
446}
447
448impl TestEnvironment {
449    pub async fn new() -> Self {
450        let temp_dir = TempDir::new().unwrap();
451        let base_dir = temp_dir.path().to_path_buf();
452        let ctx = XetContext::default().unwrap();
453
454        #[cfg(feature = "simulation")]
455        let (config, server) = {
456            let server = LocalTestServerBuilder::new().start().await;
457            let config = Arc::new(
458                super::configurations::TranslatorConfig::test_server_config(&ctx, server.http_endpoint(), &base_dir)
459                    .unwrap(),
460            );
461            (config, Some(server))
462        };
463
464        #[cfg(not(feature = "simulation"))]
465        let config = Arc::new(super::configurations::TranslatorConfig::local_config(&ctx, &base_dir).unwrap());
466
467        Self {
468            _temp_dir: temp_dir,
469            base_dir,
470            ctx,
471            config,
472            #[cfg(feature = "simulation")]
473            _server: server,
474        }
475    }
476}