Skip to main content

sparrowdb_common/
lib.rs

1/// Logical sequence number identifying a WAL record.
2#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3pub struct Lsn(pub u64);
4
5/// Physical page identifier within a file.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct PageId(pub u64);
8
9/// Transaction identifier.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct TxnId(pub u64);
12
13/// Node identifier: upper 16 bits = label_id, lower 48 bits = slot_id.
14#[derive(
15    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
16)]
17pub struct NodeId(pub u64);
18
19/// Edge identifier: monotonic u64 sourced from the active metapage.
20#[derive(
21    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
22)]
23pub struct EdgeId(pub u64);
24
25/// All errors that SparrowDB can return.
26#[derive(Debug)]
27pub enum Error {
28    Io(std::io::Error),
29    InvalidMagic,
30    ChecksumMismatch,
31    VersionMismatch,
32    NotFound,
33    AlreadyExists,
34    InvalidArgument(String),
35    Corruption(String),
36    OutOfMemory,
37    Unimplemented,
38    /// AEAD authentication tag verification failed — wrong key or corrupted ciphertext.
39    DecryptionFailed,
40    /// A write transaction is already active; only one writer is allowed at a time.
41    WriterBusy,
42    /// AEAD authentication tag rejected on page/WAL decrypt — signals that the
43    /// database was opened with the wrong encryption key (distinct from a
44    /// generic checksum error so callers can present a clear "wrong key" message).
45    EncryptionAuthFailed,
46    /// Two concurrent write transactions both modified the same node.
47    ///
48    /// The transaction that committed second is aborted to maintain consistency.
49    WriteWriteConflict {
50        node_id: u64,
51    },
52    /// The node has attached edges and cannot be deleted without removing them first.
53    NodeHasEdges {
54        node_id: u64,
55    },
56    /// The per-query deadline was exceeded before the query could complete.
57    ///
58    /// Returned by [`GraphDb::execute_with_timeout`] when the supplied
59    /// [`std::time::Duration`] expires during scan or traversal.
60    QueryTimeout,
61    /// A mutation or DDL statement was submitted to a read-only transaction.
62    ///
63    /// [`ReadTx::query`] only accepts read-only Cypher (`MATCH … RETURN`).
64    /// Use [`GraphDb::execute`] for `CREATE`, `MERGE`, `MATCH … SET`,
65    /// `MATCH … DELETE`, `CHECKPOINT`, and `OPTIMIZE`.
66    ReadOnly,
67    /// The configured per-query memory limit was exceeded during BFS expansion.
68    ///
69    /// Returned by the Phase 3 chunked pipeline when the frontier buffer
70    /// grows beyond the limit set via `EngineBuilder::with_memory_limit`.
71    /// Use a larger limit or restructure the query to reduce fan-out.
72    QueryMemoryExceeded,
73    /// `GraphDb::open`/`open_encrypted` could not acquire the exclusive
74    /// cross-process lock on the database root because another process
75    /// already holds it open (#524).
76    ///
77    /// Two processes each deriving `next_label_id` (and other catalog
78    /// counters) from their own in-memory state, with no coordination
79    /// between them, can both allocate the same id and permanently corrupt
80    /// `catalog.tlv` — `GraphDb::open` refuses to hand out a second handle
81    /// to the same root instead of risking that. The lock is released
82    /// automatically when the other process's handle is dropped or the
83    /// process exits (including on crash/kill), so retrying after the
84    /// holder closes its handle succeeds.
85    DatabaseLocked(String),
86}
87
88impl std::fmt::Display for Error {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Error::Io(e) => write!(f, "I/O error: {e}"),
92            Error::InvalidMagic => write!(f, "invalid magic bytes"),
93            Error::ChecksumMismatch => write!(f, "checksum mismatch"),
94            Error::VersionMismatch => write!(f, "version mismatch"),
95            Error::NotFound => write!(f, "not found"),
96            Error::AlreadyExists => write!(f, "already exists"),
97            Error::InvalidArgument(s) => write!(f, "invalid argument: {s}"),
98            Error::Corruption(s) => write!(f, "corruption: {s}"),
99            Error::OutOfMemory => write!(f, "out of memory"),
100            Error::Unimplemented => write!(f, "not yet implemented"),
101            Error::DecryptionFailed => write!(f, "decryption failed: wrong key or corrupted data"),
102            Error::WriterBusy => write!(f, "writer busy: a write transaction is already active"),
103            Error::EncryptionAuthFailed => write!(
104                f,
105                "encryption authentication failed: wrong key or corrupted ciphertext"
106            ),
107            Error::WriteWriteConflict { node_id } => write!(
108                f,
109                "write-write conflict on node {node_id}: another transaction modified this node"
110            ),
111            Error::NodeHasEdges { node_id } => write!(
112                f,
113                "node {node_id} has attached edges and cannot be deleted without removing them first"
114            ),
115            Error::QueryTimeout => write!(f, "query timeout: deadline exceeded"),
116            Error::ReadOnly => write!(
117                f,
118                "read-only transaction: mutation statements are not allowed in ReadTx::query"
119            ),
120            Error::QueryMemoryExceeded => write!(
121                f,
122                "query memory exceeded: BFS frontier exceeded the configured memory limit"
123            ),
124            Error::DatabaseLocked(path) => write!(
125                f,
126                "database locked: another process already has '{path}' open for writing. \
127                 SparrowDB allows only one open handle per database root at a time — close \
128                 the other process's connection (or wait for it to exit) and retry."
129            ),
130        }
131    }
132}
133
134impl std::error::Error for Error {
135    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
136        match self {
137            Error::Io(e) => Some(e),
138            _ => None,
139        }
140    }
141}
142
143impl From<std::io::Error> for Error {
144    fn from(e: std::io::Error) -> Self {
145        Error::Io(e)
146    }
147}
148
149/// Crate-wide result type.
150pub type Result<T> = std::result::Result<T, Error>;
151
152// ── Canonical column-ID derivation ───────────────────────────────────────────
153
154/// Derive a stable `u32` column ID from a property key name.
155///
156/// Uses FNV-1a 32-bit hash for deterministic, catalog-free mapping.
157/// This is the **single authoritative implementation** — both the storage
158/// layer and the execution engine must call this function so that the
159/// `col_id` written to disk and the `col_id` used at query time always agree.
160pub fn col_id_of(name: &str) -> u32 {
161    const FNV_PRIME: u32 = 16_777_619;
162    const OFFSET_BASIS: u32 = 2_166_136_261;
163    let mut hash = OFFSET_BASIS;
164    for byte in name.bytes() {
165        hash ^= byte as u32;
166        hash = hash.wrapping_mul(FNV_PRIME);
167    }
168    hash
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn page_id_roundtrip() {
177        let id = PageId(42);
178        assert_eq!(id.0, 42);
179    }
180
181    #[test]
182    fn lsn_ordering() {
183        assert!(Lsn(1) < Lsn(2));
184    }
185
186    #[test]
187    fn txn_id_copy() {
188        let t = TxnId(99);
189        let t2 = t;
190        assert_eq!(t, t2);
191    }
192
193    #[test]
194    fn node_id_packing_roundtrip() {
195        let label_id: u64 = 3;
196        let slot_id: u64 = 0x0000_BEEF_CAFE;
197        let packed = (label_id << 48) | (slot_id & 0x0000_FFFF_FFFF_FFFF);
198        let node = NodeId(packed);
199        let recovered_label = node.0 >> 48;
200        let recovered_slot = node.0 & 0x0000_FFFF_FFFF_FFFF;
201        assert_eq!(recovered_label, label_id);
202        assert_eq!(recovered_slot, slot_id);
203    }
204
205    #[test]
206    fn error_display() {
207        let e = Error::InvalidMagic;
208        assert!(!e.to_string().is_empty());
209    }
210}