Skip to main content

aft/
legacy_partitions.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use rusqlite::{Connection, OpenFlags};
8use serde_json::json;
9
10const ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR: u64 = 3;
11const ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR: u64 = 2;
12const SQLITE_SUFFIXES: [&str; 4] = [".sqlite-wal", ".sqlite-shm", ".sqlite-journal", ".sqlite"];
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum LegacyPartitionKind {
16    Callgraph,
17    Inspect,
18}
19
20impl LegacyPartitionKind {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Callgraph => "callgraph",
24            Self::Inspect => "inspect",
25        }
26    }
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LegacyPartitionInventoryEntry {
31    pub harness: String,
32    pub kind: LegacyPartitionKind,
33    pub key: String,
34    /// Logical partition path. The current legacy layout stores flat files, but
35    /// callers treat `<storage>/<harness>/<domain>/<key>` as the partition ID.
36    pub path: PathBuf,
37    pub bytes: u64,
38    pub callgraph_pointer_mtime: Option<SystemTime>,
39    pub inspect_tier2_last_full_run: Option<i64>,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct LegacyHarnessDuplication {
44    pub harness: String,
45    pub partitions: usize,
46    pub bytes: u64,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct DiskFloorDecision {
51    pub source_bytes: u64,
52    pub available_bytes: u64,
53    pub required_bytes: u64,
54}
55
56impl DiskFloorDecision {
57    pub fn allows_copy(self) -> bool {
58        self.available_bytes >= self.required_bytes
59    }
60
61    pub fn should_skip_copy(self) -> bool {
62        !self.allows_copy()
63    }
64
65    pub fn warning_message(self, source: &Path, target: &Path) -> String {
66        format!(
67            "Skipping root-keyed cache copy from {} into {}: free disk ({}) is below the required 1.5× floor ({} for {} source bytes).",
68            source.display(),
69            target.display(),
70            self.available_bytes,
71            self.required_bytes,
72            self.source_bytes
73        )
74    }
75
76    pub fn configure_warning(self, source: &Path, target: &Path) -> serde_json::Value {
77        json!({
78            "kind": "root_keyed_disk_floor",
79            "source_path": source.display().to_string(),
80            "target_path": target.display().to_string(),
81            "bytes_source": self.source_bytes,
82            "bytes_free": self.available_bytes,
83            "bytes_required": self.required_bytes,
84            "message": self.warning_message(source, target),
85        })
86    }
87}
88
89pub fn required_root_keyed_copy_free_bytes(source_bytes: u64) -> u64 {
90    source_bytes
91        .saturating_mul(ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR)
92        .saturating_add(ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR - 1)
93        / ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR
94}
95
96pub fn evaluate_root_keyed_copy_disk_floor(
97    source_bytes: u64,
98    available_bytes: u64,
99) -> DiskFloorDecision {
100    DiskFloorDecision {
101        source_bytes,
102        available_bytes,
103        required_bytes: required_root_keyed_copy_free_bytes(source_bytes),
104    }
105}
106
107/// Read free bytes for `path` from the filesystem containing the nearest
108/// existing ancestor. Future root-keyed migration/copy paths use this seam for
109/// the 1.5× disk-floor preflight.
110pub fn available_disk_for(path: &Path) -> io::Result<u64> {
111    #[cfg(unix)]
112    {
113        use std::ffi::CString;
114        use std::os::unix::ffi::OsStrExt;
115
116        let probe = existing_ancestor(path);
117        let c_path = CString::new(probe.as_os_str().as_bytes())
118            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
119        let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
120        let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
121        if result != 0 {
122            return Err(io::Error::last_os_error());
123        }
124        let stat = unsafe { stat.assume_init() };
125        Ok((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
126    }
127
128    #[cfg(windows)]
129    {
130        let _ = path;
131        // Root-keyed migration is not wired on Windows yet. Mirror the existing
132        // storage-migration posture so call sites can remain total until the
133        // Windows-specific disk probe lands.
134        Ok(u64::MAX)
135    }
136}
137
138/// Cheap structural predicate for the coexistence window: true when `candidate`
139/// points anywhere under `<storage>/<harness>/(callgraph|inspect)`.
140pub fn is_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) -> bool {
141    let storage_root = lexical_normalize(storage_root);
142    let candidate = if candidate.is_absolute() {
143        lexical_normalize(candidate)
144    } else {
145        lexical_normalize(&storage_root.join(candidate))
146    };
147
148    let Ok(relative) = candidate.strip_prefix(&storage_root) else {
149        return false;
150    };
151
152    let mut components = relative.components();
153    let Some(Component::Normal(_harness)) = components.next() else {
154        return false;
155    };
156    let Some(Component::Normal(domain)) = components.next() else {
157        return false;
158    };
159
160    matches!(domain.to_str(), Some("callgraph" | "inspect"))
161}
162
163#[track_caller]
164pub fn debug_assert_not_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) {
165    debug_assert!(
166        !is_legacy_harness_partition_path(storage_root, candidate),
167        "new-layout write path must not point into a legacy harness partition: {}",
168        candidate.display()
169    );
170}
171
172pub fn refuse_legacy_partition_write(
173    storage_root: &Path,
174    candidate: &Path,
175    operation: &str,
176) -> io::Result<()> {
177    if is_legacy_harness_partition_path(storage_root, candidate) {
178        return Err(io::Error::new(
179            io::ErrorKind::PermissionDenied,
180            format!(
181                "refusing {operation} into legacy harness partition {}",
182                candidate.display()
183            ),
184        ));
185    }
186    Ok(())
187}
188
189#[track_caller]
190pub fn guard_new_layout_write_path(
191    storage_root: &Path,
192    candidate: &Path,
193    operation: &str,
194) -> io::Result<()> {
195    debug_assert_not_legacy_harness_partition_path(storage_root, candidate);
196    refuse_legacy_partition_write(storage_root, candidate, operation)
197}
198
199pub fn inventory_legacy_partitions(
200    storage_root: &Path,
201) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
202    let storage_root = lexical_normalize(storage_root);
203    let mut entries = Vec::new();
204    for harness_entry in sorted_read_dir(&storage_root)? {
205        if !harness_entry.file_type()?.is_dir() {
206            continue;
207        }
208        let harness = harness_entry.file_name().to_string_lossy().to_string();
209        let harness_path = harness_entry.path();
210        entries.extend(scan_legacy_callgraph_partitions(
211            &harness,
212            &harness_path.join(LegacyPartitionKind::Callgraph.as_str()),
213        )?);
214        entries.extend(scan_legacy_inspect_partitions(
215            &harness,
216            &harness_path.join(LegacyPartitionKind::Inspect.as_str()),
217        )?);
218    }
219    entries.sort_by(|left, right| {
220        left.harness
221            .cmp(&right.harness)
222            .then_with(|| left.kind.as_str().cmp(right.kind.as_str()))
223            .then_with(|| left.key.cmp(&right.key))
224    });
225    Ok(entries)
226}
227
228pub fn summarize_legacy_partition_duplication(
229    storage_root: &Path,
230) -> io::Result<Vec<LegacyHarnessDuplication>> {
231    let mut summaries = BTreeMap::<String, LegacyHarnessDuplication>::new();
232    for entry in inventory_legacy_partitions(storage_root)? {
233        let summary =
234            summaries
235                .entry(entry.harness.clone())
236                .or_insert_with(|| LegacyHarnessDuplication {
237                    harness: entry.harness.clone(),
238                    partitions: 0,
239                    bytes: 0,
240                });
241        summary.partitions += 1;
242        summary.bytes = summary.bytes.saturating_add(entry.bytes);
243    }
244    Ok(summaries.into_values().collect())
245}
246
247#[derive(Clone, Debug, Default)]
248struct PartitionAccumulator {
249    bytes: u64,
250    callgraph_pointer_mtime: Option<SystemTime>,
251    inspect_tier2_last_full_run: Option<i64>,
252}
253
254fn scan_legacy_callgraph_partitions(
255    harness: &str,
256    callgraph_dir: &Path,
257) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
258    let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
259    for entry in sorted_read_dir(callgraph_dir)? {
260        let file_type = entry.file_type()?;
261        let name = entry.file_name().to_string_lossy().to_string();
262        if file_type.is_dir() {
263            if !looks_like_partition_key(&name) {
264                continue;
265            }
266            let partition = partitions.entry(name.clone()).or_default();
267            partition.bytes = partition.bytes.saturating_add(tree_size(&entry.path())?);
268            if partition.callgraph_pointer_mtime.is_none() {
269                partition.callgraph_pointer_mtime = callgraph_pointer_mtime(callgraph_dir, &name);
270            }
271            continue;
272        }
273
274        let Some(key) = callgraph_partition_key_from_name(&name) else {
275            continue;
276        };
277        let partition = partitions.entry(key.clone()).or_default();
278        partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
279        if name.ends_with(".current") {
280            partition.callgraph_pointer_mtime =
281                entry.metadata().and_then(|meta| meta.modified()).ok();
282        }
283    }
284
285    Ok(partitions
286        .into_iter()
287        .map(|(key, partition)| LegacyPartitionInventoryEntry {
288            harness: harness.to_string(),
289            kind: LegacyPartitionKind::Callgraph,
290            path: callgraph_dir.join(&key),
291            key,
292            bytes: partition.bytes,
293            callgraph_pointer_mtime: partition.callgraph_pointer_mtime,
294            inspect_tier2_last_full_run: None,
295        })
296        .collect())
297}
298
299fn scan_legacy_inspect_partitions(
300    harness: &str,
301    inspect_dir: &Path,
302) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
303    let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
304    for entry in sorted_read_dir(inspect_dir)? {
305        let file_type = entry.file_type()?;
306        let name = entry.file_name().to_string_lossy().to_string();
307        if file_type.is_dir() {
308            if !looks_like_partition_key(&name) {
309                continue;
310            }
311            let partition = partitions.entry(name.clone()).or_default();
312            partition.bytes = partition.bytes.saturating_add(tree_size(&entry.path())?);
313            if partition.inspect_tier2_last_full_run.is_none() {
314                partition.inspect_tier2_last_full_run =
315                    inspect_tier2_last_full_run(inspect_dir, &name);
316            }
317            continue;
318        }
319
320        let Some(key) = inspect_partition_key_from_name(&name) else {
321            continue;
322        };
323        let partition = partitions.entry(key.clone()).or_default();
324        partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
325    }
326
327    Ok(partitions
328        .into_iter()
329        .map(|(key, mut partition)| {
330            if partition.inspect_tier2_last_full_run.is_none() {
331                partition.inspect_tier2_last_full_run =
332                    inspect_tier2_last_full_run(inspect_dir, &key);
333            }
334            LegacyPartitionInventoryEntry {
335                harness: harness.to_string(),
336                kind: LegacyPartitionKind::Inspect,
337                path: inspect_dir.join(&key),
338                key,
339                bytes: partition.bytes,
340                callgraph_pointer_mtime: None,
341                inspect_tier2_last_full_run: partition.inspect_tier2_last_full_run,
342            }
343        })
344        .collect())
345}
346
347fn callgraph_pointer_mtime(callgraph_dir: &Path, key: &str) -> Option<SystemTime> {
348    for candidate in [
349        callgraph_dir.join(format!("{key}.current")),
350        callgraph_dir.join(key).join(format!("{key}.current")),
351    ] {
352        if let Ok(modified) = fs::metadata(candidate).and_then(|metadata| metadata.modified()) {
353            return Some(modified);
354        }
355    }
356    None
357}
358
359fn inspect_tier2_last_full_run(inspect_dir: &Path, key: &str) -> Option<i64> {
360    let sqlite_path = [
361        inspect_dir.join(format!("{key}.sqlite")),
362        inspect_dir.join(key).join(format!("{key}.sqlite")),
363    ]
364    .into_iter()
365    .find(|candidate| candidate.is_file())?;
366
367    let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
368    conn.busy_timeout(Duration::from_millis(500)).ok()?;
369    conn.query_row("SELECT MAX(last_full_run) FROM tier2_meta", [], |row| {
370        row.get::<_, Option<i64>>(0)
371    })
372    .ok()
373    .flatten()
374}
375
376fn callgraph_partition_key_from_name(name: &str) -> Option<String> {
377    if name.contains(".tmp.") {
378        return None;
379    }
380    if let Some(key) = name.strip_suffix(".current") {
381        return looks_like_partition_key(key).then(|| key.to_string());
382    }
383    let base = sqliteish_base_name(name)?;
384    let key = if let Some((candidate, generation)) = base.split_once(".g") {
385        if generation.is_empty() {
386            return None;
387        }
388        candidate
389    } else {
390        base
391    };
392    looks_like_partition_key(key).then(|| key.to_string())
393}
394
395fn inspect_partition_key_from_name(name: &str) -> Option<String> {
396    if name.contains(".tmp.") {
397        return None;
398    }
399    let base = sqliteish_base_name(name)?;
400    looks_like_partition_key(base).then(|| base.to_string())
401}
402
403fn sqliteish_base_name(name: &str) -> Option<&str> {
404    SQLITE_SUFFIXES
405        .iter()
406        .find_map(|suffix| name.strip_suffix(suffix))
407}
408
409fn looks_like_partition_key(value: &str) -> bool {
410    value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
411}
412
413fn file_size(path: &Path) -> io::Result<u64> {
414    Ok(fs::metadata(path)?.len())
415}
416
417fn tree_size(path: &Path) -> io::Result<u64> {
418    if !path.exists() {
419        return Ok(0);
420    }
421    let metadata = fs::metadata(path)?;
422    if metadata.is_file() {
423        return Ok(metadata.len());
424    }
425    if !metadata.is_dir() {
426        return Ok(0);
427    }
428
429    let mut total = 0_u64;
430    for entry in fs::read_dir(path)? {
431        total = total.saturating_add(tree_size(&entry?.path())?);
432    }
433    Ok(total)
434}
435
436fn sorted_read_dir(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
437    let entries = match fs::read_dir(path) {
438        Ok(entries) => entries,
439        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
440        Err(error) => return Err(error),
441    };
442    let mut entries = entries.collect::<io::Result<Vec<_>>>()?;
443    entries.sort_by_key(|entry| entry.file_name());
444    Ok(entries)
445}
446
447fn lexical_normalize(path: &Path) -> PathBuf {
448    let mut normalized = PathBuf::new();
449    for component in path.components() {
450        match component {
451            Component::ParentDir => {
452                if !normalized.pop() {
453                    normalized.push(component);
454                }
455            }
456            Component::CurDir => {}
457            other => normalized.push(other.as_os_str()),
458        }
459    }
460    normalized
461}
462
463#[cfg(unix)]
464fn existing_ancestor(path: &Path) -> &Path {
465    let mut current = path;
466    while !current.exists() {
467        if let Some(parent) = current.parent() {
468            current = parent;
469        } else {
470            break;
471        }
472    }
473    current
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use filetime::FileTime;
480    use rusqlite::params;
481    use std::panic::catch_unwind;
482    use std::time::UNIX_EPOCH;
483    use tempfile::TempDir;
484
485    #[test]
486    fn legacy_partition_guard_matches_exact_domains() {
487        let storage_root = PathBuf::from("/tmp/aft-storage");
488        assert!(is_legacy_harness_partition_path(
489            &storage_root,
490            &storage_root.join("opencode/callgraph/0123456789abcdef.current")
491        ));
492        assert!(is_legacy_harness_partition_path(
493            &storage_root,
494            &storage_root.join("pi/inspect/0123456789abcdef.sqlite")
495        ));
496        assert!(!is_legacy_harness_partition_path(
497            &storage_root,
498            &storage_root.join("opencode/callgraph-old/0123456789abcdef.sqlite")
499        ));
500        assert!(!is_legacy_harness_partition_path(
501            &storage_root,
502            &storage_root.join("index/0123456789abcdef.sqlite")
503        ));
504        assert!(!is_legacy_harness_partition_path(
505            &storage_root,
506            Path::new("/elsewhere/opencode/callgraph/0123456789abcdef.sqlite")
507        ));
508    }
509
510    #[test]
511    fn debug_assert_and_refusal_cover_legacy_write_paths() {
512        let storage_root = PathBuf::from("/tmp/aft-storage");
513        let legacy_target = storage_root.join("opencode/callgraph/0123456789abcdef.sqlite");
514        let panic = catch_unwind(|| {
515            debug_assert_not_legacy_harness_partition_path(&storage_root, &legacy_target);
516        });
517        assert!(panic.is_err());
518
519        let error = refuse_legacy_partition_write(&storage_root, &legacy_target, "publish")
520            .expect_err("legacy write must be refused");
521        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
522        assert!(error
523            .to_string()
524            .contains("refusing publish into legacy harness partition"));
525    }
526
527    #[test]
528    fn root_keyed_copy_disk_floor_boundaries() {
529        let exact = evaluate_root_keyed_copy_disk_floor(20, 30);
530        assert_eq!(exact.required_bytes, 30);
531        assert!(exact.allows_copy());
532        assert!(!exact.should_skip_copy());
533
534        let below = evaluate_root_keyed_copy_disk_floor(20, 29);
535        assert!(below.should_skip_copy());
536        assert!(below
537            .warning_message(Path::new("/legacy"), Path::new("/shared"))
538            .contains("1.5× floor"));
539
540        let above = evaluate_root_keyed_copy_disk_floor(20, 31);
541        assert!(above.allows_copy());
542        assert_eq!(
543            below.configure_warning(Path::new("/legacy"), Path::new("/shared"))["kind"],
544            "root_keyed_disk_floor"
545        );
546    }
547
548    #[test]
549    fn inventory_fixture_reports_partition_sizes_and_freshness() {
550        let fixture = write_legacy_inventory_fixture();
551        let storage_root = fixture.temp.path();
552
553        let inventory = inventory_legacy_partitions(storage_root).expect("inventory");
554        assert_eq!(inventory.len(), 2);
555
556        let callgraph = inventory
557            .iter()
558            .find(|entry| entry.kind == LegacyPartitionKind::Callgraph)
559            .expect("callgraph entry");
560        let expected_callgraph_bytes = partition_bytes_on_disk(
561            &storage_root.join("opencode/callgraph"),
562            "0123456789abcdef",
563            callgraph_partition_key_from_name,
564        );
565        assert_eq!(callgraph.harness, "opencode");
566        assert_eq!(callgraph.key, "0123456789abcdef");
567        assert_eq!(
568            callgraph.path,
569            storage_root.join("opencode/callgraph/0123456789abcdef")
570        );
571        assert_eq!(callgraph.bytes, expected_callgraph_bytes);
572        let pointer_secs = callgraph
573            .callgraph_pointer_mtime
574            .expect("pointer mtime")
575            .duration_since(UNIX_EPOCH)
576            .expect("mtime after epoch")
577            .as_secs();
578        assert_eq!(pointer_secs, 1_750_000_000);
579        assert_eq!(callgraph.inspect_tier2_last_full_run, None);
580
581        let inspect = inventory
582            .iter()
583            .find(|entry| entry.kind == LegacyPartitionKind::Inspect)
584            .expect("inspect entry");
585        let minimum_inspect_bytes =
586            file_size(&storage_root.join("pi/inspect/fedcba9876543210.sqlite"))
587                .expect("inspect sqlite size");
588        assert_eq!(inspect.harness, "pi");
589        assert_eq!(inspect.key, "fedcba9876543210");
590        assert_eq!(
591            inspect.path,
592            storage_root.join("pi/inspect/fedcba9876543210")
593        );
594        assert!(inspect.bytes >= minimum_inspect_bytes);
595        assert_eq!(inspect.callgraph_pointer_mtime, None);
596        assert_eq!(inspect.inspect_tier2_last_full_run, Some(250));
597
598        let summary = summarize_legacy_partition_duplication(storage_root).expect("summary");
599        assert_eq!(
600            summary,
601            vec![
602                LegacyHarnessDuplication {
603                    harness: "opencode".to_string(),
604                    partitions: 1,
605                    bytes: expected_callgraph_bytes,
606                },
607                LegacyHarnessDuplication {
608                    harness: "pi".to_string(),
609                    partitions: 1,
610                    bytes: inspect.bytes,
611                },
612            ]
613        );
614    }
615
616    struct LegacyInventoryFixture {
617        temp: TempDir,
618    }
619
620    fn write_legacy_inventory_fixture() -> LegacyInventoryFixture {
621        let temp = tempfile::tempdir().expect("tempdir");
622
623        let callgraph_dir = temp.path().join("opencode/callgraph");
624        fs::create_dir_all(&callgraph_dir).expect("create callgraph dir");
625        fs::write(
626            callgraph_dir.join("0123456789abcdef.current"),
627            b"0123456789abcdef.g1.1.sqlite\n",
628        )
629        .expect("write pointer");
630        fs::write(
631            callgraph_dir.join("0123456789abcdef.g1.1.sqlite"),
632            b"callgraph-db",
633        )
634        .expect("write generation db");
635        fs::write(
636            callgraph_dir.join("0123456789abcdef.g1.1.sqlite-wal"),
637            b"wal",
638        )
639        .expect("write generation wal");
640        fs::write(
641            callgraph_dir.join("0123456789abcdef.current.tmp.123"),
642            b"ignored-temp",
643        )
644        .expect("write ignored temp");
645        filetime::set_file_mtime(
646            callgraph_dir.join("0123456789abcdef.current"),
647            FileTime::from_unix_time(1_750_000_000, 0),
648        )
649        .expect("set pointer mtime");
650
651        let inspect_dir = temp.path().join("pi/inspect");
652        fs::create_dir_all(&inspect_dir).expect("create inspect dir");
653        let sqlite_path = inspect_dir.join("fedcba9876543210.sqlite");
654        let conn = Connection::open(&sqlite_path).expect("open inspect db");
655        conn.execute(
656            "CREATE TABLE tier2_meta (category TEXT NOT NULL, project_key TEXT NOT NULL, last_full_run INTEGER NOT NULL)",
657            [],
658        )
659        .expect("create tier2_meta");
660        conn.execute(
661            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
662            params!["dead_code", "fedcba9876543210", 100_i64],
663        )
664        .expect("insert first tier2 row");
665        conn.execute(
666            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
667            params!["duplicates", "fedcba9876543210", 250_i64],
668        )
669        .expect("insert second tier2 row");
670        drop(conn);
671        fs::write(inspect_dir.join("misc.txt"), b"ignored").expect("write ignored file");
672
673        LegacyInventoryFixture { temp }
674    }
675
676    fn partition_bytes_on_disk(
677        domain_dir: &Path,
678        expected_key: &str,
679        key_fn: fn(&str) -> Option<String>,
680    ) -> u64 {
681        sorted_read_dir(domain_dir)
682            .expect("read partition dir")
683            .into_iter()
684            .filter_map(|entry| {
685                let name = entry.file_name().to_string_lossy().to_string();
686                let key = key_fn(&name)?;
687                if key != expected_key {
688                    return None;
689                }
690                Some(file_size(&entry.path()).expect("partition file size"))
691            })
692            .sum()
693    }
694}