Skip to main content

zeph_bench/
isolation.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::{Path, PathBuf};
5
6use crate::BenchError;
7
8/// Per-scenario storage isolation for benchmark runs.
9///
10/// Before each scenario starts, call [`reset`] to delete and recreate the
11/// bench-namespaced `SQLite` database so earlier scenario memories cannot
12/// contaminate later ones.
13///
14/// # Collection naming
15///
16/// The Qdrant collection name follows the pattern `bench_{dataset}_{run_id}`.
17/// Production collections (`zeph_memory`, `zeph_skills`, etc.) are never touched.
18///
19/// # Examples
20///
21/// ```
22/// use std::path::Path;
23/// use zeph_bench::BenchIsolation;
24///
25/// let iso = BenchIsolation::new("locomo", "run-2026-01-01", Path::new("/data/bench"));
26/// assert_eq!(iso.qdrant_collection, "bench_locomo_run-2026-01-01");
27/// assert!(iso.sqlite_db_path.ends_with("bench-run-2026-01-01.db"));
28/// ```
29///
30/// [`reset`]: BenchIsolation::reset
31pub struct BenchIsolation {
32    /// Qdrant collection name: `bench_{dataset}_{run_id}`.
33    pub qdrant_collection: String,
34    /// Absolute path to the bench-namespaced `SQLite` database.
35    pub sqlite_db_path: PathBuf,
36}
37
38impl BenchIsolation {
39    /// Create a new isolation context for a benchmark run.
40    ///
41    /// The Qdrant collection is named `bench_{dataset}_{run_id}` and the `SQLite`
42    /// database is placed at `{data_dir}/bench-{run_id}.db`.
43    ///
44    /// # Note
45    ///
46    /// `dataset` and `run_id` are not sanitized. Callers should use alphanumeric
47    /// values (plus hyphens/underscores) to ensure a valid Qdrant collection name
48    /// and a safe filesystem path component.
49    #[must_use]
50    pub fn new(dataset: &str, run_id: &str, data_dir: &Path) -> Self {
51        Self {
52            qdrant_collection: format!("bench_{dataset}_{run_id}"),
53            sqlite_db_path: data_dir.join(format!("bench-{run_id}.db")),
54        }
55    }
56
57    /// Reset isolation state for a fresh scenario run.
58    ///
59    /// Deletes the `SQLite` database file at [`sqlite_db_path`] if it exists, so
60    /// memories from a previous scenario cannot bleed into the next one.
61    ///
62    /// Qdrant isolation is currently a no-op: `zeph-bench` does not depend on
63    /// `qdrant-client`, so collection cleanup must be performed externally if
64    /// needed. The collection is overwritten on the next run anyway.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`BenchError::Io`] if the `SQLite` file exists but cannot be deleted.
69    ///
70    /// [`sqlite_db_path`]: BenchIsolation::sqlite_db_path
71    // The async signature is part of the public API (callers may await it alongside
72    // other futures). The body is synchronous because file deletion is O(1) and
73    // std::fs is sufficient without the tokio "fs" feature.
74    #[allow(clippy::unused_async)]
75    pub async fn reset(&self) -> Result<(), BenchError> {
76        if self.sqlite_db_path.exists() {
77            // Use std::fs (not tokio::fs) to avoid requiring the tokio "fs" feature.
78            std::fs::remove_file(&self.sqlite_db_path)?;
79        }
80        // Qdrant isolation requires the qdrant feature; currently a no-op.
81        Ok(())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use std::path::Path;
88
89    use super::*;
90
91    #[test]
92    fn collection_name_follows_bench_prefix() {
93        let iso = BenchIsolation::new("locomo", "run42", Path::new("/tmp"));
94        assert!(iso.qdrant_collection.starts_with("bench_"));
95        assert!(!iso.qdrant_collection.contains("zeph_memory"));
96        assert!(!iso.qdrant_collection.contains("zeph_skills"));
97        assert_eq!(iso.qdrant_collection, "bench_locomo_run42");
98    }
99
100    #[test]
101    fn sqlite_path_inside_data_dir() {
102        let iso = BenchIsolation::new("locomo", "run42", Path::new("/data"));
103        assert_eq!(iso.sqlite_db_path, Path::new("/data/bench-run42.db"));
104    }
105
106    #[tokio::test]
107    async fn reset_deletes_sqlite_file() {
108        let dir = tempfile::tempdir().unwrap();
109        let iso = BenchIsolation::new("test", "r1", dir.path());
110        std::fs::write(&iso.sqlite_db_path, b"data").unwrap();
111        iso.reset().await.unwrap();
112        assert!(!iso.sqlite_db_path.exists());
113    }
114
115    #[tokio::test]
116    async fn reset_succeeds_when_db_absent() {
117        let dir = tempfile::tempdir().unwrap();
118        let iso = BenchIsolation::new("test", "r1", dir.path());
119        // No file created — should not error.
120        iso.reset().await.unwrap();
121    }
122
123    /// Integration test: verifies the NFR-007 reset time budget.
124    #[tokio::test]
125    #[ignore = "slow integration test: verifies NFR-007 reset time budget"]
126    async fn reset_completes_under_2_seconds() {
127        let dir = tempfile::tempdir().unwrap();
128        let iso = BenchIsolation::new("test", "timing", dir.path());
129        std::fs::write(&iso.sqlite_db_path, b"data").unwrap();
130        let start = std::time::Instant::now();
131        iso.reset().await.unwrap();
132        assert!(start.elapsed().as_secs() < 2, "reset exceeded 2s NFR-007");
133    }
134}