Skip to main content

writerslogic_warm/core/format/
upgrade.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3//! The migration framework behind `warm-format upgrade`.
4//!
5//! A `Migration` moves one artefact from one version to the next. `MIGRATIONS` is the
6//! registry; a format change adds an entry (and a golden store, see `tests/golden/`). The
7//! planner chains entries until the artefact reaches the version this build writes, so a
8//! store several versions behind upgrades in one run.
9//!
10//! The source is never modified. The upgraded store is staged next to the destination,
11//! verified, and published with one rename.
12
13use std::fs::File;
14use std::path::Path;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use anyhow::{bail, ensure, Context, Result};
18use memmap2::Mmap;
19use serde::Serialize;
20
21use super::scan::{self, SegmentEnd};
22use super::verify::{verify_path, VerifyOptions};
23use super::{Report, Verdict, STORAGE_FORMAT_CURRENT, STORE_SCHEMA_VERSION};
24use crate::core::storage::StagedArena;
25use crate::core::store_lock::{StoreLock, LOCK_FILE};
26
27const ARENA_DIRECTORY: &str = "vectors_data.bin";
28
29/// One step of a plan, as shown to the operator.
30#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct UpgradeStep {
33    pub id: &'static str,
34    pub artefact: &'static str,
35    pub from: u32,
36    pub to: u32,
37    pub description: &'static str,
38}
39
40/// What a store needs to reach the current format.
41#[derive(Debug, Clone, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct UpgradePlan {
44    pub source: String,
45    /// Arena manifest version found (1 for a store with no manifest).
46    pub storage_format: u32,
47    pub target_storage_format: u32,
48    pub store_schema: u32,
49    pub target_store_schema: u32,
50    pub up_to_date: bool,
51    pub steps: Vec<UpgradeStep>,
52}
53
54/// What an upgrade did.
55#[derive(Debug, Clone, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct UpgradeReport {
58    pub plan: UpgradePlan,
59    pub destination: Option<String>,
60    pub frames: usize,
61    pub verification: Option<Report>,
62}
63
64/// Everything a step may use. Steps run in registry order against the same staging directory.
65struct StepContext<'a> {
66    source: &'a Path,
67    staging: &'a Path,
68    frames: usize,
69    /// `(raw_len, crc)` of every frame written, in order, to compare with what the staged
70    /// arena scans back as.
71    written: Vec<(u32, u32)>,
72}
73
74struct Migration {
75    step: UpgradeStep,
76    /// True if the store, at `detected` arena version, needs this step.
77    applies: fn(&Detected) -> bool,
78    run: fn(&mut StepContext<'_>) -> Result<()>,
79}
80
81struct Detected {
82    storage_format: u32,
83    store_schema: u32,
84    text_segment: Option<u8>,
85    has_caches: bool,
86}
87
88/// Every upgrade step that exists. Order matters only between steps that apply together.
89static MIGRATIONS: &[Migration] = &[
90    Migration {
91        step: UpgradeStep {
92            id: "store-schema-2-to-5",
93            artefact: "store-schema",
94            from: 2,
95            to: 5,
96            description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
97        },
98        applies: |d| d.store_schema == 2,
99        run: upgrade_schema,
100    },
101    Migration {
102        step: UpgradeStep {
103            id: "store-schema-3-to-5",
104            artefact: "store-schema",
105            from: 3,
106            to: 5,
107            description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
108        },
109        applies: |d| d.store_schema == 3,
110        run: upgrade_schema,
111    },
112    Migration {
113        step: UpgradeStep {
114            id: "store-schema-4-to-5",
115            artefact: "store-schema",
116            from: 4,
117            to: 5,
118            description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
119        },
120        applies: |d| d.store_schema == 4,
121        run: upgrade_schema,
122    },
123    Migration {
124        step: UpgradeStep {
125            id: "arena-1-to-4",
126            artefact: "arena",
127            from: 1,
128            to: 4,
129            description: "rewrite the journal frames of a manifest-less store into a format-4 \
130                          arena (1 GiB segments allocated in 64 MiB steps, end markers, \
131                          checksummed manifest)",
132        },
133        applies: |d| d.storage_format == 1,
134        run: rewrite_arena,
135    },
136    Migration {
137        step: UpgradeStep {
138            id: "arena-2-to-4",
139            artefact: "arena",
140            from: 2,
141            to: 4,
142            description: "rewrite the journal frames of a format-2 arena (fully preallocated 1 \
143                          GiB segments, zero terminator) into a format-4 arena",
144        },
145        applies: |d| d.storage_format == 2,
146        run: rewrite_arena,
147    },
148    Migration {
149        step: UpgradeStep {
150            id: "arena-3-to-4",
151            artefact: "arena",
152            from: 3,
153            to: 4,
154            description: "rewrite the journal frames of a format-3 arena (64 MiB fixed \
155                          segments) into a format-4 arena with 1 GiB segments",
156        },
157        applies: |d| d.storage_format == 3,
158        run: rewrite_arena,
159    },
160    Migration {
161        step: UpgradeStep {
162            id: "text-segment-1-to-2",
163            artefact: "text-segment",
164            from: 1,
165            to: 2,
166            description: "drop documents.text (a cache; the engine rebuilds it with the current \
167                          header from the journal on first open)",
168        },
169        applies: |d| {
170            d.text_segment
171                .is_none_or(|v| v < super::TEXT_SEGMENT_VERSION)
172        },
173        run: |_| Ok(()),
174    },
175    Migration {
176        step: UpgradeStep {
177            id: "index-caches",
178            artefact: "index-caches",
179            from: 0,
180            to: 0,
181            description: "drop indices.json and the trained NSG/IVF files: they are bound to \
182                          the old journal length and the engine retrains them",
183        },
184        applies: |d| d.has_caches,
185        run: |_| Ok(()),
186    },
187];
188
189fn detect(source: &Path, report: &Report) -> Detected {
190    Detected {
191        store_schema: report.versions.store_schema.unwrap_or(STORE_SCHEMA_VERSION),
192        storage_format: report
193            .versions
194            .storage_format
195            .unwrap_or(STORAGE_FORMAT_CURRENT),
196        text_segment: source
197            .join("documents.text")
198            .exists()
199            .then_some(report.versions.text_segment.unwrap_or(1)),
200        has_caches: source.join("indices.json").exists(),
201    }
202}
203
204/// Plans the upgrade of the store at `source` without touching it.
205pub fn plan_upgrade(source: &Path) -> Result<UpgradePlan> {
206    let report = verify_path(source, &VerifyOptions::default())?;
207    plan_from(source, &report)
208}
209
210fn plan_from(source: &Path, report: &Report) -> Result<UpgradePlan> {
211    ensure!(
212        report.tenants.is_empty(),
213        "{} is a server data directory; plan each tenant store, or upgrade the directory",
214        source.display()
215    );
216    refuse_unupgradable(report)?;
217    let detected = detect(source, report);
218    let current = detected.storage_format == STORAGE_FORMAT_CURRENT
219        && detected.store_schema == STORE_SCHEMA_VERSION;
220    let steps = if current {
221        Vec::new()
222    } else {
223        MIGRATIONS
224            .iter()
225            .filter(|m| (m.applies)(&detected))
226            .map(|m| m.step.clone())
227            .collect()
228    };
229    Ok(UpgradePlan {
230        source: source.display().to_string(),
231        storage_format: detected.storage_format,
232        target_storage_format: STORAGE_FORMAT_CURRENT,
233        store_schema: detected.store_schema,
234        target_store_schema: STORE_SCHEMA_VERSION,
235        up_to_date: current,
236        steps,
237    })
238}
239
240fn refuse_unupgradable(report: &Report) -> Result<()> {
241    match report.verdict {
242        Verdict::Ok | Verdict::Recoverable => Ok(()),
243        Verdict::Unsupported => {
244            let reasons: Vec<_> = report
245                .findings
246                .iter()
247                .filter(|f| f.severity == super::Severity::Unsupported)
248                .map(|f| format!("{}: {}", f.code, f.message))
249                .collect();
250            bail!(
251                "this build cannot upgrade the store ({}); a store with no store.json or a \
252                 pre-HMS-TXN-2 journal is rebuilt with `warm-admin reencode`, a store from a \
253                 newer release needs that release's warm-format",
254                reasons.join("; ")
255            )
256        }
257        Verdict::Corrupt => {
258            let reasons: Vec<_> = report
259                .findings
260                .iter()
261                .filter(|f| f.severity == super::Severity::Error)
262                .map(|f| format!("{}: {}", f.code, f.message))
263                .collect();
264            bail!(
265                "the store is damaged and is not upgraded: {}. Run `warm-format verify` for \
266                 the full report; restore a snapshot first",
267                reasons.join("; ")
268            )
269        }
270    }
271}
272
273/// Creates the parent directory of `destination`, which must not exist yet, and returns it.
274fn prepare_destination(destination: &Path) -> Result<&Path> {
275    ensure!(
276        !destination.exists(),
277        "upgrade destination already exists: {}",
278        destination.display()
279    );
280    let parent = destination
281        .parent()
282        .filter(|p| !p.as_os_str().is_empty())
283        .unwrap_or_else(|| Path::new("."));
284    std::fs::create_dir_all(parent)?;
285    Ok(parent)
286}
287
288/// Upgrades the store at `source` into a new directory `destination` (which must not exist).
289/// `dry_run` plans and verifies the source only. If the store is already current, nothing
290/// is written and the report says so.
291pub fn upgrade_store(source: &Path, destination: &Path, dry_run: bool) -> Result<UpgradeReport> {
292    let sniff = verify_path(source, &VerifyOptions::default())?;
293    if !sniff.tenants.is_empty() {
294        return upgrade_data_dir(source, destination, dry_run, &sniff);
295    }
296    let plan = plan_from(source, &sniff)?;
297    if plan.up_to_date || dry_run {
298        return Ok(UpgradeReport {
299            plan,
300            destination: None,
301            frames: sniff.frames,
302            verification: None,
303        });
304    }
305    let source_canonical = source.canonicalize()?;
306    let parent = prepare_destination(destination)?;
307    ensure!(
308        !parent.canonicalize()?.starts_with(&source_canonical),
309        "upgrade destination cannot be inside the source store"
310    );
311    let _lock = StoreLock::acquire(source)?;
312    let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
313    let staging = parent.join(format!(".warm-upgrade-{}-{nonce}", std::process::id()));
314    let outcome = stage_and_publish(source, &staging, destination, plan.clone());
315    if outcome.is_err() && staging.exists() {
316        // The staging directory is unpublished; the upgrade's own error is the one to report.
317        let _ = std::fs::remove_dir_all(&staging);
318    }
319    outcome
320}
321
322fn upgrade_data_dir(
323    source: &Path,
324    destination: &Path,
325    dry_run: bool,
326    sniff: &Report,
327) -> Result<UpgradeReport> {
328    let mut plans = Vec::new();
329    for tenant in &sniff.tenants {
330        let dir = source
331            .join("tenants")
332            .join(tenant_dir(source, &tenant.name)?);
333        plans.push(plan_from(&dir, &tenant.report)?);
334    }
335    let behind = plans.iter().any(|p| !p.up_to_date);
336    let plan = UpgradePlan {
337        source: source.display().to_string(),
338        storage_format: plans
339            .iter()
340            .map(|p| p.storage_format)
341            .min()
342            .unwrap_or(STORAGE_FORMAT_CURRENT),
343        target_storage_format: STORAGE_FORMAT_CURRENT,
344        store_schema: plans
345            .iter()
346            .map(|p| p.store_schema)
347            .min()
348            .unwrap_or(STORE_SCHEMA_VERSION),
349        target_store_schema: STORE_SCHEMA_VERSION,
350        up_to_date: !behind,
351        steps: plans
352            .iter()
353            .flat_map(|p| p.steps.clone())
354            .fold(Vec::new(), |mut acc, s| {
355                if !acc.iter().any(|a: &UpgradeStep| a.id == s.id) {
356                    acc.push(s);
357                }
358                acc
359            }),
360    };
361    if dry_run || !behind {
362        return Ok(UpgradeReport {
363            plan,
364            destination: None,
365            frames: sniff.tenants.iter().map(|t| t.report.frames).sum(),
366            verification: None,
367        });
368    }
369    let parent = prepare_destination(destination)?;
370    let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
371    let staging = parent.join(format!(".warm-upgrade-{}-{nonce}", std::process::id()));
372    let result = (|| -> Result<(usize, Report)> {
373        std::fs::create_dir_all(staging.join("tenants"))?;
374        let mut frames = 0;
375        let mut copied_verdicts = crate::core::hash::FastMap::default();
376        for tenant in &sniff.tenants {
377            let name = tenant_dir(source, &tenant.name)?;
378            let from = source.join("tenants").join(&name);
379            let to = staging.join("tenants").join(&name);
380            let _lock = StoreLock::acquire(&from)?;
381            let tenant_plan = plan_from(&from, &tenant.report)?;
382            if tenant_plan.up_to_date {
383                crate::core::admin::copy_tree(&from, &to)?;
384                copied_verdicts.insert(tenant.name.clone(), tenant.report.verdict);
385                frames += tenant.report.frames;
386            } else {
387                let staged = stage_store(&from, &to)?;
388                if tenant_plan.storage_format == STORAGE_FORMAT_CURRENT {
389                    copied_verdicts.insert(tenant.name.clone(), tenant.report.verdict);
390                }
391                frames += staged.frames;
392            }
393        }
394        for entry in std::fs::read_dir(source)? {
395            let entry = entry?;
396            if entry.file_name() != "tenants" && entry.file_type()?.is_file() {
397                std::fs::copy(entry.path(), staging.join(entry.file_name()))?;
398            }
399        }
400        let mut verification = verify_path(&staging, &VerifyOptions::default())?;
401        for tenant in &verification.tenants {
402            let allowed = copied_verdicts
403                .get(&tenant.name)
404                .copied()
405                .unwrap_or(Verdict::Ok);
406            ensure!(
407                tenant.report.verdict == Verdict::Ok
408                    || (allowed == Verdict::Recoverable
409                        && tenant.report.verdict == Verdict::Recoverable),
410                "upgraded tenant {:?} verifies as {:?} (allowed {:?}): {:?}",
411                tenant.name,
412                tenant.report.verdict,
413                allowed,
414                tenant.report.findings
415            );
416        }
417        ensure!(
418            matches!(verification.verdict, Verdict::Ok | Verdict::Recoverable),
419            "upgraded data directory does not verify: {:?}; tenants: {:?}",
420            verification.findings,
421            verification.tenants
422        );
423        for tenant in &mut verification.tenants {
424            tenant.report.path = destination
425                .join(Path::new(&tenant.report.path).strip_prefix(&staging)?)
426                .display()
427                .to_string();
428        }
429        verification.path = destination.display().to_string();
430        std::fs::rename(&staging, destination)?;
431        crate::core::durable_file::sync_directory(parent)?;
432        Ok((frames, verification))
433    })();
434    match result {
435        Ok((frames, verification)) => Ok(UpgradeReport {
436            plan,
437            destination: Some(destination.display().to_string()),
438            frames,
439            verification: Some(verification),
440        }),
441        Err(error) => {
442            // The staging directory is unpublished; the upgrade's own error is the one to report.
443            let _ = std::fs::remove_dir_all(&staging);
444            Err(error)
445        }
446    }
447}
448
449/// Directory name of tenant `name` (hex of its UTF-8 bytes), or the name itself if it was
450/// reported undecoded.
451fn tenant_dir(source: &Path, name: &str) -> Result<String> {
452    let hex: String = name.bytes().map(|b| format!("{b:02x}")).collect();
453    if source.join("tenants").join(&hex).is_dir() {
454        Ok(hex)
455    } else if source.join("tenants").join(name).is_dir() {
456        Ok(name.to_owned())
457    } else {
458        bail!("tenant directory for {name:?} not found")
459    }
460}
461
462fn stage_and_publish(
463    source: &Path,
464    staging: &Path,
465    destination: &Path,
466    plan: UpgradePlan,
467) -> Result<UpgradeReport> {
468    let original = verify_path(source, &VerifyOptions::default())?;
469    let staged = stage_store(source, staging)?;
470    let verification = verify_path(staging, &VerifyOptions::default())?;
471    ensure!(
472        verification.verdict == Verdict::Ok
473            || (plan.storage_format == STORAGE_FORMAT_CURRENT
474                && original.verdict == Verdict::Recoverable
475                && verification.verdict == Verdict::Recoverable),
476        "the upgraded store does not verify: {:?}",
477        verification.findings
478    );
479    ensure!(
480        verification.frames == staged.frames,
481        "upgraded store holds {} frames, {} were written",
482        verification.frames,
483        staged.frames
484    );
485    std::fs::rename(staging, destination).with_context(|| {
486        format!(
487            "failed to publish {} as {}",
488            staging.display(),
489            destination.display()
490        )
491    })?;
492    if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
493        crate::core::durable_file::sync_directory(parent)?;
494    }
495    let mut verification = verification;
496    verification.path = destination.display().to_string();
497    Ok(UpgradeReport {
498        plan,
499        destination: Some(destination.display().to_string()),
500        frames: staged.frames,
501        verification: Some(verification),
502    })
503}
504
505/// Copies the files that survive an upgrade unchanged; the caches and the lock are skipped.
506fn copy_store_files(source: &Path, staging: &Path, skip: &[&str]) -> Result<()> {
507    std::fs::create_dir_all(staging)?;
508    for entry in std::fs::read_dir(source)? {
509        let entry = entry?;
510        let name = entry.file_name().to_string_lossy().into_owned();
511        let cache = name.starts_with("nsg_index") || name.starts_with("ivf_index");
512        let skipped = name == LOCK_FILE
513            || name == ARENA_DIRECTORY
514            || name == "indices.json"
515            || name == "indices.json.crc32"
516            || cache
517            || skip.contains(&name.as_str());
518        if skipped {
519            continue;
520        }
521        let kind = entry.file_type()?;
522        ensure!(!kind.is_symlink(), "upgrade refuses symbolic link {name}");
523        if kind.is_file() {
524            let target = staging.join(&name);
525            std::fs::copy(entry.path(), &target)?;
526            std::fs::set_permissions(&target, entry.metadata()?.permissions())?;
527        } else {
528            bail!("upgrade does not know how to carry the directory {name}");
529        }
530    }
531    Ok(())
532}
533
534struct Staged {
535    frames: usize,
536}
537
538/// Runs the plan: carries the unchanged files, then every step the registry selects.
539fn stage_store(source: &Path, staging: &Path) -> Result<Staged> {
540    let report = verify_path(source, &VerifyOptions::default())?;
541    refuse_unupgradable(&report)?;
542    let detected = detect(source, &report);
543    copy_store_files(source, staging, &["documents.text"])?;
544    // `store.json` was copied byte for byte; rewrite its sidecar so the copy is checksummed
545    // even when the source predates sidecars.
546    let schema = staging.join("store.json");
547    let bytes = std::fs::read(&schema)?;
548    crate::core::durable_file::atomic_write(&schema, &bytes)?;
549    let mut context = StepContext {
550        source,
551        staging,
552        frames: 0,
553        written: Vec::new(),
554    };
555    for migration in MIGRATIONS.iter().filter(|m| (m.applies)(&detected)) {
556        (migration.run)(&mut context)
557            .with_context(|| format!("upgrade step {} failed", migration.step.id))?;
558    }
559    if detected.storage_format == STORAGE_FORMAT_CURRENT {
560        crate::core::admin::copy_tree(
561            &source.join(ARENA_DIRECTORY),
562            &staging.join(ARENA_DIRECTORY),
563        )?;
564        return Ok(Staged {
565            frames: report.frames,
566        });
567    }
568    ensure!(context.frames > 0, "the store has no journal frames");
569    // The staged arena must hold exactly the frames that were read, in order.
570    let mut scanned = Vec::new();
571    let arena = staging.join(ARENA_DIRECTORY);
572    for id in 0.. {
573        let path = arena.join(format!("seg_{id}.bin"));
574        if !path.exists() {
575            break;
576        }
577        // SAFETY: a read-only mapping of a segment no one writes while the upgrade runs;
578        // nothing borrowed from it outlives this iteration.
579        let map = unsafe { Mmap::map(&File::open(&path)?)? };
580        let scan = scan::scan_segment(&map, true, |f, _| scanned.push((f.raw_len, f.crc)));
581        ensure!(
582            matches!(scan.end, SegmentEnd::Marker | SegmentEnd::Exhausted),
583            "staged segment {id} does not end cleanly: {:?}",
584            scan.end
585        );
586    }
587    ensure!(
588        scanned == context.written,
589        "staged arena differs from the frames read from the source"
590    );
591    Ok(Staged {
592        frames: context.frames,
593    })
594}
595
596fn upgrade_schema(context: &mut StepContext<'_>) -> Result<()> {
597    let path = context.staging.join("store.json");
598    let mut schema: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
599    schema["version"] = STORE_SCHEMA_VERSION.into();
600    crate::core::durable_file::atomic_write(&path, &serde_json::to_vec_pretty(&schema)?)
601}
602
603/// Reads every committed frame of the source arena in order and appends it to a new
604/// format-4 arena. Payloads are opaque here: encrypted frames are carried as they are.
605fn rewrite_arena(context: &mut StepContext<'_>) -> Result<()> {
606    let base = context.source.join(ARENA_DIRECTORY);
607    let generation = crate::core::durable_file::arena_generation(&base)?;
608    let ids = crate::core::storage::list_segments(&generation)?;
609    ensure!(!ids.is_empty(), "the source arena has no segments");
610    // Frames are staged without a sync each; `finish` makes the arena durable once.
611    let directory = context.staging.join(ARENA_DIRECTORY);
612    std::fs::create_dir_all(&directory)?;
613    let mut target = StagedArena::new(&directory, None)?;
614    let mut failure: Option<anyhow::Error> = None;
615    for (n, id) in ids.iter().enumerate() {
616        let path = generation.join(format!("seg_{id}.bin"));
617        // SAFETY: a read-only mapping of a segment no one writes while the upgrade runs;
618        // nothing borrowed from it outlives this iteration.
619        let map = unsafe { Mmap::map(&File::open(&path)?)? };
620        let scan = scan::scan_segment(&map, n + 1 == ids.len(), |frame, payload| {
621            if failure.is_none() {
622                match target.write_slice(payload) {
623                    Ok(_) => {
624                        context.frames += 1;
625                        context.written.push((frame.raw_len, frame.crc));
626                    }
627                    Err(error) => failure = Some(error),
628                }
629            }
630        });
631        if let Some(error) = failure.take() {
632            return Err(error);
633        }
634        if let SegmentEnd::Corrupt { reason, .. } = scan.end {
635            bail!(
636                "{} is corrupt at byte {} ({reason})",
637                path.display(),
638                scan.end_offset
639            )
640        }
641    }
642    target.finish()?;
643    Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    /// Every registered step moves forward and names an artefact; the arena steps reach the
651    /// version this build writes.
652    #[test]
653    fn the_registry_is_consistent() {
654        let mut ids = std::collections::BTreeSet::new();
655        for migration in MIGRATIONS {
656            assert!(ids.insert(migration.step.id), "duplicate step id");
657            if migration.step.artefact == "arena" {
658                assert!(migration.step.from < migration.step.to);
659                assert_eq!(migration.step.to, STORAGE_FORMAT_CURRENT);
660            }
661        }
662        for from in super::super::STORAGE_FORMAT_OLDEST..STORAGE_FORMAT_CURRENT {
663            assert!(
664                MIGRATIONS
665                    .iter()
666                    .any(|m| m.step.artefact == "arena" && m.step.from == from),
667                "no arena step leaves format {from}"
668            );
669        }
670    }
671
672    #[test]
673    fn data_directory_paths_are_the_hex_of_the_name() {
674        let root = tempfile::tempdir().unwrap();
675        std::fs::create_dir_all(root.path().join("tenants/616c696365")).unwrap();
676        assert_eq!(tenant_dir(root.path(), "alice").unwrap(), "616c696365");
677        assert!(tenant_dir(root.path(), "bob").is_err());
678    }
679}