plugmem_host/workspace.rs
1//! A workspace: many databases under one directory, addressed by name.
2//!
3//! **The default is one database.** Nothing here switches on by itself — a
4//! caller that opens a [`crate::Database`] at a path behaves exactly as it did
5//! before this module existed. A workspace is what you reach for when one
6//! process serves many independent memories (a chat per conversation, a
7//! database per tenant) and wants them to stay independent.
8//!
9//! The layout is deliberately mechanical:
10//!
11//! ```text
12//! <root>/registry.plugmem the registry — an ordinary plugmem database
13//! <root>/db/<name>.plugmem the databases themselves
14//! ```
15//!
16//! Two properties fall out of it, and both are the point:
17//!
18//! - **a name is not a path, and cannot become one.** [`DbName`] admits only
19//! `[a-z0-9][a-z0-9_-]*`, so `..`, `/`, a drive letter or an absolute path
20//! are not filtered out — they are unrepresentable. Resolution is then a
21//! join, with nothing to get wrong;
22//! - **the directory is the truth.** [`WorkspaceLayout::list`] reads the
23//! filesystem, never the registry. The registry (see the `registry` module)
24//! is a searchable index over descriptions and can be rebuilt from the
25//! databases themselves; losing it costs search, not data.
26//!
27//! The registry lives in the root while the databases live one level down, so
28//! no name can ever collide with it.
29
30mod registry;
31
32use std::fmt;
33use std::path::{Path, PathBuf};
34use std::sync::{Mutex, MutexGuard};
35
36use crate::{Database, HostError};
37
38pub use registry::{
39 ARCHIVED_TAG, DbEntry, Description, ENTRY_TAG, ReindexReport, SELF_ENTITY, WorkspaceIssue,
40};
41
42/// Longest database name a workspace accepts, in bytes.
43///
44/// Names are ASCII, so this is also the character count. The limit exists so a
45/// name plus the extension stays comfortably inside the shortest filename limit
46/// worth caring about (255 bytes on ext4/APFS/NTFS) with room for the sidecar
47/// suffixes (`.lock`, `.jrnl`, `.snap.N`) the storage layer appends.
48pub const MAX_DB_NAME: usize = 64;
49
50/// Directory, below the workspace root, holding the databases.
51const DB_DIR: &str = "db";
52
53/// The registry's file name, directly in the workspace root.
54const REGISTRY_FILE: &str = "registry.plugmem";
55
56/// Extension of a database file. The storage layer appends its sidecar
57/// suffixes *after* this (`chat-42.plugmem.lock`), so matching on the
58/// extension picks out base files and nothing else.
59const DB_EXT: &str = "plugmem";
60
61/// Names Windows resolves to devices rather than files, in every directory and
62/// **whatever extension is appended** — `con.plugmem` opens the console, not a
63/// file called `con.plugmem`.
64///
65/// Refused on every platform, not only on Windows. A workspace is a directory
66/// someone may copy between machines, and a memory that exists on Linux and
67/// silently becomes the printer port on Windows is the worst kind of portability
68/// bug: it appears at someone else's desk, on data that was already fine.
69///
70/// Written out rather than taken from a crate (`sanitize-filename` and friends
71/// exist) because those *rewrite* an arbitrary string into a safe filename,
72/// which is a different job: here the alphabet has already refused separators,
73/// dots, colons, `$`, control bytes, non-ASCII, uppercase and trailing spaces,
74/// and this list is the entire remainder. It is 22 strings, frozen by Windows
75/// for thirty years — `CONIN$` and the superscript `COM¹` forms cannot be
76/// spelled in the alphabet at all. A dependency would be carrying a sanitizer
77/// to do the part we already did.
78const RESERVED_DEVICE_NAMES: &[&str] = &[
79 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
80 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
81];
82
83/// Why a string is not a usable database name.
84///
85/// A typed reason rather than a message, so a caller (and a test) can react to
86/// the specific problem instead of matching on prose.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum NameProblem {
90 /// The name was empty.
91 Empty,
92 /// The name was longer than [`MAX_DB_NAME`].
93 TooLong,
94 /// The first character was neither a lowercase ASCII letter nor a digit.
95 /// Leading `-`, `_` and `.` are refused so a name can never be read as a
96 /// flag or as a relative path component.
97 LeadingChar,
98 /// Some character was outside `[a-z0-9_-]`.
99 Character,
100 /// The name is a Windows device name (`con`, `nul`, `com1`, …). Refused
101 /// everywhere so a workspace stays portable between machines.
102 ReservedDevice,
103}
104
105impl fmt::Display for NameProblem {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 match self {
108 Self::Empty => f.write_str("it is empty"),
109 Self::TooLong => write!(f, "it is longer than {MAX_DB_NAME} bytes"),
110 Self::LeadingChar => f.write_str("it must start with a lowercase letter or a digit"),
111 Self::Character => {
112 f.write_str("it may hold only lowercase letters, digits, '-' and '_'")
113 }
114 Self::ReservedDevice => f.write_str(
115 "it is a Windows device name, which would open a device rather than a file there",
116 ),
117 }
118 }
119}
120
121/// A validated database name — the only thing a workspace resolves.
122///
123/// Construct it with [`DbName::parse`]; there is no other way in, which is what
124/// makes "a name is not a path" a property of the type rather than a rule
125/// somebody has to remember at every call site.
126#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
127pub struct DbName(String);
128
129impl DbName {
130 /// Validates `s` as a database name.
131 ///
132 /// The rule: ASCII, first character `[a-z0-9]`, the rest `[a-z0-9_-]`,
133 /// length `1..=`[`MAX_DB_NAME`].
134 ///
135 /// # Errors
136 ///
137 /// [`WorkspaceError::BadName`] carrying the specific [`NameProblem`].
138 pub fn parse(s: &str) -> Result<Self, WorkspaceError> {
139 let bad = |why| {
140 Err(WorkspaceError::BadName {
141 name: s.to_string(),
142 why,
143 })
144 };
145 let Some(&first) = s.as_bytes().first() else {
146 return bad(NameProblem::Empty);
147 };
148 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
149 return bad(NameProblem::LeadingChar);
150 }
151 if !s.bytes().all(is_name_byte) {
152 return bad(NameProblem::Character);
153 }
154 if s.len() > MAX_DB_NAME {
155 return bad(NameProblem::TooLong);
156 }
157 if RESERVED_DEVICE_NAMES.contains(&s) {
158 return bad(NameProblem::ReservedDevice);
159 }
160 Ok(DbName(s.to_string()))
161 }
162
163 /// The name as a string slice.
164 pub fn as_str(&self) -> &str {
165 &self.0
166 }
167}
168
169impl fmt::Display for DbName {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 f.write_str(&self.0)
172 }
173}
174
175/// True for a byte allowed anywhere in a name.
176fn is_name_byte(b: u8) -> bool {
177 b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'
178}
179
180/// Every way a workspace operation can fail.
181///
182/// Opening one database still fails as a [`HostError`]; this type adds the
183/// failures that only exist once databases are addressed by name.
184#[derive(Debug, thiserror::Error)]
185#[non_exhaustive]
186pub enum WorkspaceError {
187 /// The string is not a usable database name.
188 #[error("{name:?} is not a usable database name: {why}")]
189 BadName {
190 /// The rejected string, as given.
191 name: String,
192 /// What specifically was wrong with it.
193 why: NameProblem,
194 },
195
196 /// No database of that name exists, and the caller asked not to create one.
197 #[error("no database named {name} in this workspace (looked for {})", path.display())]
198 NoSuchDatabase {
199 /// The name that did not resolve.
200 name: DbName,
201 /// Where it would have been.
202 path: PathBuf,
203 },
204
205 /// The database is open for writing elsewhere.
206 ///
207 /// Distinct from [`HostError::Locked`] so the message can name the database
208 /// rather than a path the caller never typed. One file has one writer, and
209 /// in a workspace the other writer is usually a long-running sidecar that
210 /// will release it once the handle goes idle.
211 #[error(
212 "database {name} is in use by another process; it is released once that process closes it (a pooled handle does so after its idle timeout)"
213 )]
214 Busy {
215 /// The database that is held elsewhere.
216 name: DbName,
217 },
218
219 /// A filesystem operation on the workspace itself failed.
220 #[error("i/o on {}: {source}", path.display())]
221 Io {
222 /// The path the operation touched.
223 path: PathBuf,
224 /// The underlying error.
225 #[source]
226 source: std::io::Error,
227 },
228
229 /// Opening or using one of the databases failed.
230 #[error(transparent)]
231 Host(#[from] HostError),
232}
233
234impl WorkspaceError {
235 /// Shorthand for wrapping an I/O error with its path.
236 pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
237 Self::Io {
238 path: path.to_path_buf(),
239 source,
240 }
241 }
242}
243
244/// Where a workspace keeps its files.
245///
246/// Pure path arithmetic and one directory listing — it opens nothing and locks
247/// nothing. A caller that only needs "which file is `work`?" (the CLI resolving
248/// `--db work`) wants this and not the handle pool.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub struct WorkspaceLayout {
251 root: PathBuf,
252}
253
254impl WorkspaceLayout {
255 /// A layout rooted at `root`. Creates nothing; the directories appear when
256 /// a database is first written.
257 pub fn new(root: impl Into<PathBuf>) -> Self {
258 Self { root: root.into() }
259 }
260
261 /// The workspace root.
262 pub fn root(&self) -> &Path {
263 &self.root
264 }
265
266 /// The directory holding the databases, `<root>/db`.
267 pub fn db_dir(&self) -> PathBuf {
268 self.root.join(DB_DIR)
269 }
270
271 /// The file backing `name`, `<root>/db/<name>.plugmem`.
272 ///
273 /// A join of validated components, so the result is always inside
274 /// [`WorkspaceLayout::db_dir`].
275 pub fn path_of(&self, name: &DbName) -> PathBuf {
276 self.db_dir().join(format!("{}.{DB_EXT}", name.0))
277 }
278
279 /// The registry's file, `<root>/registry.plugmem`. It sits in the root
280 /// rather than beside the databases, so no name can collide with it.
281 pub fn registry_path(&self) -> PathBuf {
282 self.root.join(REGISTRY_FILE)
283 }
284
285 /// Whether `name` is a database on disk.
286 ///
287 /// Asks the storage layer rather than stat-ing the path: a database that
288 /// has been written to but not yet checkpointed has no file at its base
289 /// path, and treating it as absent would mean creating over it.
290 pub fn exists(&self, name: &DbName) -> bool {
291 crate::storage::database_exists(&self.path_of(name))
292 }
293
294 /// Every database in the workspace, sorted by name.
295 ///
296 /// Reads the directory — the filesystem is the truth, the registry is only
297 /// an index over it. A missing `db/` is an empty workspace, not an error.
298 /// Files whose name is not a database's are skipped here and reported by
299 /// the registry's `verify`, which is where a person is asking about
300 /// consistency rather than about what they can open.
301 ///
302 /// One database is several files (`chat-42.plugmem`, `.journal`, `.lock`,
303 /// `.snap.N`), so the listing folds them back to one name each: a name
304 /// cannot contain a dot, so everything up to the first dot is the candidate,
305 /// and the storage layer confirms whether a database is really there.
306 ///
307 /// # Errors
308 ///
309 /// [`WorkspaceError::Io`] if the directory exists but cannot be read.
310 pub fn list(&self) -> Result<Vec<DbName>, WorkspaceError> {
311 let dir = self.db_dir();
312 let entries = match std::fs::read_dir(&dir) {
313 Ok(entries) => entries,
314 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
315 Err(e) => return Err(WorkspaceError::io(&dir, e)),
316 };
317 let mut names = Vec::new();
318 for entry in entries {
319 let entry = entry.map_err(|e| WorkspaceError::io(&dir, e))?;
320 let file_name = entry.file_name();
321 let Some(candidate) = file_name.to_str().and_then(|n| n.split('.').next()) else {
322 continue;
323 };
324 if let Ok(name) = DbName::parse(candidate)
325 && !names.contains(&name)
326 && self.exists(&name)
327 {
328 names.push(name);
329 }
330 }
331 names.sort_unstable();
332 Ok(names)
333 }
334}
335
336/// How many databases a workspace keeps open, and for how long.
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub struct WorkspaceLimits {
339 /// Most databases held open at once. The least recently used is closed to
340 /// make room. `0` is read as `1` — a pool that holds nothing would reopen
341 /// on every call.
342 pub max_open: usize,
343 /// How long a database may sit unused before [`Workspace::close_idle`]
344 /// closes it. `0` disables the sweep, so handles stay until evicted.
345 ///
346 /// This is not a memory knob, it is a *liveness* knob: an open writer holds
347 /// the file's exclusive lock, so nothing else on the machine can touch that
348 /// database until the handle goes. A long-running server that never let go
349 /// would make its databases permanently unreachable from the CLI.
350 pub idle_timeout_ms: u64,
351}
352
353/// Handles kept open by default. Small on purpose: reopening a database the
354/// size of a chat is milliseconds, so caching is a latency win, not a
355/// requirement, and every extra handle is a lock somebody else cannot take.
356pub const DEFAULT_MAX_OPEN: usize = 16;
357
358/// Default idle window before a pooled handle is closed.
359pub const DEFAULT_IDLE_TIMEOUT_MS: u64 = 60_000;
360
361/// Descriptors one open database occupies: its lock, its journal, the mapped
362/// generation, and room for the staging file a checkpoint writes.
363const FILES_PER_OPEN_DATABASE: usize = 4;
364
365/// The lowest per-process descriptor limit worth planning for — the usual POSIX
366/// soft limit.
367const ASSUMED_FD_LIMIT: usize = 1024;
368
369/// Descriptors left for the rest of the process: stdio, the config file, the
370/// registry, and the embedder's sockets.
371const RESERVED_FDS: usize = 64;
372
373/// Most databases a pool will hold open, whatever it is configured with.
374///
375/// Derived rather than picked: `max_open` can come from a config file, and a
376/// file must not be able to talk a process into running itself out of file
377/// descriptors. Exceeding this would not fail at the pool — it would fail at
378/// some unrelated `open` elsewhere in the program, which is the worst place to
379/// find out.
380pub const MAX_OPEN_CEILING: usize = (ASSUMED_FD_LIMIT - RESERVED_FDS) / FILES_PER_OPEN_DATABASE;
381
382const _: () = {
383 assert!(MAX_OPEN_CEILING > DEFAULT_MAX_OPEN);
384};
385
386impl Default for WorkspaceLimits {
387 fn default() -> Self {
388 Self {
389 max_open: DEFAULT_MAX_OPEN,
390 idle_timeout_ms: DEFAULT_IDLE_TIMEOUT_MS,
391 }
392 }
393}
394
395impl WorkspaceLimits {
396 /// The effective ceiling: at least one, never more than
397 /// [`MAX_OPEN_CEILING`]. Clamped here, in one place, because `max_open` can
398 /// arrive from a config file and neither end of the range is safe to trust.
399 pub fn ceiling(&self) -> usize {
400 self.max_open.clamp(1, MAX_OPEN_CEILING)
401 }
402}
403
404/// Whether resolving a name that has no file yet creates one.
405#[derive(Clone, Copy, Debug, PartialEq, Eq)]
406pub enum IfMissing {
407 /// Create the database. What makes a workspace usable without a
408 /// registration step: a new chat writes, and its memory exists.
409 Create,
410 /// Fail with [`WorkspaceError::NoSuchDatabase`]. What reads should do, so a
411 /// misspelled name is diagnosed instead of silently answering nothing.
412 Fail,
413}
414
415/// Opens one database at a path. Supplied by the caller so this module stays
416/// out of the business of configuration — [`crate::Settings`] builds one that
417/// applies the engine config, the maintenance policy and a shared embedder.
418pub type Opener = Box<dyn Fn(&Path) -> Result<Database, HostError> + Send + Sync>;
419
420/// One open database and when it was last handed out.
421struct Pooled {
422 name: DbName,
423 db: Database,
424 last_used_ms: u64,
425}
426
427/// Many databases in one directory, opened on demand and kept open for a while.
428///
429/// The pool is a flat `Vec` rather than a map: `max_open` is a handful, and a
430/// linear scan of a handful is cheaper than hashing — the same reason the
431/// engine's own structures stay flat.
432///
433/// **A returned [`Database`] outlives its pool entry.** The handle is an `Arc`,
434/// so eviction drops the pool's copy and nothing else; the file lock is
435/// released when the *last* clone goes. A caller that parks a handle for hours
436/// keeps the database locked for hours, whatever the idle timeout says. Hold
437/// one for the length of a request, as the MCP server does, and this never
438/// comes up.
439pub struct Workspace {
440 layout: WorkspaceLayout,
441 open: Opener,
442 limits: WorkspaceLimits,
443 pool: Mutex<Vec<Pooled>>,
444 registry: Mutex<Option<Database>>,
445}
446
447impl Workspace {
448 /// A workspace over `layout`, opening databases with `open`.
449 pub fn new(layout: WorkspaceLayout, open: Opener, limits: WorkspaceLimits) -> Self {
450 Self {
451 layout,
452 open,
453 limits,
454 pool: Mutex::new(Vec::new()),
455 registry: Mutex::new(None),
456 }
457 }
458
459 /// The registry database, opened on first use.
460 ///
461 /// Lazily, and that matters: a process that only ever resolves names it was
462 /// given never opens the registry, so it neither creates the file nor holds
463 /// a lock on it. The registry is a search index — a caller that is not
464 /// searching should not pay for it, and two processes that never search can
465 /// share one workspace without contending over it.
466 ///
467 /// It lives outside the handle pool because it is not one of the databases:
468 /// it has no [`DbName`], it is never evicted, and it is never handed out by
469 /// [`Workspace::get`].
470 ///
471 /// # Errors
472 ///
473 /// [`WorkspaceError::Io`] if the root cannot be created, or whatever the
474 /// open reports — including [`HostError::Locked`] if another process holds
475 /// the registry.
476 pub fn registry(&self) -> Result<Database, WorkspaceError> {
477 let mut slot = self.registry.lock().unwrap_or_else(|e| e.into_inner());
478 if let Some(db) = slot.as_ref() {
479 return Ok(db.clone());
480 }
481 let root = self.layout.root();
482 std::fs::create_dir_all(root).map_err(|e| WorkspaceError::io(root, e))?;
483 let db = (self.open)(&self.layout.registry_path())?;
484 *slot = Some(db.clone());
485 Ok(db)
486 }
487
488 /// Closes the registry handle, if one is open. Returns whether there was
489 /// one. The same liveness concern as [`Workspace::close_idle`]: a held
490 /// registry is a registry no other process can write.
491 pub fn close_registry(&self) -> bool {
492 self.registry
493 .lock()
494 .unwrap_or_else(|e| e.into_inner())
495 .take()
496 .is_some()
497 }
498
499 /// Where the files are.
500 pub fn layout(&self) -> &WorkspaceLayout {
501 &self.layout
502 }
503
504 /// The limits in force.
505 pub fn limits(&self) -> WorkspaceLimits {
506 self.limits
507 }
508
509 /// How many databases are open right now. Observability for tests and
510 /// `stats`; not a number to make decisions on, since it moves.
511 pub fn open_count(&self) -> usize {
512 self.pooled().len()
513 }
514
515 /// Resolves `name` to an open database, opening it if it is not pooled.
516 ///
517 /// `now_ms` is the host clock (unix milliseconds), used only for the idle
518 /// bookkeeping — it is passed in rather than read here for the same reason
519 /// every verb takes `now`: the host owns time.
520 ///
521 /// # Errors
522 ///
523 /// [`WorkspaceError::NoSuchDatabase`] when the file is absent and `missing`
524 /// is [`IfMissing::Fail`]; [`WorkspaceError::Busy`] when another process
525 /// holds the writer; [`WorkspaceError::Io`] if the directory cannot be
526 /// created; [`WorkspaceError::Host`] for anything the open itself rejects.
527 pub fn get(
528 &self,
529 name: &DbName,
530 now_ms: u64,
531 missing: IfMissing,
532 ) -> Result<Database, WorkspaceError> {
533 // The lock is held across the open, deliberately. Releasing it first
534 // would let two callers race to open the same file, and the loser would
535 // see a spurious `Busy` — from its own process. An open is bounded
536 // (the lock is tried, never waited on), so the cost is a short queue.
537 let mut pool = self.pooled();
538
539 if let Some(slot) = pool.iter_mut().find(|p| &p.name == name) {
540 slot.last_used_ms = now_ms;
541 return Ok(slot.db.clone());
542 }
543
544 let path = self.layout.path_of(name);
545 if !self.layout.exists(name) {
546 if missing == IfMissing::Fail {
547 return Err(WorkspaceError::NoSuchDatabase {
548 name: name.clone(),
549 path,
550 });
551 }
552 let dir = self.layout.db_dir();
553 std::fs::create_dir_all(&dir).map_err(|e| WorkspaceError::io(&dir, e))?;
554 }
555
556 // Make room *before* opening, so the ceiling counts this database too.
557 let ceiling = self.limits.ceiling();
558 while pool.len() >= ceiling {
559 let lru = Self::least_recently_used(&pool);
560 pool.remove(lru);
561 }
562
563 let db = (self.open)(&path).map_err(|e| match e {
564 HostError::Locked { .. } => WorkspaceError::Busy { name: name.clone() },
565 other => WorkspaceError::Host(other),
566 })?;
567 pool.push(Pooled {
568 name: name.clone(),
569 db: db.clone(),
570 last_used_ms: now_ms,
571 });
572 Ok(db)
573 }
574
575 /// Closes every database unused for longer than the idle timeout, returning
576 /// how many were closed. A no-op when the timeout is `0`.
577 ///
578 /// Call it on a timer. Nothing else releases a file lock a server is
579 /// holding on a database nobody is asking about.
580 pub fn close_idle(&self, now_ms: u64) -> usize {
581 let timeout = self.limits.idle_timeout_ms;
582 if timeout == 0 {
583 return 0;
584 }
585 let mut pool = self.pooled();
586 let before = pool.len();
587 // `saturating_sub`: a clock that stepped backwards leaves handles open
588 // rather than closing all of them at once.
589 pool.retain(|p| now_ms.saturating_sub(p.last_used_ms) < timeout);
590 before - pool.len()
591 }
592
593 /// Closes every open handle. The pool's copies, that is — see the note on
594 /// [`Workspace`] about clones the caller still holds.
595 pub fn close_all(&self) -> usize {
596 let mut pool = self.pooled();
597 std::mem::take(&mut *pool).len()
598 }
599
600 /// The pool guard. A panic in a verb cannot leave the pool half-updated
601 /// (every mutation here is a single push, remove or retain), so a poisoned
602 /// lock is recovered — the same rule the engine lock follows.
603 fn pooled(&self) -> MutexGuard<'_, Vec<Pooled>> {
604 self.pool.lock().unwrap_or_else(|e| e.into_inner())
605 }
606
607 /// Index of the least recently used entry. The pool is never empty here.
608 fn least_recently_used(pool: &[Pooled]) -> usize {
609 let mut oldest = 0;
610 for (i, p) in pool.iter().enumerate() {
611 if p.last_used_ms < pool[oldest].last_used_ms {
612 oldest = i;
613 }
614 }
615 oldest
616 }
617}
618
619impl fmt::Debug for Workspace {
620 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621 f.debug_struct("Workspace")
622 .field("root", &self.layout.root())
623 .field("limits", &self.limits)
624 .field("open", &self.open_count())
625 .finish()
626 }
627}
628
629/// Fixtures shared by this module's tests and the registry's.
630#[cfg(test)]
631pub(crate) mod testkit {
632 use std::path::PathBuf;
633 use std::sync::Arc;
634 use std::sync::atomic::{AtomicUsize, Ordering};
635
636 use super::{DbName, Opener, Workspace, WorkspaceLayout, WorkspaceLimits};
637 use crate::Database;
638
639 /// A unique temp directory; removed on drop.
640 pub(crate) struct TempDir(pub PathBuf);
641
642 impl TempDir {
643 pub(crate) fn new(tag: &str) -> Self {
644 let dir = std::env::temp_dir().join(format!(
645 "plugmem-workspace-{tag}-{}-{}",
646 std::process::id(),
647 std::time::SystemTime::now()
648 .duration_since(std::time::UNIX_EPOCH)
649 .unwrap()
650 .as_nanos()
651 ));
652 std::fs::create_dir_all(&dir).unwrap();
653 TempDir(dir)
654 }
655 }
656
657 impl Drop for TempDir {
658 fn drop(&mut self) {
659 let _ = std::fs::remove_dir_all(&self.0);
660 }
661 }
662
663 /// A workspace of plain default databases, plus a count of how many times
664 /// the opener actually ran — the only way to tell a pool hit from a reopen.
665 pub(crate) fn workspace(
666 tmp: &TempDir,
667 limits: WorkspaceLimits,
668 ) -> (Workspace, Arc<AtomicUsize>) {
669 let opens = Arc::new(AtomicUsize::new(0));
670 let counted = Arc::clone(&opens);
671 let open: Opener = Box::new(move |path: &std::path::Path| {
672 counted.fetch_add(1, Ordering::SeqCst);
673 Ok(Database::open(path, crate::Config::default())?.0)
674 });
675 (
676 Workspace::new(WorkspaceLayout::new(&tmp.0), open, limits),
677 opens,
678 )
679 }
680
681 /// A name that must parse.
682 pub(crate) fn name(s: &str) -> DbName {
683 DbName::parse(s).unwrap()
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use std::sync::atomic::Ordering;
690
691 use super::testkit::{TempDir, name, workspace};
692 use super::*;
693
694 fn problem(s: &str) -> NameProblem {
695 match DbName::parse(s) {
696 Err(WorkspaceError::BadName { why, .. }) => why,
697 other => panic!("expected {s:?} to be refused, got {other:?}"),
698 }
699 }
700
701 #[test]
702 fn a_name_admits_only_the_safe_alphabet() {
703 for ok in [
704 "a",
705 "0",
706 "chat-42",
707 "common",
708 "x_y-9",
709 &"a".repeat(MAX_DB_NAME),
710 ] {
711 assert_eq!(DbName::parse(ok).unwrap().as_str(), ok);
712 }
713
714 assert_eq!(problem(""), NameProblem::Empty);
715 assert_eq!(problem(&"a".repeat(MAX_DB_NAME + 1)), NameProblem::TooLong);
716
717 // A leading character that could be read as a flag, a path component or
718 // a hidden file is refused before anything else looks at the string.
719 // A non-ASCII name trips this same check, because its first *byte* is
720 // already outside the alphabet.
721 for bad in ["-x", "_x", ".x", "..", "/x", "Ab", "чат"] {
722 assert_eq!(problem(bad), NameProblem::LeadingChar, "{bad:?}");
723 }
724
725 // Path separators, dots, spaces, uppercase and non-ASCII are not
726 // filtered late — they simply are not names.
727 for bad in ["a/b", "a\\b", "a.b", "a b", "aB", "a:b", "aчат", "a\0b"] {
728 assert_eq!(problem(bad), NameProblem::Character, "{bad:?}");
729 }
730
731 // Windows resolves these to devices in every directory, extension or
732 // no extension, so `con.plugmem` is the console. Refused everywhere,
733 // because a workspace directory is a thing people copy between
734 // machines.
735 for bad in ["con", "nul", "prn", "aux", "com1", "lpt9"] {
736 assert_eq!(problem(bad), NameProblem::ReservedDevice, "{bad:?}");
737 }
738 // Only the exact names — a memory called `console` is fine.
739 for ok in ["console", "con1", "com0", "com10", "nula"] {
740 assert!(DbName::parse(ok).is_ok(), "{ok:?}");
741 }
742 }
743
744 #[test]
745 fn a_name_prints_as_itself() {
746 assert_eq!(DbName::parse("chat-42").unwrap().to_string(), "chat-42");
747 assert_eq!(NameProblem::Empty.to_string(), "it is empty");
748 assert_eq!(
749 NameProblem::TooLong.to_string(),
750 format!("it is longer than {MAX_DB_NAME} bytes")
751 );
752 assert!(NameProblem::LeadingChar.to_string().contains("start with"));
753 assert!(NameProblem::Character.to_string().contains("lowercase"));
754 assert!(
755 NameProblem::ReservedDevice
756 .to_string()
757 .contains("Windows device name")
758 );
759 }
760
761 #[test]
762 fn the_layout_puts_the_registry_out_of_reach_of_names() {
763 let layout = WorkspaceLayout::new("/ws");
764 let name = DbName::parse("chat-42").unwrap();
765
766 assert_eq!(layout.root(), Path::new("/ws"));
767 assert_eq!(layout.db_dir(), Path::new("/ws/db"));
768 assert_eq!(layout.path_of(&name), Path::new("/ws/db/chat-42.plugmem"));
769 assert_eq!(layout.registry_path(), Path::new("/ws/registry.plugmem"));
770
771 // The registry is one level above the databases, so no name — not even
772 // one spelled like the registry file — can resolve onto it.
773 let lookalike = DbName::parse("registry").unwrap();
774 assert_ne!(layout.path_of(&lookalike), layout.registry_path());
775 }
776
777 #[test]
778 fn listing_reads_the_directory_and_ignores_what_is_not_a_database() {
779 let tmp = TempDir::new("list");
780 let layout = WorkspaceLayout::new(&tmp.0);
781
782 // A workspace nobody has written to yet lists nothing rather than failing.
783 assert!(layout.list().unwrap().is_empty());
784
785 std::fs::create_dir_all(layout.db_dir()).unwrap();
786 for file in [
787 "chat-42.plugmem",
788 "common.plugmem",
789 // Sidecars of a database already counted: folded back to its name,
790 // not listed again.
791 "chat-42.plugmem.lock",
792 "chat-42.plugmem.journal",
793 "chat-42.plugmem.snap.3",
794 // Neither is an unrelated file, nor one whose stem is not a name.
795 "notes.txt",
796 "Chat-43.plugmem",
797 ] {
798 std::fs::write(layout.db_dir().join(file), b"").unwrap();
799 }
800
801 let names: Vec<String> = layout
802 .list()
803 .unwrap()
804 .iter()
805 .map(DbName::to_string)
806 .collect();
807 assert_eq!(names, ["chat-42", "common"]);
808
809 assert!(layout.exists(&DbName::parse("chat-42").unwrap()));
810 assert!(!layout.exists(&DbName::parse("nope").unwrap()));
811 }
812
813 #[test]
814 fn a_database_exists_before_its_first_checkpoint() {
815 let tmp = TempDir::new("list-uncheckpointed");
816 let layout = WorkspaceLayout::new(&tmp.0);
817 let fresh = DbName::parse("fresh").unwrap();
818 std::fs::create_dir_all(layout.db_dir()).unwrap();
819
820 // The base path holds the published snapshot, and that is written by
821 // the first checkpoint — so a brand-new database has a journal and no
822 // base file. Reporting it as absent would mean creating over live data.
823 let (db, _) = Database::open(layout.path_of(&fresh), crate::Config::default()).unwrap();
824 db.remember(crate::RememberInput::text(1_000, "not yet checkpointed"))
825 .unwrap();
826 assert!(!layout.path_of(&fresh).exists());
827 assert!(layout.exists(&fresh));
828 assert_eq!(layout.list().unwrap(), [fresh]);
829 }
830
831 #[test]
832 fn an_unreadable_directory_is_an_error_not_an_empty_workspace() {
833 let tmp = TempDir::new("list-io");
834 let layout = WorkspaceLayout::new(&tmp.0);
835 // `db` is a *file*, so reading it as a directory fails with something
836 // other than NotFound — the caller must hear about it.
837 std::fs::write(layout.db_dir(), b"not a directory").unwrap();
838 assert!(matches!(layout.list(), Err(WorkspaceError::Io { .. })));
839 }
840
841 #[test]
842 fn every_failure_names_what_the_caller_typed() {
843 let busy = WorkspaceError::Busy {
844 name: DbName::parse("chat-42").unwrap(),
845 };
846 assert!(busy.to_string().contains("chat-42"));
847
848 let host = WorkspaceError::from(HostError::Embed("no".into()));
849 assert!(matches!(host, WorkspaceError::Host(HostError::Embed(_))));
850
851 let io = WorkspaceError::io(
852 Path::new("/ws"),
853 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
854 );
855 assert!(io.to_string().contains("/ws"));
856
857 let missing = WorkspaceError::NoSuchDatabase {
858 name: DbName::parse("gone").unwrap(),
859 path: PathBuf::from("/ws/db/gone.plugmem"),
860 };
861 assert!(missing.to_string().contains("gone"));
862 }
863
864 #[test]
865 fn a_pooled_database_is_reused_and_a_missing_one_is_created_only_on_request() {
866 let tmp = TempDir::new("pool-reuse");
867 let (ws, opens) = workspace(&tmp, WorkspaceLimits::default());
868 let chat = name("chat-42");
869
870 // Reading a name nobody has written names the name, not a path the
871 // caller never typed.
872 let missed = ws.get(&chat, 1_000, IfMissing::Fail).unwrap_err();
873 assert!(
874 matches!(&missed, WorkspaceError::NoSuchDatabase { name, .. } if name == &chat),
875 "{missed}"
876 );
877 assert_eq!(opens.load(Ordering::SeqCst), 0);
878 assert!(!ws.layout().db_dir().exists());
879
880 // Writing creates it, directory and all.
881 let db = ws.get(&chat, 1_000, IfMissing::Create).unwrap();
882 db.remember(crate::RememberInput::text(1_000, "prefers tokio"))
883 .unwrap();
884 assert!(ws.layout().exists(&chat));
885 assert_eq!(ws.open_count(), 1);
886
887 // The second call is a pool hit: same state, no reopen.
888 let again = ws.get(&chat, 2_000, IfMissing::Fail).unwrap();
889 assert_eq!(again.stats().facts, 1);
890 assert_eq!(opens.load(Ordering::SeqCst), 1);
891
892 assert!(format!("{ws:?}").contains("open: 1"));
893 }
894
895 #[test]
896 fn databases_in_one_workspace_do_not_see_each_other() {
897 let tmp = TempDir::new("isolation");
898 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
899
900 for (db, text) in [
901 ("chat-42", "the sky is blue"),
902 ("chat-43", "the sky is red"),
903 ] {
904 ws.get(&name(db), 1_000, IfMissing::Create)
905 .unwrap()
906 .remember(crate::RememberInput::text(1_000, text))
907 .unwrap();
908 }
909
910 // The same query, the same fact id in each — and each answers only for
911 // itself. Ids are per database, which is why they can be plain `[f1]`.
912 for (db, expected) in [("chat-42", "blue"), ("chat-43", "red")] {
913 let out = ws
914 .get(&name(db), 2_000, IfMissing::Fail)
915 .unwrap()
916 .recall(crate::RecallQuery::text(2_000, "sky"))
917 .unwrap();
918 assert_eq!(out.facts.len(), 1, "{db}");
919 assert!(out.rendered.contains(expected), "{db}: {}", out.rendered);
920 }
921 }
922
923 #[test]
924 fn the_ceiling_evicts_the_least_recently_used() {
925 let tmp = TempDir::new("pool-evict");
926 let (ws, opens) = workspace(
927 &tmp,
928 WorkspaceLimits {
929 max_open: 2,
930 ..WorkspaceLimits::default()
931 },
932 );
933
934 // Three databases through a pool of two, touching `a` in between so it
935 // is `b` that is coldest when `c` arrives.
936 ws.get(&name("a"), 1_000, IfMissing::Create).unwrap();
937 ws.get(&name("b"), 2_000, IfMissing::Create).unwrap();
938 ws.get(&name("a"), 3_000, IfMissing::Fail).unwrap();
939 ws.get(&name("c"), 4_000, IfMissing::Create).unwrap();
940 assert_eq!(ws.open_count(), 2);
941 assert_eq!(opens.load(Ordering::SeqCst), 3);
942
943 // `a` is still pooled; `b` was evicted and has to be reopened.
944 ws.get(&name("a"), 5_000, IfMissing::Fail).unwrap();
945 assert_eq!(opens.load(Ordering::SeqCst), 3);
946 ws.get(&name("b"), 6_000, IfMissing::Fail).unwrap();
947 assert_eq!(opens.load(Ordering::SeqCst), 4);
948 }
949
950 #[test]
951 fn a_ceiling_of_zero_still_serves_one_database() {
952 let tmp = TempDir::new("pool-zero");
953 let (ws, opens) = workspace(
954 &tmp,
955 WorkspaceLimits {
956 max_open: 0,
957 idle_timeout_ms: 0,
958 },
959 );
960 ws.get(&name("a"), 1_000, IfMissing::Create).unwrap();
961 ws.get(&name("b"), 2_000, IfMissing::Create).unwrap();
962 assert_eq!(ws.open_count(), 1);
963 assert_eq!(opens.load(Ordering::SeqCst), 2);
964
965 // A zero timeout disables the sweep rather than closing everything.
966 assert_eq!(ws.close_idle(u64::MAX), 0);
967 assert_eq!(ws.open_count(), 1);
968 }
969
970 #[test]
971 fn an_idle_database_is_closed_and_its_lock_released() {
972 let tmp = TempDir::new("pool-idle");
973 let (ws, _) = workspace(
974 &tmp,
975 WorkspaceLimits {
976 max_open: 8,
977 idle_timeout_ms: 1_000,
978 },
979 );
980 let chat = name("chat-42");
981 let path = ws.layout().path_of(&chat);
982 drop(ws.get(&chat, 1_000, IfMissing::Create).unwrap());
983
984 // Inside the window nothing moves, and the file stays locked.
985 assert_eq!(ws.close_idle(1_500), 0);
986 assert!(matches!(
987 Database::open(&path, crate::Config::default()),
988 Err(HostError::Locked { .. })
989 ));
990
991 // A clock that stepped backwards must not close everything.
992 assert_eq!(ws.close_idle(500), 0);
993
994 // Past it, the handle goes and the database is reachable again — this
995 // is the whole point of the timeout, not memory.
996 assert_eq!(ws.close_idle(2_000), 1);
997 assert_eq!(ws.open_count(), 0);
998 assert!(Database::open(&path, crate::Config::default()).is_ok());
999 }
1000
1001 #[test]
1002 fn a_handle_held_by_a_caller_outlives_its_pool_entry() {
1003 let tmp = TempDir::new("pool-outlive");
1004 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1005 let chat = name("chat-42");
1006 let held = ws.get(&chat, 1_000, IfMissing::Create).unwrap();
1007 let path = ws.layout().path_of(&chat);
1008
1009 assert_eq!(ws.close_all(), 1);
1010 assert_eq!(ws.open_count(), 0);
1011
1012 // The pool let go; the caller did not, so the lock is still taken and
1013 // the handle still works. Documented on `Workspace`, checked here.
1014 held.remember(crate::RememberInput::text(2_000, "still mine"))
1015 .unwrap();
1016 assert!(matches!(
1017 Database::open(&path, crate::Config::default()),
1018 Err(HostError::Locked { .. })
1019 ));
1020 drop(held);
1021 assert!(Database::open(&path, crate::Config::default()).is_ok());
1022 }
1023
1024 #[test]
1025 fn a_database_held_by_another_process_is_reported_by_name() {
1026 let tmp = TempDir::new("pool-busy");
1027 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1028 let chat = name("chat-42");
1029 let path = ws.layout().path_of(&chat);
1030 std::fs::create_dir_all(ws.layout().db_dir()).unwrap();
1031
1032 // Stand in for the other process with a handle the pool knows nothing
1033 // about: the lock is what matters, not who holds it.
1034 let outsider = Database::open(&path, crate::Config::default()).unwrap().0;
1035 let e = ws.get(&chat, 1_000, IfMissing::Create).unwrap_err();
1036 assert!(matches!(&e, WorkspaceError::Busy { name } if name == &chat));
1037 assert!(e.to_string().contains("chat-42"), "{e}");
1038
1039 drop(outsider);
1040 assert!(ws.get(&chat, 2_000, IfMissing::Fail).is_ok());
1041 }
1042
1043 #[test]
1044 fn an_open_that_fails_for_another_reason_keeps_its_own_error() {
1045 let tmp = TempDir::new("pool-open-err");
1046 let open: Opener = Box::new(|_| Err(HostError::Embed("no provider".into())));
1047 let ws = Workspace::new(
1048 WorkspaceLayout::new(&tmp.0),
1049 open,
1050 WorkspaceLimits::default(),
1051 );
1052 let e = ws.get(&name("a"), 1_000, IfMissing::Create).unwrap_err();
1053 assert!(
1054 matches!(e, WorkspaceError::Host(HostError::Embed(_))),
1055 "{e}"
1056 );
1057 }
1058
1059 #[test]
1060 fn a_directory_that_cannot_be_created_is_an_error_not_a_panic() {
1061 let tmp = TempDir::new("pool-mkdir");
1062 // `db` is a file, so `create_dir_all` cannot make it a directory.
1063 std::fs::write(tmp.0.join(DB_DIR), b"in the way").unwrap();
1064 let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
1065 assert!(matches!(
1066 ws.get(&name("a"), 1_000, IfMissing::Create),
1067 Err(WorkspaceError::Io { .. })
1068 ));
1069 }
1070
1071 proptest::proptest! {
1072 /// The property the whole design rests on: whatever a caller sends,
1073 /// either it is refused, or the file it names sits directly inside
1074 /// `<root>/db` — one component, no traversal, no absolute path, no
1075 /// device name. Checked over arbitrary strings rather than a list of
1076 /// attacks somebody thought of.
1077 #[test]
1078 fn a_name_that_parses_can_only_resolve_inside_the_workspace(s in ".*") {
1079 let Ok(name) = DbName::parse(&s) else { return Ok(()) };
1080 let layout = WorkspaceLayout::new("/ws");
1081 let path = layout.path_of(&name);
1082
1083 let rest: Vec<_> = path
1084 .strip_prefix(layout.db_dir())
1085 .expect("resolved outside the workspace")
1086 .components()
1087 .collect();
1088 let expected = format!("{s}.{DB_EXT}");
1089 proptest::prop_assert_eq!(rest.len(), 1);
1090 proptest::prop_assert_eq!(
1091 path.file_name().and_then(|n| n.to_str()),
1092 Some(expected.as_str())
1093 );
1094 }
1095 }
1096}