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}