Skip to main content

plugmem_host/
error.rs

1//! Host-layer errors.
2
3use std::path::PathBuf;
4
5/// Every way the host layer can fail. Engine failures pass through as
6/// [`HostError::Engine`]; everything filesystem- or network-shaped is
7/// typed here.
8#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum HostError {
11    /// The database file is exclusively locked by another process (or
12    /// another handle in this process). One local database has one owner — open a
13    /// different file, or drop the other handle.
14    #[error("database at {} is locked by another process", path.display())]
15    Locked {
16        /// The database base path.
17        path: PathBuf,
18    },
19
20    /// A filesystem operation failed.
21    #[error("i/o on {}: {source}", path.display())]
22    Io {
23        /// The file the operation touched.
24        path: PathBuf,
25        /// The underlying error.
26        #[source]
27        source: std::io::Error,
28    },
29
30    /// There is no published snapshot generation to map: the database has
31    /// never been checkpointed, so a read-only open ([`crate::Database::
32    /// open_readonly`]) or a [`crate::Database::scrub`] has nothing to point
33    /// at. Open it read-write once and checkpoint, then retry.
34    ///
35    /// A *dirty journal* is not this error. A reader maps the published
36    /// generation and never reads the journal, which is snapshot isolation:
37    /// it answers as of the last checkpoint rather than refusing until you
38    /// take one.
39    #[error("database at {} has no published snapshot yet: checkpoint it once first", path.display())]
40    NeedsCheckpoint {
41        /// The database base path.
42        path: PathBuf,
43    },
44
45    /// The engine returned a typed error.
46    #[error(transparent)]
47    Engine(#[from] plugmem_core::Error),
48
49    /// The embedder transport or response was unusable (the message
50    /// names what exactly: status, dimension mismatch, malformed JSON).
51    #[error("embedder: {0}")]
52    Embed(String),
53
54    /// A long explicit reembed is already staging a replacement generation.
55    /// Writes fail immediately rather than waiting behind model/network work.
56    #[error("database is being reembedded; retry the write after it completes")]
57    ReembedBusy,
58}
59
60/// What to tell someone whose pool hit its ceiling.
61///
62/// Kept as one string in one place so the CLI, the MCP server and the Node
63/// binding say the same thing. The engine cannot say it: `plugmem-core` knows
64/// nothing about config files, and its message is therefore a bare byte count
65/// — true, and useless on its own.
66pub const MAX_BYTES_HINT: &str = "that ceiling is `max_bytes` (`[engine] max_bytes` in config.toml), \
67and it applies to each pool separately rather than to their sum. Its default is not a capacity \
68judgement — it is the figure that keeps every pool addressable where `usize` is 32 bits, so a \
69database written anywhere opens anywhere. Raising it costs exactly that portability: a 32-bit \
70host then refuses the file with a typed error instead of misreading it.";
71
72impl HostError {
73    /// The follow-up line for a pool that ran out of room, or `None` when this
74    /// error is something else.
75    ///
76    /// The number in the message is a setting; the setting has a name and one
77    /// specific trade-off. Callers that talk to a person should print this
78    /// after the error itself.
79    pub fn capacity_hint(&self) -> Option<&'static str> {
80        let Self::Engine(engine) = self else {
81            return None;
82        };
83        matches!(
84            engine,
85            plugmem_core::Error::CapacityExceeded { .. }
86                | plugmem_core::Error::Arena(plugmem_core::ArenaError::CapacityExceeded { .. })
87        )
88        .then_some(MAX_BYTES_HINT)
89    }
90
91    /// Shorthand for wrapping an I/O error with its path.
92    pub(crate) fn io(path: &std::path::Path, source: std::io::Error) -> Self {
93        Self::Io {
94            path: path.to_path_buf(),
95            source,
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn a_pool_ceiling_carries_its_follow_up_and_nothing_else_does() {
106        // The engine's own message for this is a bare byte count — true, and
107        // useless to somebody who does not know the number is a setting. Every
108        // surface prints this line after it, so it has to actually attach.
109        for engine in [
110            plugmem_core::Error::CapacityExceeded { what: "vectors" },
111            plugmem_core::Error::Arena(plugmem_core::ArenaError::CapacityExceeded {
112                max_bytes: 65_536,
113            }),
114        ] {
115            let hint = HostError::Engine(engine).capacity_hint();
116            assert_eq!(hint, Some(MAX_BYTES_HINT));
117            assert!(hint.unwrap().contains("max_bytes"));
118        }
119
120        // Anything else must not: a lock conflict followed by a lecture about
121        // pool sizing is worse than no follow-up at all.
122        assert_eq!(
123            HostError::Engine(plugmem_core::Error::TooLarge {
124                what: "text",
125                len: 9_000,
126                max: 4_096,
127            })
128            .capacity_hint(),
129            None
130        );
131        assert_eq!(HostError::Embed("no provider".into()).capacity_hint(), None);
132        assert_eq!(
133            HostError::Locked {
134                path: PathBuf::from("/tmp/m.plugmem"),
135            }
136            .capacity_hint(),
137            None
138        );
139    }
140}