feat: add --force-copy flag to copy files instead of hard-linking

Introduces the `--force-copy` CLI argument to bypass hard-linking during selection. When enabled, unchanged kmer-identity files are copied instead of hard-linked to ensure output independence from the source filesystem. The default behavior remains unchanged, continuing to use hard-links with automatic fallback to copying on failure.
This commit is contained in:
Eric Coissac
2026-08-28 23:08:42 +02:00
parent caf59b5658
commit 7363370065
3 changed files with 46 additions and 8 deletions
+12
View File
@@ -70,6 +70,17 @@ pub struct SelectArgs {
/// Overwrite existing output directory
#[arg(short, long)]
pub force: bool,
/// Copy each layer's unchanged kmer-identity files (mphf/unitigs/
/// evidence/fingerprint) instead of hard-linking them. By default
/// `select` hard-links these (never rewritten by a column
/// projection/aggregation, so a hard link is exact and avoids
/// transiently doubling disk usage for a large index), falling back to
/// a real copy only if linking itself fails (e.g. source and output on
/// different filesystems). Set this when the output must survive
/// independently of the source on disk.
#[arg(long)]
pub force_copy: bool,
}
/// Split a repeatable `<name>:<value>` argument. Exits on malformed input.
@@ -128,6 +139,7 @@ pub fn run(args: SelectArgs) {
let mut alg = Select::new(&src, &args.output, &specs, output_presence)
.threshold(args.presence_threshold)
.force(args.force)
.force_copy(args.force_copy)
.sparse(!args.dense)
.on_progress(|_: Progress| pb.inc(1));
+15 -1
View File
@@ -24,6 +24,7 @@ pub struct Select<'a> {
threshold: u32,
output_presence: bool,
force: bool,
force_copy: bool,
sparse: bool,
reporter: Reporter,
on_progress: Option<Box<dyn FnMut(Progress) + Send + 'a>>,
@@ -43,6 +44,7 @@ impl<'a> Select<'a> {
threshold: 0,
output_presence,
force: false,
force_copy: false,
sparse: true,
reporter: Reporter::new(),
on_progress: None,
@@ -63,6 +65,18 @@ impl<'a> Select<'a> {
self
}
/// Always copy each layer's unchanged kmer-identity files
/// (mphf/unitigs/evidence/fingerprint) instead of hard-linking them
/// (default: `false` — hard link, falling back to a copy only if
/// linking itself fails). Set this when `output` must survive
/// independently of `src` on disk — a hard link still shares the same
/// underlying inode, so rewriting one path outside `select` would
/// affect the other.
pub fn force_copy(mut self, v: bool) -> Self {
self.force_copy = v;
self
}
/// Pack the output's presence matrices in the compact sparse format
/// rather than dense (default: `true`).
pub fn sparse(mut self, v: bool) -> Self {
@@ -124,7 +138,7 @@ impl Algorithm for Select<'_> {
runner
.run(
&order,
|i| select_partition(&dst, src, i, self.specs, self.threshold, self.output_presence),
|i| select_partition(&dst, src, i, self.specs, self.threshold, self.output_presence, self.force_copy),
|_, _, _| {
done += 1;
if let Some(cb) = on_progress.as_mut() {
+19 -7
View File
@@ -107,15 +107,26 @@ fn add_result(mb: &mut MatrixBuilder, r: AggResult) -> io::Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Copy all plain files (not subdirectories) from `src_dir` to `dst_dir` —
/// carries a layer's kmer identity (mphf/unitigs/evidence/fingerprint)
/// across unchanged; only the data matrix (a subdirectory) is rebuilt.
fn copy_layer_files(src_dir: &Path, dst_dir: &Path) -> io::Result<()> {
/// Carries a layer's kmer identity (mphf/unitigs/evidence/fingerprint) from
/// `src_dir` to `dst_dir` **unchanged** — these files are never rewritten by
/// `select` (only the data matrix, a subdirectory, is rebuilt), so a hard
/// link is exact and avoids transiently doubling disk usage for the layer's
/// largest files on a big index. Falls back to a real copy per file when
/// linking fails (e.g. `src`/`dst` on different filesystems, `EXDEV`) or
/// when `force_copy` is set (`--force-copy`, for a destination meant to
/// survive independently of the source — a hard link still shares the same
/// underlying inode, so truncating/rewriting one path in place, outside
/// `select` itself, would affect the other).
fn copy_layer_files(src_dir: &Path, dst_dir: &Path, force_copy: bool) -> io::Result<()> {
for entry in fs::read_dir(src_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
fs::copy(&path, dst_dir.join(entry.file_name()))?;
if !path.is_file() {
continue;
}
let dst_path = dst_dir.join(entry.file_name());
if force_copy || fs::hard_link(&path, &dst_path).is_err() {
fs::copy(&path, &dst_path)?;
}
}
Ok(())
@@ -133,6 +144,7 @@ pub(crate) fn select_partition(
specs: &[OutputCol],
threshold: u32,
output_presence: bool,
force_copy: bool,
) -> OKIResult<()> {
let src_partition = src.partition(i)?;
let dst_partition = dst.partition(i)?;
@@ -147,7 +159,7 @@ pub(crate) fn select_partition(
.map_err(OKIError::Io)?
.dir()
.to_path_buf();
copy_layer_files(src_layer.dir(), &dst_layer_dir).map_err(OKIError::Io)?;
copy_layer_files(src_layer.dir(), &dst_layer_dir, force_copy).map_err(OKIError::Io)?;
let group_mat: Box<dyn MatrixGroupOps> = match src_layer.content() {
LayerContent::Count => {