1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use tracing::instrument;
4
5use crate::progress;
6
7pub type Error = crate::error::OperationError<Summary>;
10
11#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
12pub struct Summary {
13 pub files_created: usize,
14 pub directories_created: usize,
15 pub bytes_written: u64,
16}
17
18impl std::ops::Add for Summary {
19 type Output = Self;
20 fn add(self, other: Self) -> Self {
21 Self {
22 files_created: self.files_created + other.files_created,
23 directories_created: self.directories_created + other.directories_created,
24 bytes_written: self.bytes_written + other.bytes_written,
25 }
26 }
27}
28
29impl std::fmt::Display for Summary {
30 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
31 write!(
32 f,
33 "files created: {}\n\
34 directories created: {}\n\
35 bytes written: {}",
36 self.files_created,
37 self.directories_created,
38 bytesize::ByteSize(self.bytes_written)
39 )
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct FileGenConfig {
46 pub root: std::path::PathBuf,
48 pub dirwidth: Vec<usize>,
50 pub numfiles: usize,
52 pub filesize: usize,
54 pub writebuf: usize,
56 pub chunk_size: u64,
58 pub leaf_files: bool,
60}
61
62impl FileGenConfig {
63 pub fn new(
65 root: impl Into<std::path::PathBuf>,
66 dirwidth: Vec<usize>,
67 numfiles: usize,
68 filesize: usize,
69 ) -> Self {
70 Self {
71 root: root.into(),
72 dirwidth,
73 numfiles,
74 filesize,
75 writebuf: 1024 * 1024, chunk_size: 0,
77 leaf_files: false,
78 }
79 }
80}
81
82fn allocate_write_buffer(path: &std::path::Path, bufsize: usize) -> Vec<u8> {
83 #[cfg(test)]
84 crate::testutils::record_blocking_path_allocation(path);
85 #[cfg(not(test))]
86 let _ = path;
87 vec![0u8; bufsize]
88}
89
90#[instrument(skip(prog_track))]
91pub async fn write_file(
92 prog_track: &'static progress::Progress,
93 path: std::path::PathBuf,
94 mut filesize: usize,
95 bufsize: usize,
96 chunk_size: u64,
97) -> Result<Summary, Error> {
98 let open_file_guard = throttle::open_file_permit().await;
99 let admission = open_file_guard.admission();
100 crate::safedir::with_fd_admission(admission, async move {
101 let _open_file_guard = open_file_guard;
102 throttle::get_file_iops_tokens(chunk_size, filesize as u64).await;
103 let _ops_guard = prog_track.ops.guard();
104 let original_filesize = filesize;
105 let open_path = path.clone();
112 let file = crate::safedir::run_metadata_probed_blocking_no_rate(
113 congestion::Side::Destination,
114 congestion::MetadataOp::OpenCreate,
115 move || {
116 std::fs::OpenOptions::new()
117 .write(true)
118 .create(true)
119 .truncate(false)
120 .open(open_path)
121 },
122 )
123 .await
124 .with_context(|| format!("Error opening {:?}", &path))
125 .map_err(|err| Error::new(err, Default::default()))?;
126 let mut file = file;
127 let mut bytes = None;
128 while filesize > 0 {
129 let writesize = std::cmp::min(filesize, bufsize);
130 let write_path = path.clone();
131 let chunk_output = crate::safedir::run_fd_admitted_blocking(move || {
132 use std::io::Write as _;
133
134 #[cfg(test)]
135 let gate_visit = {
136 use std::os::fd::AsRawFd as _;
137 crate::testutils::wait_on_blocking_path_gate(&write_path, file.as_raw_fd())
138 };
139 let mut file = file;
143 let mut bytes =
144 bytes.unwrap_or_else(|| allocate_write_buffer(&write_path, bufsize));
145 rand::fill(&mut bytes[..]);
147 let result = file
148 .write_all(&bytes[..writesize])
149 .with_context(|| format!("Error writing to {:?}", &write_path));
150 #[cfg(test)]
151 {
152 Ok((result, file, gate_visit, bytes))
153 }
154 #[cfg(not(test))]
155 {
156 Ok((result, file, bytes))
157 }
158 })
159 .await
160 .with_context(|| format!("Error running file writer for {:?}", &path))
161 .map_err(|err| Error::new(err, Default::default()))?;
162 #[cfg(test)]
163 let (write_result, returned_file, _gate_visit, returned_bytes) = chunk_output;
164 #[cfg(not(test))]
165 let (write_result, returned_file, returned_bytes) = chunk_output;
166 file = returned_file;
167 bytes = Some(returned_bytes);
168 write_result.map_err(|err| Error::new(err, Default::default()))?;
169 filesize -= writesize;
170 prog_track.bytes_copied.add(writesize as u64);
171 }
172 let flush_path = path.clone();
173 let (flush_result, file) = crate::safedir::run_fd_admitted_blocking(move || {
174 use std::io::Write as _;
175
176 let mut file = file;
177 let result = file
178 .flush()
179 .with_context(|| format!("Error flushing {:?}", &flush_path));
180 Ok((result, file))
181 })
182 .await
183 .with_context(|| format!("Error running file writer for {:?}", &path))
184 .map_err(|err| Error::new(err, Default::default()))?;
185 flush_result.map_err(|err| Error::new(err, Default::default()))?;
186 drop(file);
187 prog_track.files_copied.inc();
188 Ok(Summary {
189 files_created: 1,
190 bytes_written: original_filesize as u64,
191 ..Default::default()
192 })
193 })
194 .await
195}
196
197#[async_recursion]
198#[instrument(skip(prog_track))]
199pub async fn filegen(
200 prog_track: &'static progress::Progress,
201 config: &FileGenConfig,
202) -> Result<Summary, Error> {
203 let FileGenConfig {
204 root,
205 dirwidth,
206 numfiles,
207 filesize,
208 writebuf,
209 chunk_size,
210 leaf_files,
211 } = config;
212 let numdirs = *dirwidth.first().unwrap_or(&0);
213 let mut join_set = tokio::task::JoinSet::new();
214 for i in 0..numdirs {
216 let path = root.join(format!("dir{i}"));
217 let next_dirwidth = dirwidth[1..].to_vec();
218 let recurse_config = FileGenConfig {
219 root: path.clone(),
220 dirwidth: next_dirwidth,
221 numfiles: *numfiles,
222 filesize: *filesize,
223 writebuf: *writebuf,
224 chunk_size: *chunk_size,
225 leaf_files: *leaf_files,
226 };
227 let recurse = || async move {
228 crate::walk::run_metadata_probed(
232 congestion::Side::Destination,
233 congestion::MetadataOp::MkDir,
234 tokio::fs::create_dir(&path),
235 )
236 .await
237 .with_context(|| format!("Error creating directory {:?}", &path))
238 .map_err(|err| Error::new(err, Default::default()))?;
239 prog_track.directories_created.inc();
240 let dir_summary = Summary {
241 directories_created: 1,
242 ..Default::default()
243 };
244 let recurse_summary = filegen(prog_track, &recurse_config).await?;
245 Ok(dir_summary + recurse_summary)
246 };
247 join_set.spawn(recurse());
248 }
249 let is_leaf = dirwidth.is_empty();
252 let should_generate_files = !leaf_files || is_leaf;
253 if should_generate_files {
254 for i in 0..*numfiles {
255 throttle::get_ops_token().await;
258 let path = root.join(format!("file{i}"));
259 join_set.spawn(write_file(
260 prog_track,
261 path,
262 *filesize,
263 *writebuf,
264 *chunk_size,
265 ));
266 }
267 }
268 let mut success = true;
269 let mut last_error: Option<anyhow::Error> = None;
270 let mut filegen_summary = Summary::default();
271 while let Some(res) = join_set.join_next().await {
272 match res.map_err(|err| Error::new(err.into(), Default::default()))? {
273 Ok(summary) => filegen_summary = filegen_summary + summary,
274 Err(error) => {
275 tracing::error!("filegen: {:?} failed with: {:#}", root, &error);
276 filegen_summary = filegen_summary + error.summary;
277 if last_error.is_none() {
278 last_error = Some(error.source);
279 }
280 success = false;
281 }
282 }
283 }
284 if !success {
285 let error = if let Some(error) = last_error {
286 error.context(format!("filegen: {:?} failed!", &root))
287 } else {
288 anyhow!("filegen: {:?} failed!", &root)
289 };
290 return Err(Error::new(error, filegen_summary));
291 }
292 Ok(filegen_summary)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::testutils;
299 use std::os::unix::fs::PermissionsExt;
300 use tracing_test::traced_test;
301
302 static PROGRESS: std::sync::LazyLock<progress::Progress> =
303 std::sync::LazyLock::new(progress::Progress::new);
304
305 #[tokio::test]
306 #[traced_test]
307 async fn test_basic_filegen() -> Result<(), anyhow::Error> {
308 let tmp_dir = testutils::create_temp_dir().await?;
309 let test_path = tmp_dir.as_path();
310 let config = FileGenConfig {
312 root: test_path.to_path_buf(),
313 dirwidth: vec![2],
314 numfiles: 3,
315 filesize: 100,
316 writebuf: 50,
317 chunk_size: 0,
318 leaf_files: false,
319 };
320 let summary = filegen(&PROGRESS, &config).await?;
321 assert_eq!(summary.files_created, 9);
326 assert_eq!(summary.directories_created, 2);
327 assert_eq!(summary.bytes_written, 900);
328 assert!(test_path.join("file0").exists()); assert!(test_path.join("dir0").join("file0").exists());
331 assert!(test_path.join("dir0").join("file1").exists());
332 assert!(test_path.join("dir0").join("file2").exists());
333 assert!(test_path.join("dir1").join("file0").exists());
334 assert!(test_path.join("dir1").join("file1").exists());
335 assert!(test_path.join("dir1").join("file2").exists());
336 let metadata = tokio::fs::metadata(test_path.join("dir0").join("file0")).await?;
338 assert_eq!(metadata.len(), 100);
339 tokio::fs::remove_dir_all(test_path).await?;
341 Ok(())
342 }
343
344 #[tokio::test]
345 #[traced_test]
346 async fn test_nested_filegen() -> Result<(), anyhow::Error> {
347 let tmp_dir = testutils::create_temp_dir().await?;
348 let test_path = tmp_dir.as_path();
349 let config = FileGenConfig {
351 root: test_path.to_path_buf(),
352 dirwidth: vec![2, 3],
353 numfiles: 4,
354 filesize: 50,
355 writebuf: 25,
356 chunk_size: 0,
357 leaf_files: false,
358 };
359 let summary = filegen(&PROGRESS, &config).await?;
360 assert_eq!(summary.files_created, 36);
365 assert_eq!(summary.directories_created, 8);
366 assert_eq!(summary.bytes_written, 1800);
367 assert!(test_path.join("file0").exists()); assert!(test_path.join("dir0").join("file0").exists()); assert!(test_path.join("dir0").join("dir0").join("file0").exists());
371 assert!(test_path.join("dir0").join("dir2").join("file3").exists());
372 assert!(test_path.join("dir1").join("dir1").join("file2").exists());
373 tokio::fs::remove_dir_all(test_path).await?;
375 Ok(())
376 }
377
378 #[tokio::test]
379 #[traced_test]
380 async fn test_deeply_nested_filegen() -> Result<(), anyhow::Error> {
381 let tmp_dir = testutils::create_temp_dir().await?;
382 let test_path = tmp_dir.as_path();
383 let config = FileGenConfig {
385 root: test_path.to_path_buf(),
386 dirwidth: vec![2, 2, 2],
387 numfiles: 2,
388 filesize: 10,
389 writebuf: 10,
390 chunk_size: 0,
391 leaf_files: false,
392 };
393 let summary = filegen(&PROGRESS, &config).await?;
394 assert_eq!(summary.files_created, 30);
398 assert_eq!(summary.directories_created, 14);
399 assert_eq!(summary.bytes_written, 300);
400 assert!(test_path.join("file0").exists()); assert!(
403 test_path
404 .join("dir0")
405 .join("dir0")
406 .join("dir0")
407 .join("file0")
408 .exists()
409 );
410 assert!(
411 test_path
412 .join("dir1")
413 .join("dir1")
414 .join("dir1")
415 .join("file1")
416 .exists()
417 );
418 tokio::fs::remove_dir_all(test_path).await?;
420 Ok(())
421 }
422
423 #[tokio::test]
424 #[traced_test]
425 async fn test_single_file() -> Result<(), anyhow::Error> {
426 let tmp_dir = testutils::create_temp_dir().await?;
427 let test_path = tmp_dir.as_path();
428 let config = FileGenConfig {
430 root: test_path.to_path_buf(),
431 dirwidth: vec![],
432 numfiles: 5,
433 filesize: 200,
434 writebuf: 100,
435 chunk_size: 0,
436 leaf_files: false,
437 };
438 let summary = filegen(&PROGRESS, &config).await?;
439 assert_eq!(summary.files_created, 5);
440 assert_eq!(summary.directories_created, 0);
441 assert_eq!(summary.bytes_written, 1000); for i in 0..5 {
443 let file_path = test_path.join(format!("file{i}"));
445 assert!(file_path.exists());
446 let metadata = tokio::fs::metadata(&file_path).await?;
447 assert_eq!(metadata.len(), 200);
448 }
449 tokio::fs::remove_dir_all(test_path).await?;
451 Ok(())
452 }
453
454 #[tokio::test]
455 #[traced_test]
456 async fn test_zero_files() -> Result<(), anyhow::Error> {
457 let tmp_dir = testutils::create_temp_dir().await?;
458 let test_path = tmp_dir.as_path();
459 let config = FileGenConfig {
461 root: test_path.to_path_buf(),
462 dirwidth: vec![3, 2],
463 numfiles: 0,
464 filesize: 100,
465 writebuf: 50,
466 chunk_size: 0,
467 leaf_files: false,
468 };
469 let summary = filegen(&PROGRESS, &config).await?;
470 assert_eq!(summary.files_created, 0);
472 assert_eq!(summary.directories_created, 9);
473 assert_eq!(summary.bytes_written, 0);
474 assert!(test_path.join("dir0").join("dir0").exists());
476 assert!(test_path.join("dir2").join("dir1").exists());
477 assert!(!test_path.join("dir0").join("file0").exists());
478 tokio::fs::remove_dir_all(test_path).await?;
480 Ok(())
481 }
482
483 #[tokio::test]
484 #[traced_test]
485 async fn test_leaf_files_only() -> Result<(), anyhow::Error> {
486 let tmp_dir = testutils::create_temp_dir().await?;
487 let test_path = tmp_dir.as_path();
488 let config = FileGenConfig {
490 root: test_path.to_path_buf(),
491 dirwidth: vec![2, 3],
492 numfiles: 4,
493 filesize: 50,
494 writebuf: 25,
495 chunk_size: 0,
496 leaf_files: true,
497 };
498 let summary = filegen(&PROGRESS, &config).await?;
499 assert_eq!(summary.files_created, 24);
503 assert_eq!(summary.directories_created, 8);
504 assert_eq!(summary.bytes_written, 1200);
505 assert!(!test_path.join("file0").exists()); assert!(!test_path.join("dir0").join("file0").exists()); assert!(!test_path.join("dir1").join("file0").exists());
509 assert!(test_path.join("dir0").join("dir0").join("file0").exists());
511 assert!(test_path.join("dir0").join("dir0").join("file3").exists());
512 assert!(test_path.join("dir0").join("dir2").join("file0").exists());
513 assert!(test_path.join("dir1").join("dir1").join("file0").exists());
514 tokio::fs::remove_dir_all(test_path).await?;
516 Ok(())
517 }
518
519 #[tokio::test]
520 #[traced_test]
521 async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
522 let tmp_dir = testutils::create_temp_dir().await?;
523 let root = tmp_dir.join("readonly");
524 tokio::fs::create_dir(&root).await?;
525 tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).await?;
526
527 let config = FileGenConfig {
528 root: root.clone(),
529 dirwidth: Vec::new(),
530 numfiles: 1,
531 filesize: 10,
532 writebuf: 10,
533 chunk_size: 0,
534 leaf_files: false,
535 };
536 let result = filegen(&PROGRESS, &config).await;
537
538 tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).await?;
540
541 assert!(
542 result.is_err(),
543 "filegen inside read-only directory should fail"
544 );
545 let err = result.unwrap_err();
546 let err_msg = format!("{:#}", err.source);
547 assert!(
548 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
549 "Error message must include permission denied text. Got: {}",
550 err_msg
551 );
552 Ok(())
553 }
554
555 mod max_files_in_flight_tests {
556 use super::*;
557
558 static CANCELLATION_PROGRESS: std::sync::LazyLock<progress::Progress> =
559 std::sync::LazyLock::new(progress::Progress::new);
560 static REUSE_PROGRESS: std::sync::LazyLock<progress::Progress> =
561 std::sync::LazyLock::new(progress::Progress::new);
562
563 #[tokio::test(flavor = "current_thread")]
564 async fn write_file_reuses_one_blocking_buffer_across_chunks() -> anyhow::Result<()> {
565 let root = testutils::create_temp_dir().await?;
566 let path = root.join("reused-buffer");
567 let bufsize = 4096;
568 let filesize = bufsize * 3;
569 let admission = testutils::AdmissionLimit::new().await;
570 admission.set_files_in_flight(1);
571 let gate = testutils::BlockingPathGate::install(path.clone());
572 gate.release_all();
573 let timeout = std::time::Duration::from_secs(20);
574 let runtime_thread = std::thread::current().id();
575
576 let write_result = match admission
577 .run_with_timeout(
578 timeout,
579 write_file(&REUSE_PROGRESS, path.clone(), filesize, bufsize, 0),
580 )
581 .await
582 {
583 Ok(Ok(summary)) => Ok(summary),
584 Ok(Err(error)) => Err(error.source),
585 Err(error) => Err(anyhow::Error::new(error)
586 .context("multi-chunk write_file did not finish in time")),
587 };
588 let hit_count = gate.hit_count();
589 let allocation_count = gate.allocation_count();
590 let allocation_thread = gate.allocation_thread();
591 let final_len = tokio::fs::metadata(&path).await.map(|meta| meta.len());
592 drop(gate);
593 drop(admission);
594 let cleanup_result = tokio::fs::remove_dir_all(root).await;
595
596 let summary = write_result?;
597 let final_len = final_len?;
598 cleanup_result?;
599 assert_eq!(hit_count, 3, "write_file did not submit one job per chunk");
600 assert_eq!(
601 allocation_count, 1,
602 "write_file allocated a new buffer instead of reusing the returned one"
603 );
604 assert_ne!(
605 allocation_thread,
606 Some(runtime_thread),
607 "filegen allocated its user-sized buffer on the async runtime thread"
608 );
609 assert_eq!(summary.files_created, 1);
610 assert_eq!(summary.bytes_written, filesize as u64);
611 assert_eq!(final_len, filesize as u64);
612 Ok(())
613 }
614
615 #[tokio::test(flavor = "current_thread")]
616 async fn cancelled_write_file_stops_after_one_buffer_and_retains_capacity()
617 -> anyhow::Result<()> {
618 let root = testutils::create_temp_dir().await?;
619 let path = root.join("cancelled-file");
620 let bufsize = 4096;
621 let filesize = bufsize * 3;
622 let gate = testutils::BlockingPathGate::install(path.clone());
623 let admission = testutils::AdmissionLimit::new().await;
624 admission.set_files_in_flight(1);
625 let timeout = std::time::Duration::from_secs(20);
626 let runtime_thread = std::thread::current().id();
627 let bytes_before = CANCELLATION_PROGRESS.bytes_copied.get();
628 let files_before = CANCELLATION_PROGRESS.files_copied.get();
629 let task = tokio::spawn(write_file(
630 &CANCELLATION_PROGRESS,
631 path.clone(),
632 filesize,
633 bufsize,
634 0,
635 ));
636 let observations =
637 testutils::cancel_at_blocking_path(admission, gate, task, timeout, |_| {
638 (
639 CANCELLATION_PROGRESS.bytes_copied.get(),
640 CANCELLATION_PROGRESS.files_copied.get(),
641 )
642 })
643 .await;
644 let final_len = tokio::fs::metadata(&path).await.map(|meta| meta.len());
645 let final_bytes = CANCELLATION_PROGRESS.bytes_copied.get();
646 let final_files = CANCELLATION_PROGRESS.files_copied.get();
647 let cleanup_result = tokio::fs::remove_dir_all(root).await;
648
649 let (observations, (bytes_at_cancellation, files_at_cancellation)) = observations?;
650 let final_len = final_len?;
651 cleanup_result?;
652
653 assert!(observations.waiter_was_cancelled);
654 assert!(
655 observations.admission_was_retained_while_work_gated,
656 "cancelling write_file released capacity while its fd-owning job was live"
657 );
658 assert!(
659 observations.fd_was_open_while_work_gated,
660 "write_file closed its fd while the job was gated"
661 );
662 assert_eq!(observations.hit_count_before_release, 1);
663 assert_eq!(
664 observations.allocation_count_before_release, 0,
665 "filegen allocated its user-sized buffer before the admitted work gate released"
666 );
667 assert_eq!(bytes_at_cancellation, bytes_before);
668 assert_eq!(files_at_cancellation, files_before);
669 assert!(
670 observations.fd_was_closed_at_output_drop_start,
671 "write_file reached its output-drop boundary before its destination fd closed"
672 );
673 assert!(
674 observations.admission_was_retained_at_output_drop_start,
675 "write_file released capacity before its abandoned output dropped"
676 );
677 assert_eq!(
678 observations.final_hit_count, 1,
679 "cancellation submitted another buffer"
680 );
681 assert_eq!(
682 observations.final_allocation_count, 1,
683 "filegen did not allocate exactly one reusable buffer in admitted blocking work"
684 );
685 assert_ne!(
686 observations.allocation_thread,
687 Some(runtime_thread),
688 "filegen allocated its user-sized buffer on the async runtime thread"
689 );
690 assert_eq!(
691 final_len, bufsize as u64,
692 "cancellation must finish at most the already-submitted buffer"
693 );
694 assert_eq!(
695 final_bytes, bytes_before,
696 "an abandoned buffer must not advance byte progress"
697 );
698 assert_eq!(
699 final_files, files_before,
700 "a cancelled file must not be counted complete"
701 );
702 Ok(())
703 }
704 }
705}