bison_db/store.rs
1//! The single-file document store: [`Db`].
2//!
3//! `bison-db` persists documents to one append-only file. Every write — insert,
4//! overwrite, or delete — appends a self-describing record to the tail; the file
5//! is never edited in place. An in-memory index maps each live document id to
6//! the byte offset of its most recent record, so a read is one hash lookup and
7//! one positional read. This log-structured design makes writes sequential
8//! (the pattern disks and SSDs serve fastest) and keeps a crash from corrupting
9//! data already on disk: a half-written record at the tail is detected by its
10//! length and checksum and dropped on the next open.
11//!
12//! ## Record framing
13//!
14//! The file opens with a fixed header (magic plus a format version), then a run
15//! of records. Each record is an 8-byte frame (`u32` payload length, `u32`
16//! CRC-32C of the payload) followed by the payload itself: a one-byte operation
17//! tag, the 8-byte document id, and — for an insert or overwrite — the encoded
18//! document body. A delete writes a tombstone with no body.
19//!
20//! ## Durability
21//!
22//! A record reaches the OS page cache as soon as it is written, so it is visible
23//! to later reads in the same process immediately. When it becomes durable
24//! against a power loss is governed by the store's [`SyncPolicy`]:
25//!
26//! - [`SyncPolicy::Always`] forces an `fsync` after every write, so each
27//! operation is durable the moment it returns.
28//! - [`SyncPolicy::Manual`] (the default) syncs only on [`Db::flush`] and once,
29//! best-effort, on drop. It is faster, and writes remain crash-*safe* — a torn
30//! write is never misread — but the most recent unsynced writes can be lost on
31//! power loss.
32//!
33//! Either way the on-disk invariant holds: a crash never tears a record that was
34//! already durable. On a newly created file, the parent directory is `fsync`ed
35//! so the file's existence is itself durable.
36
37use std::collections::HashMap;
38use std::fmt;
39use std::fs::{File, OpenOptions};
40use std::ops::RangeBounds;
41use std::path::{Path, PathBuf};
42
43use crate::codec::{crc32c, decode_document, encode_document_into};
44use crate::error::{Error, Result};
45use crate::index::{SecondaryIndex, in_bounds, total_cmp_value};
46use crate::sys::{read_exact_at, write_all_at};
47use crate::value::{Document, Value};
48
49/// The largest record payload the store will write or accept while reading.
50///
51/// A document encodes to at most this many bytes; a larger one is rejected with
52/// [`Error::ValueTooLarge`] on write. On read, any framed length above this cap
53/// is treated as corruption, which bounds the allocation the recovery path can
54/// be asked to make from a damaged file.
55pub const MAX_RECORD_BYTES: usize = 64 * 1024 * 1024;
56
57/// Magic bytes at the start of every store file. The trailing digit tracks the
58/// header layout, distinct from the format version that follows it.
59const HEADER_MAGIC: [u8; 8] = *b"BISONDB1";
60
61/// On-disk format version. Frozen at `1` as of v0.4.0: the layout described in
62/// `docs/FORMAT.md` is stable, and files written by 0.2.0 onward are readable by
63/// every later release. Bumped only on an incompatible record-layout change,
64/// which would be a major-version event.
65const FORMAT_VERSION: u16 = 1;
66
67/// Length of the file header: 8 magic bytes, a `u16` version, 6 reserved bytes.
68const HEADER_LEN: u64 = 16;
69
70/// Size of a record frame: a `u32` length followed by a `u32` checksum.
71const FRAME_LEN: usize = 8;
72
73/// Smallest legal payload: a one-byte op tag plus an 8-byte id, with no body
74/// (the shape of a delete tombstone).
75const MIN_PAYLOAD: usize = 1 + 8;
76
77/// Operation tag for an insert or overwrite: the payload carries a document body.
78const OP_PUT: u8 = 1;
79
80/// Operation tag for a delete: the payload is the op tag and id only.
81const OP_DELETE: u8 = 2;
82
83/// A document's primary key within a [`Db`].
84///
85/// Ids are assigned by [`Db::insert`] as a dense, monotonically increasing
86/// sequence starting at 1; `0` is never assigned and can be used as a sentinel.
87/// The id is stable for the life of the document and survives reopening the
88/// file. Reconstruct one with [`DocId::from`] when you have stored it elsewhere.
89///
90/// # Examples
91///
92/// ```
93/// use bison_db::DocId;
94/// let id = DocId::from(7);
95/// assert_eq!(id.get(), 7);
96/// assert_eq!(id.to_string(), "7");
97/// ```
98#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub struct DocId(u64);
100
101impl DocId {
102 /// Returns the underlying `u64`.
103 ///
104 /// # Examples
105 ///
106 /// ```
107 /// use bison_db::DocId;
108 /// assert_eq!(DocId::from(42).get(), 42);
109 /// ```
110 #[inline]
111 #[must_use]
112 pub const fn get(self) -> u64 {
113 self.0
114 }
115}
116
117impl From<u64> for DocId {
118 #[inline]
119 fn from(raw: u64) -> Self {
120 DocId(raw)
121 }
122}
123
124impl From<DocId> for u64 {
125 #[inline]
126 fn from(id: DocId) -> Self {
127 id.0
128 }
129}
130
131impl fmt::Display for DocId {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(f, "{}", self.0)
134 }
135}
136
137/// Where a live document's body sits in the file.
138#[derive(Clone, Copy)]
139struct BodyLoc {
140 /// Byte offset of the encoded document body.
141 offset: u64,
142 /// Length of the encoded document body in bytes.
143 len: u32,
144}
145
146/// A point-in-time summary of a store's size and contents.
147///
148/// Returned by [`Db::stats`]. The gap between `file_bytes` and `live_bytes`
149/// (plus framing) is space held by superseded and deleted records — the slack a
150/// future compaction step will reclaim.
151///
152/// # Examples
153///
154/// ```no_run
155/// # fn main() -> bison_db::Result<()> {
156/// let db = bison_db::Db::open("data.bison")?;
157/// let stats = db.stats();
158/// println!("{} live documents in {} bytes", stats.live_documents, stats.file_bytes);
159/// # Ok(())
160/// # }
161/// ```
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub struct Stats {
164 /// Number of documents currently readable.
165 pub live_documents: usize,
166 /// Total size of the file on disk, in bytes.
167 pub file_bytes: u64,
168 /// Bytes occupied by the bodies of live documents, excluding framing.
169 pub live_bytes: u64,
170}
171
172/// When a write is made durable on disk.
173///
174/// bison-db never holds writes in a userspace buffer — every write reaches the
175/// operating system immediately and is visible to later reads. This policy
176/// controls only when the store forces those bytes through the OS cache to the
177/// physical device with `fsync`, which is what protects them from a power loss.
178///
179/// # Examples
180///
181/// ```
182/// # fn main() -> bison_db::Result<()> {
183/// use bison_db::{DbOptions, SyncPolicy};
184/// # let path = std::env::temp_dir().join("bison_db_syncpolicy_doc.bison");
185/// # let _ = std::fs::remove_file(&path);
186/// // Durable per write, at the cost of an fsync on every insert/update/delete.
187/// let db = DbOptions::new().sync(SyncPolicy::Always).open(&path)?;
188/// # drop(db);
189/// # let _ = std::fs::remove_file(&path);
190/// # Ok(())
191/// # }
192/// ```
193#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
194pub enum SyncPolicy {
195 /// `fsync` after every write before it returns. Each insert, update, and
196 /// delete is durable the moment the call completes, at the cost of one
197 /// device sync per operation.
198 Always,
199 /// `fsync` only when [`Db::flush`] is called (and once, best-effort, when the
200 /// store is dropped). Writes are still crash-*safe* — a torn write is never
201 /// misread — but the most recent unsynced writes can be lost on power loss.
202 /// This is the default, and the fastest policy.
203 #[default]
204 Manual,
205}
206
207/// Options for opening a [`Db`], built fluently and finished with
208/// [`open`](DbOptions::open).
209///
210/// Use this when the default [`Db::open`] is not enough — currently, to choose a
211/// [`SyncPolicy`]. The set of options is intentionally small and will only grow
212/// additively.
213///
214/// # Examples
215///
216/// ```
217/// # fn main() -> bison_db::Result<()> {
218/// use bison_db::{DbOptions, SyncPolicy};
219/// # let path = std::env::temp_dir().join("bison_db_dboptions_doc.bison");
220/// # let _ = std::fs::remove_file(&path);
221/// let db = DbOptions::new().sync(SyncPolicy::Always).open(&path)?;
222/// assert_eq!(db.sync_policy(), SyncPolicy::Always);
223/// # drop(db);
224/// # let _ = std::fs::remove_file(&path);
225/// # Ok(())
226/// # }
227/// ```
228#[derive(Clone, Copy, Debug, Default)]
229pub struct DbOptions {
230 sync: SyncPolicy,
231}
232
233impl DbOptions {
234 /// Creates options with the defaults ([`SyncPolicy::Manual`]).
235 ///
236 /// # Examples
237 ///
238 /// ```
239 /// use bison_db::{DbOptions, SyncPolicy};
240 /// assert_eq!(DbOptions::new().build_sync_policy(), SyncPolicy::Manual);
241 /// ```
242 #[must_use]
243 pub fn new() -> Self {
244 DbOptions::default()
245 }
246
247 /// Sets the [`SyncPolicy`] for the store.
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// use bison_db::{DbOptions, SyncPolicy};
253 /// let opts = DbOptions::new().sync(SyncPolicy::Always);
254 /// assert_eq!(opts.build_sync_policy(), SyncPolicy::Always);
255 /// ```
256 #[must_use]
257 pub fn sync(mut self, policy: SyncPolicy) -> Self {
258 self.sync = policy;
259 self
260 }
261
262 /// Returns the [`SyncPolicy`] these options currently carry.
263 ///
264 /// # Examples
265 ///
266 /// ```
267 /// use bison_db::{DbOptions, SyncPolicy};
268 /// assert_eq!(DbOptions::new().build_sync_policy(), SyncPolicy::Manual);
269 /// ```
270 #[must_use]
271 pub fn build_sync_policy(&self) -> SyncPolicy {
272 self.sync
273 }
274
275 /// Opens (or creates) the store at `path` with these options.
276 ///
277 /// Equivalent to [`Db::open`] when the options are the defaults.
278 ///
279 /// # Errors
280 ///
281 /// Same as [`Db::open`].
282 ///
283 /// # Examples
284 ///
285 /// ```
286 /// # fn main() -> bison_db::Result<()> {
287 /// use bison_db::{DbOptions, SyncPolicy};
288 /// # let path = std::env::temp_dir().join("bison_db_dboptions_open_doc.bison");
289 /// # let _ = std::fs::remove_file(&path);
290 /// let db = DbOptions::new().sync(SyncPolicy::Always).open(&path)?;
291 /// # drop(db);
292 /// # let _ = std::fs::remove_file(&path);
293 /// # Ok(())
294 /// # }
295 /// ```
296 pub fn open<P: AsRef<Path>>(self, path: P) -> Result<Db> {
297 Db::open_inner(path.as_ref().to_path_buf(), self.sync)
298 }
299}
300
301/// An embedded document store backed by a single append-only file.
302///
303/// Open one with [`Db::open`], then [`insert`](Db::insert),
304/// [`get`](Db::get), [`update`](Db::update), and [`delete`](Db::delete)
305/// documents by id. Call [`flush`](Db::flush) to make recent writes durable, and
306/// [`compact`](Db::compact) to reclaim space left by overwrites and deletes.
307///
308/// # Concurrency
309///
310/// `Db` follows a single-writer, multi-reader model, like an embedded SQL
311/// engine: reads ([`get`](Db::get), [`find`](Db::find), [`range`](Db::range))
312/// take `&self`, while writes take `&mut self`. The compiler therefore enforces
313/// that writes are exclusive. `Db` is [`Send`] and [`Sync`], so the idiomatic
314/// way to share one across threads is an [`Arc`](std::sync::Arc)`<`[`RwLock`](std::sync::RwLock)`<Db>>`:
315/// many threads can read concurrently, and a writer takes the lock exclusively.
316/// The single-writer design is inherent to a single append-only file — there is
317/// one tail — and is the right fit for an embedded store.
318///
319/// # Examples
320///
321/// ```
322/// # fn main() -> bison_db::Result<()> {
323/// use bison_db::{Db, Document};
324///
325/// let dir = std::env::temp_dir().join("bison_db_doc_example");
326/// let _ = std::fs::remove_file(&dir);
327/// let mut db = Db::open(&dir)?;
328///
329/// let mut user = Document::new();
330/// user.set("name", "grace").set("born", 1906_i64);
331/// let id = db.insert(user)?;
332///
333/// let fetched = db.get(id)?.expect("just inserted");
334/// assert_eq!(fetched.get("name").and_then(|v| v.as_str()), Some("grace"));
335///
336/// db.flush()?;
337/// # let _ = std::fs::remove_file(&dir);
338/// # Ok(())
339/// # }
340/// ```
341pub struct Db {
342 /// The open store file, used for both positional reads and tail appends.
343 file: File,
344 /// Path the store was opened from, returned by [`Db::path`].
345 path: PathBuf,
346 /// Live document id to the location of its most recent body.
347 index: HashMap<u64, BodyLoc>,
348 /// Offset at which the next record will be appended.
349 tail: u64,
350 /// Id that the next [`Db::insert`] will assign.
351 next_id: u64,
352 /// Reusable buffer for framing a record, so writes do not allocate.
353 scratch: Vec<u8>,
354 /// Secondary indexes by field name, built on demand and maintained on every
355 /// write. Not persisted: rebuilt via [`Db::create_index`] each session.
356 indexes: HashMap<String, SecondaryIndex>,
357 /// When to force writes to disk with `fsync`.
358 sync: SyncPolicy,
359}
360
361impl Db {
362 /// Opens the store at `path`, creating an empty one if the file does not
363 /// exist, and replaying any existing records to rebuild the index.
364 ///
365 /// On open the whole log is scanned: each record's checksum is verified and
366 /// the in-memory index is reconstructed from the surviving inserts and
367 /// deletes. A record left half-written by a crash — detectable because it
368 /// runs past the end of the file or fails its checksum at the tail — is
369 /// truncated away, restoring the file to its last consistent state. A
370 /// checksum failure on a record that is *not* at the tail is reported as
371 /// [`Error::Corrupt`], because that indicates in-place damage rather than a
372 /// torn write.
373 ///
374 /// Uses [`SyncPolicy::Manual`]; for a different policy, open through
375 /// [`DbOptions`].
376 ///
377 /// # Errors
378 ///
379 /// Returns [`Error::Io`] if the file cannot be opened or read,
380 /// [`Error::BadMagic`] if an existing file is not a bison-db store,
381 /// [`Error::UnsupportedVersion`] if it was written by a newer format, and
382 /// [`Error::Corrupt`] if a non-tail record fails verification.
383 ///
384 /// # Examples
385 ///
386 /// ```
387 /// # fn main() -> bison_db::Result<()> {
388 /// let path = std::env::temp_dir().join("bison_db_open_example.bison");
389 /// let _ = std::fs::remove_file(&path);
390 /// let db = bison_db::Db::open(&path)?;
391 /// assert!(db.is_empty());
392 /// # let _ = std::fs::remove_file(&path);
393 /// # Ok(())
394 /// # }
395 /// ```
396 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
397 DbOptions::new().open(path)
398 }
399
400 /// Opens (or creates) the store at `path` with the given [`DbOptions`].
401 ///
402 /// A shorthand for [`DbOptions::open`]; see [`Db::open`] for the open and
403 /// recovery contract.
404 ///
405 /// # Errors
406 ///
407 /// Same as [`Db::open`].
408 ///
409 /// # Examples
410 ///
411 /// ```
412 /// # fn main() -> bison_db::Result<()> {
413 /// use bison_db::{Db, DbOptions, SyncPolicy};
414 /// # let path = std::env::temp_dir().join("bison_db_open_with_example.bison");
415 /// # let _ = std::fs::remove_file(&path);
416 /// let db = Db::open_with(&path, DbOptions::new().sync(SyncPolicy::Always))?;
417 /// assert_eq!(db.sync_policy(), SyncPolicy::Always);
418 /// # drop(db);
419 /// # let _ = std::fs::remove_file(&path);
420 /// # Ok(())
421 /// # }
422 /// ```
423 pub fn open_with<P: AsRef<Path>>(path: P, options: DbOptions) -> Result<Self> {
424 options.open(path)
425 }
426
427 /// The shared open path used by [`Db::open`] and [`DbOptions::open`].
428 fn open_inner(path: PathBuf, sync: SyncPolicy) -> Result<Self> {
429 // Remove any temporary file left behind by an interrupted compaction;
430 // the original store file is the authoritative copy.
431 let _ = std::fs::remove_file(compacting_path(&path));
432
433 let file = OpenOptions::new()
434 .read(true)
435 .write(true)
436 .create(true)
437 .truncate(false)
438 .open(&path)?;
439 let file_len = file.metadata()?.len();
440
441 let mut db = Db {
442 file,
443 path,
444 index: HashMap::new(),
445 tail: HEADER_LEN,
446 next_id: 1,
447 scratch: Vec::with_capacity(256),
448 indexes: HashMap::new(),
449 sync,
450 };
451
452 if file_len == 0 {
453 db.write_header()?;
454 // Make the newly created file's directory entry durable, so the file
455 // is guaranteed to exist after a crash that follows creation.
456 sync_parent_dir(&db.path)?;
457 } else {
458 db.verify_header(file_len)?;
459 db.replay(file_len)?;
460 }
461 Ok(db)
462 }
463
464 /// Returns the store's [`SyncPolicy`].
465 ///
466 /// # Examples
467 ///
468 /// ```
469 /// # fn main() -> bison_db::Result<()> {
470 /// use bison_db::{Db, SyncPolicy};
471 /// # let path = std::env::temp_dir().join("bison_db_syncpolicy_getter.bison");
472 /// # let _ = std::fs::remove_file(&path);
473 /// let db = Db::open(&path)?;
474 /// assert_eq!(db.sync_policy(), SyncPolicy::Manual);
475 /// # drop(db);
476 /// # let _ = std::fs::remove_file(&path);
477 /// # Ok(())
478 /// # }
479 /// ```
480 #[must_use]
481 pub fn sync_policy(&self) -> SyncPolicy {
482 self.sync
483 }
484
485 /// Inserts `doc`, assigning and returning a fresh [`DocId`].
486 ///
487 /// The document is appended to the log and indexed; it is readable
488 /// immediately and durable after the next [`flush`](Db::flush).
489 ///
490 /// # Errors
491 ///
492 /// Returns [`Error::ValueTooLarge`] if the encoded document exceeds
493 /// [`MAX_RECORD_BYTES`], or [`Error::Io`] if the append fails.
494 ///
495 /// # Examples
496 ///
497 /// ```
498 /// # fn main() -> bison_db::Result<()> {
499 /// # let path = std::env::temp_dir().join("bison_db_insert_example.bison");
500 /// # let _ = std::fs::remove_file(&path);
501 /// use bison_db::{Db, Document};
502 /// let mut db = Db::open(&path)?;
503 /// let mut doc = Document::new();
504 /// doc.set("k", "v");
505 /// let id = db.insert(doc)?;
506 /// assert!(db.contains(id));
507 /// # let _ = std::fs::remove_file(&path);
508 /// # Ok(())
509 /// # }
510 /// ```
511 pub fn insert(&mut self, doc: Document) -> Result<DocId> {
512 let id = self.next_id;
513 self.append(OP_PUT, id, Some(&doc))?;
514 self.next_id = id + 1;
515 self.index_add(id, &doc);
516 Ok(DocId(id))
517 }
518
519 /// Reads the document stored under `id`, or `None` if no live document has
520 /// that id.
521 ///
522 /// # Errors
523 ///
524 /// Returns [`Error::Io`] if the body cannot be read, or [`Error::Corrupt`]
525 /// if the stored bytes fail to decode (which a passing checksum makes
526 /// unexpected in practice).
527 ///
528 /// # Examples
529 ///
530 /// ```
531 /// # fn main() -> bison_db::Result<()> {
532 /// # let path = std::env::temp_dir().join("bison_db_get_example.bison");
533 /// # let _ = std::fs::remove_file(&path);
534 /// use bison_db::{Db, Document, DocId};
535 /// let mut db = Db::open(&path)?;
536 /// let id = db.insert({ let mut d = Document::new(); d.set("n", 1_i64); d })?;
537 /// assert!(db.get(id)?.is_some());
538 /// assert!(db.get(DocId::from(9999))?.is_none());
539 /// # let _ = std::fs::remove_file(&path);
540 /// # Ok(())
541 /// # }
542 /// ```
543 pub fn get(&self, id: DocId) -> Result<Option<Document>> {
544 match self.index.get(&id.0).copied() {
545 Some(loc) => self.read_body(loc).map(Some),
546 None => Ok(None),
547 }
548 }
549
550 /// Overwrites the document stored under `id` with `doc`, returning `true` if
551 /// a document was present to overwrite and `false` otherwise.
552 ///
553 /// A successful update appends a new record and repoints the index; the
554 /// previous body remains in the file as dead space until compaction.
555 ///
556 /// # Errors
557 ///
558 /// Returns [`Error::ValueTooLarge`] or [`Error::Io`] under the same
559 /// conditions as [`insert`](Db::insert).
560 ///
561 /// # Examples
562 ///
563 /// ```
564 /// # fn main() -> bison_db::Result<()> {
565 /// # let path = std::env::temp_dir().join("bison_db_update_example.bison");
566 /// # let _ = std::fs::remove_file(&path);
567 /// use bison_db::{Db, Document, DocId};
568 /// let mut db = Db::open(&path)?;
569 /// let id = db.insert({ let mut d = Document::new(); d.set("v", 1_i64); d })?;
570 ///
571 /// let mut next = Document::new();
572 /// next.set("v", 2_i64);
573 /// assert!(db.update(id, next)?);
574 /// assert!(!db.update(DocId::from(404), Document::new())?);
575 /// # let _ = std::fs::remove_file(&path);
576 /// # Ok(())
577 /// # }
578 /// ```
579 pub fn update(&mut self, id: DocId, doc: Document) -> Result<bool> {
580 let Some(loc) = self.index.get(&id.0).copied() else {
581 return Ok(false);
582 };
583 if !self.indexes.is_empty() {
584 let old = self.read_body(loc)?;
585 self.index_remove(id.0, &old);
586 }
587 self.append(OP_PUT, id.0, Some(&doc))?;
588 self.index_add(id.0, &doc);
589 Ok(true)
590 }
591
592 /// Deletes the document stored under `id`, returning `true` if one was
593 /// present and `false` otherwise.
594 ///
595 /// A tombstone is appended so the deletion survives reopening; the document
596 /// is unreadable as soon as this returns.
597 ///
598 /// # Errors
599 ///
600 /// Returns [`Error::Io`] if the tombstone cannot be appended.
601 ///
602 /// # Examples
603 ///
604 /// ```
605 /// # fn main() -> bison_db::Result<()> {
606 /// # let path = std::env::temp_dir().join("bison_db_delete_example.bison");
607 /// # let _ = std::fs::remove_file(&path);
608 /// use bison_db::{Db, Document};
609 /// let mut db = Db::open(&path)?;
610 /// let id = db.insert({ let mut d = Document::new(); d.set("x", 1_i64); d })?;
611 /// assert!(db.delete(id)?);
612 /// assert!(db.get(id)?.is_none());
613 /// assert!(!db.delete(id)?);
614 /// # let _ = std::fs::remove_file(&path);
615 /// # Ok(())
616 /// # }
617 /// ```
618 pub fn delete(&mut self, id: DocId) -> Result<bool> {
619 let Some(loc) = self.index.get(&id.0).copied() else {
620 return Ok(false);
621 };
622 if !self.indexes.is_empty() {
623 let old = self.read_body(loc)?;
624 self.index_remove(id.0, &old);
625 }
626 self.append(OP_DELETE, id.0, None)?;
627 Ok(true)
628 }
629
630 /// Returns `true` if a live document has this `id`.
631 ///
632 /// This is an in-memory index lookup with no file access.
633 ///
634 /// # Examples
635 ///
636 /// ```
637 /// # fn main() -> bison_db::Result<()> {
638 /// # let path = std::env::temp_dir().join("bison_db_contains_example.bison");
639 /// # let _ = std::fs::remove_file(&path);
640 /// use bison_db::{Db, Document};
641 /// let mut db = Db::open(&path)?;
642 /// let id = db.insert(Document::new())?;
643 /// assert!(db.contains(id));
644 /// # let _ = std::fs::remove_file(&path);
645 /// # Ok(())
646 /// # }
647 /// ```
648 #[must_use]
649 pub fn contains(&self, id: DocId) -> bool {
650 self.index.contains_key(&id.0)
651 }
652
653 /// Returns the number of live documents.
654 ///
655 /// # Examples
656 ///
657 /// ```
658 /// # fn main() -> bison_db::Result<()> {
659 /// # let path = std::env::temp_dir().join("bison_db_len_example.bison");
660 /// # let _ = std::fs::remove_file(&path);
661 /// use bison_db::{Db, Document};
662 /// let mut db = Db::open(&path)?;
663 /// db.insert(Document::new())?;
664 /// assert_eq!(db.len(), 1);
665 /// # let _ = std::fs::remove_file(&path);
666 /// # Ok(())
667 /// # }
668 /// ```
669 #[must_use]
670 pub fn len(&self) -> usize {
671 self.index.len()
672 }
673
674 /// Returns `true` if the store holds no live documents.
675 ///
676 /// # Examples
677 ///
678 /// ```
679 /// # fn main() -> bison_db::Result<()> {
680 /// # let path = std::env::temp_dir().join("bison_db_isempty_example.bison");
681 /// # let _ = std::fs::remove_file(&path);
682 /// let db = bison_db::Db::open(&path)?;
683 /// assert!(db.is_empty());
684 /// # let _ = std::fs::remove_file(&path);
685 /// # Ok(())
686 /// # }
687 /// ```
688 #[must_use]
689 pub fn is_empty(&self) -> bool {
690 self.index.is_empty()
691 }
692
693 /// Returns an iterator over the ids of all live documents.
694 ///
695 /// The order is unspecified and may change between runs; collect and sort if
696 /// you need a stable order.
697 ///
698 /// # Examples
699 ///
700 /// ```
701 /// # fn main() -> bison_db::Result<()> {
702 /// # let path = std::env::temp_dir().join("bison_db_ids_example.bison");
703 /// # let _ = std::fs::remove_file(&path);
704 /// use bison_db::{Db, Document};
705 /// let mut db = Db::open(&path)?;
706 /// db.insert(Document::new())?;
707 /// db.insert(Document::new())?;
708 /// assert_eq!(db.ids().count(), 2);
709 /// # let _ = std::fs::remove_file(&path);
710 /// # Ok(())
711 /// # }
712 /// ```
713 pub fn ids(&self) -> impl Iterator<Item = DocId> + '_ {
714 self.index.keys().copied().map(DocId)
715 }
716
717 /// Flushes buffered writes and `fsync`s the file, making every preceding
718 /// write durable against power loss.
719 ///
720 /// # Errors
721 ///
722 /// Returns [`Error::Io`] if the sync fails.
723 ///
724 /// # Examples
725 ///
726 /// ```
727 /// # fn main() -> bison_db::Result<()> {
728 /// # let path = std::env::temp_dir().join("bison_db_flush_example.bison");
729 /// # let _ = std::fs::remove_file(&path);
730 /// use bison_db::{Db, Document};
731 /// let mut db = Db::open(&path)?;
732 /// db.insert(Document::new())?;
733 /// db.flush()?;
734 /// # let _ = std::fs::remove_file(&path);
735 /// # Ok(())
736 /// # }
737 /// ```
738 pub fn flush(&mut self) -> Result<()> {
739 self.file.sync_all()?;
740 Ok(())
741 }
742
743 /// Returns the path the store was opened from.
744 ///
745 /// # Examples
746 ///
747 /// ```
748 /// # fn main() -> bison_db::Result<()> {
749 /// # let path = std::env::temp_dir().join("bison_db_path_example.bison");
750 /// # let _ = std::fs::remove_file(&path);
751 /// let db = bison_db::Db::open(&path)?;
752 /// assert_eq!(db.path(), path.as_path());
753 /// # let _ = std::fs::remove_file(&path);
754 /// # Ok(())
755 /// # }
756 /// ```
757 #[must_use]
758 pub fn path(&self) -> &Path {
759 &self.path
760 }
761
762 /// Returns a [`Stats`] snapshot of the store's size and live contents.
763 ///
764 /// # Examples
765 ///
766 /// ```
767 /// # fn main() -> bison_db::Result<()> {
768 /// # let path = std::env::temp_dir().join("bison_db_stats_example.bison");
769 /// # let _ = std::fs::remove_file(&path);
770 /// use bison_db::{Db, Document};
771 /// let mut db = Db::open(&path)?;
772 /// db.insert(Document::new())?;
773 /// assert_eq!(db.stats().live_documents, 1);
774 /// # let _ = std::fs::remove_file(&path);
775 /// # Ok(())
776 /// # }
777 /// ```
778 #[must_use]
779 pub fn stats(&self) -> Stats {
780 let live_bytes = self.index.values().map(|loc| u64::from(loc.len)).sum();
781 Stats {
782 live_documents: self.index.len(),
783 file_bytes: self.tail,
784 live_bytes,
785 }
786 }
787
788 /// Rewrites the file to contain only live documents, reclaiming the space
789 /// held by superseded and deleted records.
790 ///
791 /// Every overwrite and delete leaves dead bytes behind in the append-only
792 /// log; over time the file grows past the size of its live data. Compaction
793 /// writes a fresh copy containing one current record per live document, then
794 /// atomically swaps it in. Document ids are preserved, so existing
795 /// [`DocId`]s and secondary indexes remain valid; only the on-disk layout
796 /// changes.
797 ///
798 /// The compacted copy is built in a sibling temporary file and put in place
799 /// with an atomic rename, so a crash at any point leaves either the original
800 /// file or the fully compacted one — never a partial result. A leftover
801 /// temporary from an interrupted compaction is cleaned up on the next
802 /// [`open`](Db::open).
803 ///
804 /// Compaction is durable on return regardless of [`SyncPolicy`]: the new file
805 /// is `fsync`ed before the swap.
806 ///
807 /// # Errors
808 ///
809 /// Returns [`Error::Io`] if the temporary file cannot be written or swapped
810 /// in, or [`Error::Corrupt`] if a live record cannot be read back.
811 ///
812 /// # Examples
813 ///
814 /// ```
815 /// # fn main() -> bison_db::Result<()> {
816 /// # let path = std::env::temp_dir().join("bison_db_compact_example.bison");
817 /// # let _ = std::fs::remove_file(&path);
818 /// use bison_db::{Db, Document};
819 /// let mut db = Db::open(&path)?;
820 /// let id = db.insert({ let mut d = Document::new(); d.set("v", 1_i64); d })?;
821 /// for n in 2..1000 {
822 /// db.update(id, { let mut d = Document::new(); d.set("v", n); d })?;
823 /// }
824 /// let before = db.stats().file_bytes;
825 ///
826 /// db.compact()?; // collapse 999 versions down to one live record
827 ///
828 /// assert!(db.stats().file_bytes < before);
829 /// assert_eq!(db.get(id)?.unwrap().get("v").and_then(|v| v.as_int()), Some(999));
830 /// # let _ = std::fs::remove_file(&path);
831 /// # Ok(())
832 /// # }
833 /// ```
834 pub fn compact(&mut self) -> Result<()> {
835 let temp_path = compacting_path(&self.path);
836 let _ = std::fs::remove_file(&temp_path);
837
838 let temp = OpenOptions::new()
839 .read(true)
840 .write(true)
841 .create_new(true)
842 .open(&temp_path)?;
843 write_header_to(&temp)?;
844
845 let mut new_index = HashMap::with_capacity(self.index.len());
846 let mut tail = HEADER_LEN;
847 let mut body = Vec::new();
848 let mut frame = Vec::new();
849
850 for (&id, &loc) in &self.index {
851 body.resize(loc.len as usize, 0);
852 read_exact_at(&self.file, &mut body, loc.offset)?;
853
854 frame.clear();
855 frame.extend_from_slice(&[0u8; FRAME_LEN]);
856 frame.push(OP_PUT);
857 frame.extend_from_slice(&id.to_le_bytes());
858 frame.extend_from_slice(&body);
859 let payload_len = frame.len() - FRAME_LEN;
860 let crc = crc32c(&frame[FRAME_LEN..]);
861 frame[0..4].copy_from_slice(&(payload_len as u32).to_le_bytes());
862 frame[4..8].copy_from_slice(&crc.to_le_bytes());
863
864 write_all_at(&temp, &frame, tail)?;
865 let offset = tail + FRAME_LEN as u64 + MIN_PAYLOAD as u64;
866 let _ = new_index.insert(
867 id,
868 BodyLoc {
869 offset,
870 len: loc.len,
871 },
872 );
873 tail += (FRAME_LEN + payload_len) as u64;
874 }
875
876 temp.sync_all()?;
877 drop(temp);
878
879 // Re-point our handle at the temporary file, which closes the original
880 // file so the rename can replace it on every platform. The temporary is
881 // open with share-delete, so renaming it while open is permitted.
882 self.file = OpenOptions::new().read(true).write(true).open(&temp_path)?;
883 std::fs::rename(&temp_path, &self.path)?;
884 self.file = OpenOptions::new().read(true).write(true).open(&self.path)?;
885 sync_parent_dir(&self.path)?;
886
887 self.index = new_index;
888 self.tail = tail;
889 Ok(())
890 }
891
892 /// Builds a secondary index over `field`, making [`find`](Db::find) and
893 /// [`range`](Db::range) on that field fast point and range lookups instead of
894 /// full scans.
895 ///
896 /// The index is built by reading every live document once and recording its
897 /// value for `field`; documents without the field are skipped. From then on,
898 /// it is maintained automatically on every insert, update, and delete. Any
899 /// number of fields may be indexed — call this once per field.
900 ///
901 /// Indexes live in memory only and are **not** persisted: after reopening a
902 /// store, call this again for each field you want indexed. Calling it for a
903 /// field that is already indexed is a no-op.
904 ///
905 /// # Errors
906 ///
907 /// Returns [`Error::Io`] or [`Error::Corrupt`] if a document cannot be read
908 /// while building the index.
909 ///
910 /// # Examples
911 ///
912 /// ```
913 /// # fn main() -> bison_db::Result<()> {
914 /// # let path = std::env::temp_dir().join("bison_db_createindex_example.bison");
915 /// # let _ = std::fs::remove_file(&path);
916 /// use bison_db::{Db, Document, Value};
917 /// let mut db = Db::open(&path)?;
918 /// db.insert({ let mut d = Document::new(); d.set("city", "Oslo"); d })?;
919 ///
920 /// db.create_index("city")?;
921 /// let hits = db.find("city", &Value::from("Oslo"))?;
922 /// assert_eq!(hits.len(), 1);
923 /// # let _ = std::fs::remove_file(&path);
924 /// # Ok(())
925 /// # }
926 /// ```
927 pub fn create_index(&mut self, field: &str) -> Result<()> {
928 if self.indexes.contains_key(field) {
929 return Ok(());
930 }
931 let mut index = SecondaryIndex::new();
932 let entries: Vec<(u64, BodyLoc)> = self.index.iter().map(|(id, loc)| (*id, *loc)).collect();
933 for (id, loc) in entries {
934 let doc = self.read_body(loc)?;
935 if let Some(value) = doc.get(field) {
936 index.add(value, id);
937 }
938 }
939 let _ = self.indexes.insert(field.to_string(), index);
940 Ok(())
941 }
942
943 /// Drops the secondary index over `field`, returning `true` if one existed.
944 ///
945 /// # Examples
946 ///
947 /// ```
948 /// # fn main() -> bison_db::Result<()> {
949 /// # let path = std::env::temp_dir().join("bison_db_dropindex_example.bison");
950 /// # let _ = std::fs::remove_file(&path);
951 /// let mut db = bison_db::Db::open(&path)?;
952 /// db.create_index("name")?;
953 /// assert!(db.drop_index("name"));
954 /// assert!(!db.drop_index("name"));
955 /// # let _ = std::fs::remove_file(&path);
956 /// # Ok(())
957 /// # }
958 /// ```
959 pub fn drop_index(&mut self, field: &str) -> bool {
960 self.indexes.remove(field).is_some()
961 }
962
963 /// Returns an iterator over the names of the currently indexed fields.
964 ///
965 /// The order is unspecified.
966 ///
967 /// # Examples
968 ///
969 /// ```
970 /// # fn main() -> bison_db::Result<()> {
971 /// # let path = std::env::temp_dir().join("bison_db_indexes_example.bison");
972 /// # let _ = std::fs::remove_file(&path);
973 /// let mut db = bison_db::Db::open(&path)?;
974 /// db.create_index("a")?;
975 /// db.create_index("b")?;
976 /// assert_eq!(db.indexes().count(), 2);
977 /// # let _ = std::fs::remove_file(&path);
978 /// # Ok(())
979 /// # }
980 /// ```
981 pub fn indexes(&self) -> impl Iterator<Item = &str> {
982 self.indexes.keys().map(String::as_str)
983 }
984
985 /// Returns the ids of all live documents whose `field` equals `value`.
986 ///
987 /// If `field` is indexed (see [`create_index`](Db::create_index)) this is a
988 /// point lookup; otherwise it falls back to scanning every live document, so
989 /// the result is correct either way — the index only changes the speed.
990 /// Equality follows the same total order the indexes use, so a `Float` field
991 /// distinguishes `0.0` from `-0.0`.
992 ///
993 /// # Errors
994 ///
995 /// Returns [`Error::Io`] or [`Error::Corrupt`] if a document must be read
996 /// (the unindexed path) and cannot be.
997 ///
998 /// # Examples
999 ///
1000 /// ```
1001 /// # fn main() -> bison_db::Result<()> {
1002 /// # let path = std::env::temp_dir().join("bison_db_find_example.bison");
1003 /// # let _ = std::fs::remove_file(&path);
1004 /// use bison_db::{Db, Document, Value};
1005 /// let mut db = Db::open(&path)?;
1006 /// db.insert({ let mut d = Document::new(); d.set("role", "admin"); d })?;
1007 /// db.insert({ let mut d = Document::new(); d.set("role", "user"); d })?;
1008 /// db.create_index("role")?;
1009 ///
1010 /// assert_eq!(db.find("role", &Value::from("admin"))?.len(), 1);
1011 /// assert!(db.find("role", &Value::from("ghost"))?.is_empty());
1012 /// # let _ = std::fs::remove_file(&path);
1013 /// # Ok(())
1014 /// # }
1015 /// ```
1016 pub fn find(&self, field: &str, value: &Value) -> Result<Vec<DocId>> {
1017 if let Some(index) = self.indexes.get(field) {
1018 return Ok(index.equal(value).into_iter().map(DocId).collect());
1019 }
1020 let mut out = Vec::new();
1021 for (id, loc) in &self.index {
1022 let doc = self.read_body(*loc)?;
1023 if doc
1024 .get(field)
1025 .is_some_and(|v| total_cmp_value(v, value) == core::cmp::Ordering::Equal)
1026 {
1027 out.push(DocId(*id));
1028 }
1029 }
1030 Ok(out)
1031 }
1032
1033 /// Returns the ids of all live documents whose `field` falls within `range`.
1034 ///
1035 /// Bounds are [`Value`]s compared with the same total order the indexes use;
1036 /// any [`RangeBounds`] form works (`a..b`, `a..=b`, `..b`, `a..`, `..`).
1037 /// If `field` is indexed the matches come back ordered by field value (then
1038 /// id); otherwise the store scans every live document. As with
1039 /// [`find`](Db::find), the index changes only the speed, not the result.
1040 ///
1041 /// # Errors
1042 ///
1043 /// Returns [`Error::Io`] or [`Error::Corrupt`] if a document must be read
1044 /// (the unindexed path) and cannot be.
1045 ///
1046 /// # Examples
1047 ///
1048 /// ```
1049 /// # fn main() -> bison_db::Result<()> {
1050 /// # let path = std::env::temp_dir().join("bison_db_range_example.bison");
1051 /// # let _ = std::fs::remove_file(&path);
1052 /// use bison_db::{Db, Document, Value};
1053 /// let mut db = Db::open(&path)?;
1054 /// for age in [17_i64, 25, 40, 70] {
1055 /// db.insert({ let mut d = Document::new(); d.set("age", age); d })?;
1056 /// }
1057 /// db.create_index("age")?;
1058 ///
1059 /// // Working-age adults: 18..=65.
1060 /// let hits = db.range("age", Value::from(18_i64)..=Value::from(65_i64))?;
1061 /// assert_eq!(hits.len(), 2); // 25 and 40
1062 /// # let _ = std::fs::remove_file(&path);
1063 /// # Ok(())
1064 /// # }
1065 /// ```
1066 pub fn range<R: RangeBounds<Value>>(&self, field: &str, range: R) -> Result<Vec<DocId>> {
1067 let lo = range.start_bound();
1068 let hi = range.end_bound();
1069 if let Some(index) = self.indexes.get(field) {
1070 return Ok(index.range(lo, hi).into_iter().map(DocId).collect());
1071 }
1072 let mut out = Vec::new();
1073 for (id, loc) in &self.index {
1074 let doc = self.read_body(*loc)?;
1075 if doc.get(field).is_some_and(|v| in_bounds(v, lo, hi)) {
1076 out.push(DocId(*id));
1077 }
1078 }
1079 Ok(out)
1080 }
1081
1082 /// Reads and decodes the document body at `loc`.
1083 fn read_body(&self, loc: BodyLoc) -> Result<Document> {
1084 let mut buf = vec![0u8; loc.len as usize];
1085 read_exact_at(&self.file, &mut buf, loc.offset)?;
1086 decode_document(&buf)
1087 }
1088
1089 /// Adds document `id`'s indexed field values to every secondary index.
1090 fn index_add(&mut self, id: u64, doc: &Document) {
1091 for (field, index) in &mut self.indexes {
1092 if let Some(value) = doc.get(field) {
1093 index.add(value, id);
1094 }
1095 }
1096 }
1097
1098 /// Removes document `id`'s indexed field values from every secondary index.
1099 fn index_remove(&mut self, id: u64, doc: &Document) {
1100 for (field, index) in &mut self.indexes {
1101 if let Some(value) = doc.get(field) {
1102 index.remove(value, id);
1103 }
1104 }
1105 }
1106
1107 /// Appends one framed record and updates the index accordingly.
1108 ///
1109 /// For [`OP_PUT`] the body is encoded and the index repointed at it; for
1110 /// [`OP_DELETE`] the index entry is removed. The frame is built in `scratch`
1111 /// so the steady-state write path performs no per-record allocation.
1112 fn append(&mut self, op: u8, id: u64, doc: Option<&Document>) -> Result<()> {
1113 self.scratch.clear();
1114 // Reserve the frame header; the length and checksum are backfilled once
1115 // the payload is known.
1116 self.scratch.extend_from_slice(&[0u8; FRAME_LEN]);
1117 self.scratch.push(op);
1118 self.scratch.extend_from_slice(&id.to_le_bytes());
1119 if let Some(doc) = doc {
1120 encode_document_into(&mut self.scratch, doc)?;
1121 }
1122
1123 let payload_len = self.scratch.len() - FRAME_LEN;
1124 if payload_len > MAX_RECORD_BYTES {
1125 return Err(Error::ValueTooLarge);
1126 }
1127 let crc = crc32c(&self.scratch[FRAME_LEN..]);
1128 self.scratch[0..4].copy_from_slice(&(payload_len as u32).to_le_bytes());
1129 self.scratch[4..8].copy_from_slice(&crc.to_le_bytes());
1130
1131 write_all_at(&self.file, &self.scratch, self.tail)?;
1132
1133 let record_start = self.tail;
1134 self.tail += (FRAME_LEN + payload_len) as u64;
1135
1136 match op {
1137 OP_PUT => {
1138 let offset = record_start + FRAME_LEN as u64 + MIN_PAYLOAD as u64;
1139 let len = (payload_len - MIN_PAYLOAD) as u32;
1140 let _ = self.index.insert(id, BodyLoc { offset, len });
1141 }
1142 OP_DELETE => {
1143 let _ = self.index.remove(&id);
1144 }
1145 _ => {}
1146 }
1147
1148 if self.sync == SyncPolicy::Always {
1149 self.file.sync_all()?;
1150 }
1151 Ok(())
1152 }
1153
1154 /// Writes the 16-byte file header at offset 0 and syncs it, establishing a
1155 /// valid empty store.
1156 fn write_header(&mut self) -> Result<()> {
1157 write_header_to(&self.file)?;
1158 self.file.sync_all()?;
1159 Ok(())
1160 }
1161
1162 /// Validates the header of an existing file: length, magic, and version.
1163 fn verify_header(&self, file_len: u64) -> Result<()> {
1164 if file_len < HEADER_LEN {
1165 return Err(Error::BadMagic);
1166 }
1167 let mut header = [0u8; HEADER_LEN as usize];
1168 read_exact_at(&self.file, &mut header, 0)?;
1169 if header[0..8] != HEADER_MAGIC {
1170 return Err(Error::BadMagic);
1171 }
1172 let version = u16::from_le_bytes([header[8], header[9]]);
1173 if version > FORMAT_VERSION {
1174 return Err(Error::UnsupportedVersion(version));
1175 }
1176 Ok(())
1177 }
1178
1179 /// Scans every record after the header, rebuilding the index and truncating
1180 /// a torn record at the tail if one is found.
1181 fn replay(&mut self, file_len: u64) -> Result<()> {
1182 let mut offset = HEADER_LEN;
1183 let mut frame = [0u8; FRAME_LEN];
1184
1185 loop {
1186 if offset + FRAME_LEN as u64 > file_len {
1187 break;
1188 }
1189 read_exact_at(&self.file, &mut frame, offset)?;
1190 let payload_len = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
1191 let expected_crc = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]);
1192
1193 if !(MIN_PAYLOAD..=MAX_RECORD_BYTES).contains(&payload_len) {
1194 // A length this size at the tail is an incomplete write; mid-file
1195 // it is corruption. Either way the run of valid records ends here.
1196 break;
1197 }
1198 let record_end = offset + FRAME_LEN as u64 + payload_len as u64;
1199 if record_end > file_len {
1200 break;
1201 }
1202
1203 let mut payload = vec![0u8; payload_len];
1204 read_exact_at(&self.file, &mut payload, offset + FRAME_LEN as u64)?;
1205 if crc32c(&payload) != expected_crc {
1206 if record_end == file_len {
1207 // Torn final record: drop it and stop.
1208 break;
1209 }
1210 return Err(Error::Corrupt("crc mismatch"));
1211 }
1212
1213 let op = payload[0];
1214 let id = u64::from_le_bytes([
1215 payload[1], payload[2], payload[3], payload[4], payload[5], payload[6], payload[7],
1216 payload[8],
1217 ]);
1218
1219 match op {
1220 OP_PUT => {
1221 let offset = offset + FRAME_LEN as u64 + MIN_PAYLOAD as u64;
1222 let len = (payload_len - MIN_PAYLOAD) as u32;
1223 let _ = self.index.insert(id, BodyLoc { offset, len });
1224 }
1225 OP_DELETE => {
1226 let _ = self.index.remove(&id);
1227 }
1228 _ => return Err(Error::Corrupt("unknown record op")),
1229 }
1230 if id >= self.next_id {
1231 self.next_id = id + 1;
1232 }
1233 offset = record_end;
1234 }
1235
1236 if offset < file_len {
1237 // Trailing torn bytes: cut the file back to the last good record.
1238 self.file.set_len(offset)?;
1239 }
1240 self.tail = offset;
1241 Ok(())
1242 }
1243}
1244
1245impl fmt::Debug for Db {
1246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1247 f.debug_struct("Db")
1248 .field("path", &self.path)
1249 .field("live_documents", &self.index.len())
1250 .field("file_bytes", &self.tail)
1251 .field("sync", &self.sync)
1252 .finish()
1253 }
1254}
1255
1256/// Compile-time proof that [`Db`] is `Send + Sync`, so it can be shared across
1257/// threads behind a lock as the concurrency docs describe. If a future field
1258/// broke this, the crate would fail to compile here rather than silently.
1259const _: fn() = || {
1260 fn assert_send_sync<T: Send + Sync>() {}
1261 assert_send_sync::<Db>();
1262};
1263
1264impl Drop for Db {
1265 /// Makes a best-effort `fsync` on a clean shutdown under
1266 /// [`SyncPolicy::Manual`], so a normal program exit does not lose writes that
1267 /// were never explicitly flushed. Under [`SyncPolicy::Always`] every write is
1268 /// already durable, so nothing is done. Any error here is ignored because a
1269 /// destructor cannot return one; call [`Db::flush`] before dropping when you
1270 /// need to observe a sync failure.
1271 fn drop(&mut self) {
1272 if self.sync == SyncPolicy::Manual {
1273 let _ = self.file.sync_all();
1274 }
1275 }
1276}
1277
1278/// Writes the 16-byte file header at offset 0 of `file` (without syncing).
1279fn write_header_to(file: &File) -> Result<()> {
1280 let mut header = [0u8; HEADER_LEN as usize];
1281 header[0..8].copy_from_slice(&HEADER_MAGIC);
1282 header[8..10].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
1283 write_all_at(file, &header, 0)?;
1284 Ok(())
1285}
1286
1287/// The sibling path a compaction writes its temporary file to: the store's path
1288/// with a `.compacting` suffix. A leftover one is removed on [`Db::open`].
1289fn compacting_path(path: &Path) -> PathBuf {
1290 let mut name = path.as_os_str().to_os_string();
1291 name.push(".compacting");
1292 PathBuf::from(name)
1293}
1294
1295/// Forces the directory containing `path` to disk, so the file's creation is
1296/// durable. On Unix this is a real `fsync` of the parent directory; on Windows,
1297/// directory handles do not support this and file-level `fsync` already persists
1298/// the entry, so this is a documented no-op.
1299#[cfg(unix)]
1300fn sync_parent_dir(path: &Path) -> Result<()> {
1301 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
1302 let dir = parent.unwrap_or_else(|| Path::new("."));
1303 let handle = File::open(dir)?;
1304 handle.sync_all()?;
1305 Ok(())
1306}
1307
1308/// Windows counterpart to [`sync_parent_dir`]: a no-op, because the file-level
1309/// `fsync` already makes the directory entry durable on this platform.
1310#[cfg(windows)]
1311fn sync_parent_dir(_path: &Path) -> Result<()> {
1312 Ok(())
1313}
1314
1315#[cfg(test)]
1316#[allow(clippy::unwrap_used, clippy::expect_used)]
1317mod tests {
1318 use super::*;
1319 use crate::value::Value;
1320 use std::sync::atomic::{AtomicU64, Ordering};
1321
1322 /// Returns a unique temp path and removes any stale file at it.
1323 fn temp_path() -> PathBuf {
1324 static COUNTER: AtomicU64 = AtomicU64::new(0);
1325 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1326 let pid = std::process::id();
1327 let path = std::env::temp_dir().join(format!("bison_db_test_{pid}_{n}.bison"));
1328 let _ = std::fs::remove_file(&path);
1329 path
1330 }
1331
1332 fn doc(pairs: &[(&str, i64)]) -> Document {
1333 let mut d = Document::new();
1334 for (k, v) in pairs {
1335 d.set(*k, *v);
1336 }
1337 d
1338 }
1339
1340 #[test]
1341 fn test_insert_get_roundtrip() {
1342 let path = temp_path();
1343 let mut db = Db::open(&path).unwrap();
1344 let id = db.insert(doc(&[("a", 1), ("b", 2)])).unwrap();
1345 let got = db.get(id).unwrap().unwrap();
1346 assert_eq!(got.get("a").and_then(Value::as_int), Some(1));
1347 let _ = std::fs::remove_file(&path);
1348 }
1349
1350 #[test]
1351 fn test_get_missing_returns_none() {
1352 let path = temp_path();
1353 let db = Db::open(&path).unwrap();
1354 assert!(db.get(DocId::from(1)).unwrap().is_none());
1355 let _ = std::fs::remove_file(&path);
1356 }
1357
1358 #[test]
1359 fn test_delete_removes_document() {
1360 let path = temp_path();
1361 let mut db = Db::open(&path).unwrap();
1362 let id = db.insert(doc(&[("x", 9)])).unwrap();
1363 assert!(db.delete(id).unwrap());
1364 assert!(db.get(id).unwrap().is_none());
1365 assert!(!db.delete(id).unwrap());
1366 let _ = std::fs::remove_file(&path);
1367 }
1368
1369 #[test]
1370 fn test_update_changes_value() {
1371 let path = temp_path();
1372 let mut db = Db::open(&path).unwrap();
1373 let id = db.insert(doc(&[("v", 1)])).unwrap();
1374 assert!(db.update(id, doc(&[("v", 2)])).unwrap());
1375 assert_eq!(
1376 db.get(id)
1377 .unwrap()
1378 .unwrap()
1379 .get("v")
1380 .and_then(Value::as_int),
1381 Some(2)
1382 );
1383 let _ = std::fs::remove_file(&path);
1384 }
1385
1386 #[test]
1387 fn test_update_absent_id_is_false() {
1388 let path = temp_path();
1389 let mut db = Db::open(&path).unwrap();
1390 assert!(!db.update(DocId::from(7), Document::new()).unwrap());
1391 let _ = std::fs::remove_file(&path);
1392 }
1393
1394 #[test]
1395 fn test_reopen_recovers_state() {
1396 let path = temp_path();
1397 let (a, b);
1398 {
1399 let mut db = Db::open(&path).unwrap();
1400 a = db.insert(doc(&[("n", 10)])).unwrap();
1401 b = db.insert(doc(&[("n", 20)])).unwrap();
1402 db.delete(a).unwrap();
1403 db.flush().unwrap();
1404 }
1405 let db = Db::open(&path).unwrap();
1406 assert!(db.get(a).unwrap().is_none());
1407 assert_eq!(
1408 db.get(b).unwrap().unwrap().get("n").and_then(Value::as_int),
1409 Some(20)
1410 );
1411 assert_eq!(db.len(), 1);
1412 let _ = std::fs::remove_file(&path);
1413 }
1414
1415 #[test]
1416 fn test_reopen_continues_id_sequence() {
1417 let path = temp_path();
1418 let first;
1419 {
1420 let mut db = Db::open(&path).unwrap();
1421 first = db.insert(Document::new()).unwrap();
1422 }
1423 let mut db = Db::open(&path).unwrap();
1424 let second = db.insert(Document::new()).unwrap();
1425 assert!(second.get() > first.get());
1426 let _ = std::fs::remove_file(&path);
1427 }
1428
1429 #[test]
1430 fn test_open_rejects_foreign_file() {
1431 let path = temp_path();
1432 std::fs::write(&path, b"this is definitely not a bison-db file at all").unwrap();
1433 assert!(matches!(Db::open(&path), Err(Error::BadMagic)));
1434 let _ = std::fs::remove_file(&path);
1435 }
1436
1437 #[test]
1438 fn test_torn_tail_is_truncated_on_open() {
1439 let path = temp_path();
1440 let keep;
1441 {
1442 let mut db = Db::open(&path).unwrap();
1443 keep = db.insert(doc(&[("ok", 1)])).unwrap();
1444 db.flush().unwrap();
1445 }
1446 // Append a bogus frame claiming a payload longer than what follows.
1447 {
1448 use std::io::Write;
1449 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
1450 let mut frame = Vec::new();
1451 frame.extend_from_slice(&999u32.to_le_bytes());
1452 frame.extend_from_slice(&0u32.to_le_bytes());
1453 frame.extend_from_slice(b"short");
1454 f.write_all(&frame).unwrap();
1455 f.flush().unwrap();
1456 }
1457 let db = Db::open(&path).unwrap();
1458 assert!(db.get(keep).unwrap().is_some());
1459 assert_eq!(db.len(), 1);
1460 let _ = std::fs::remove_file(&path);
1461 }
1462
1463 #[test]
1464 fn test_stats_reflect_live_documents() {
1465 let path = temp_path();
1466 let mut db = Db::open(&path).unwrap();
1467 db.insert(doc(&[("a", 1)])).unwrap();
1468 let id = db.insert(doc(&[("b", 2)])).unwrap();
1469 db.delete(id).unwrap();
1470 let stats = db.stats();
1471 assert_eq!(stats.live_documents, 1);
1472 assert!(stats.file_bytes > HEADER_LEN);
1473 let _ = std::fs::remove_file(&path);
1474 }
1475
1476 fn sorted(mut ids: Vec<DocId>) -> Vec<u64> {
1477 ids.sort();
1478 ids.into_iter().map(DocId::get).collect()
1479 }
1480
1481 #[test]
1482 fn test_create_index_then_find() {
1483 let path = temp_path();
1484 let mut db = Db::open(&path).unwrap();
1485 let a = db.insert(doc(&[("g", 1)])).unwrap();
1486 let b = db.insert(doc(&[("g", 2)])).unwrap();
1487 let c = db.insert(doc(&[("g", 1)])).unwrap();
1488
1489 db.create_index("g").unwrap();
1490 assert_eq!(
1491 sorted(db.find("g", &Value::from(1_i64)).unwrap()),
1492 sorted(vec![a, c])
1493 );
1494 assert_eq!(
1495 sorted(db.find("g", &Value::from(2_i64)).unwrap()),
1496 vec![b.get()]
1497 );
1498 assert!(db.find("g", &Value::from(9_i64)).unwrap().is_empty());
1499 let _ = std::fs::remove_file(&path);
1500 }
1501
1502 #[test]
1503 fn test_find_indexed_matches_scan() {
1504 let path = temp_path();
1505 let mut db = Db::open(&path).unwrap();
1506 for n in [1, 2, 2, 3, 2] {
1507 db.insert(doc(&[("k", n)])).unwrap();
1508 }
1509 let scan = sorted(db.find("k", &Value::from(2_i64)).unwrap()); // no index yet
1510 db.create_index("k").unwrap();
1511 let indexed = sorted(db.find("k", &Value::from(2_i64)).unwrap());
1512 assert_eq!(scan, indexed);
1513 assert_eq!(scan.len(), 3);
1514 let _ = std::fs::remove_file(&path);
1515 }
1516
1517 #[test]
1518 fn test_range_query_inclusive_and_exclusive() {
1519 let path = temp_path();
1520 let mut db = Db::open(&path).unwrap();
1521 for n in [10, 20, 30, 40] {
1522 db.insert(doc(&[("age", n)])).unwrap();
1523 }
1524 db.create_index("age").unwrap();
1525 assert_eq!(
1526 db.range("age", Value::from(20_i64)..=Value::from(30_i64))
1527 .unwrap()
1528 .len(),
1529 2
1530 );
1531 assert_eq!(
1532 db.range("age", Value::from(20_i64)..Value::from(40_i64))
1533 .unwrap()
1534 .len(),
1535 2
1536 );
1537 assert_eq!(db.range("age", Value::from(25_i64)..).unwrap().len(), 2);
1538 let _ = std::fs::remove_file(&path);
1539 }
1540
1541 #[test]
1542 fn test_index_maintained_on_update_and_delete() {
1543 let path = temp_path();
1544 let mut db = Db::open(&path).unwrap();
1545 let id = db.insert(doc(&[("status", 1)])).unwrap();
1546 db.create_index("status").unwrap();
1547 assert_eq!(db.find("status", &Value::from(1_i64)).unwrap(), vec![id]);
1548
1549 db.update(id, doc(&[("status", 2)])).unwrap();
1550 assert!(db.find("status", &Value::from(1_i64)).unwrap().is_empty());
1551 assert_eq!(db.find("status", &Value::from(2_i64)).unwrap(), vec![id]);
1552
1553 db.delete(id).unwrap();
1554 assert!(db.find("status", &Value::from(2_i64)).unwrap().is_empty());
1555 let _ = std::fs::remove_file(&path);
1556 }
1557
1558 #[test]
1559 fn test_indexes_listed_and_dropped() {
1560 let path = temp_path();
1561 let mut db = Db::open(&path).unwrap();
1562 db.create_index("a").unwrap();
1563 db.create_index("b").unwrap();
1564 db.create_index("a").unwrap(); // idempotent
1565 let mut names: Vec<&str> = db.indexes().collect();
1566 names.sort_unstable();
1567 assert_eq!(names, ["a", "b"]);
1568 assert!(db.drop_index("a"));
1569 assert!(!db.drop_index("a"));
1570 assert_eq!(db.indexes().count(), 1);
1571 let _ = std::fs::remove_file(&path);
1572 }
1573
1574 #[test]
1575 fn test_index_not_persisted_but_rebuildable_after_reopen() {
1576 let path = temp_path();
1577 let id;
1578 {
1579 let mut db = Db::open(&path).unwrap();
1580 id = db.insert(doc(&[("city", 7)])).unwrap();
1581 db.create_index("city").unwrap();
1582 db.flush().unwrap();
1583 }
1584 let mut db = Db::open(&path).unwrap();
1585 assert_eq!(db.indexes().count(), 0); // indexes are not on disk
1586 db.create_index("city").unwrap(); // rebuild from the log
1587 assert_eq!(db.find("city", &Value::from(7_i64)).unwrap(), vec![id]);
1588 let _ = std::fs::remove_file(&path);
1589 }
1590
1591 #[test]
1592 fn test_default_sync_policy_is_manual() {
1593 let path = temp_path();
1594 let db = Db::open(&path).unwrap();
1595 assert_eq!(db.sync_policy(), SyncPolicy::Manual);
1596 let _ = std::fs::remove_file(&path);
1597 }
1598
1599 #[test]
1600 fn test_options_set_always_sync_policy() {
1601 let path = temp_path();
1602 let mut db = Db::open_with(&path, DbOptions::new().sync(SyncPolicy::Always)).unwrap();
1603 assert_eq!(db.sync_policy(), SyncPolicy::Always);
1604 // Every write fsyncs; data is present and reopen recovers it.
1605 let id = db.insert(doc(&[("v", 1)])).unwrap();
1606 assert!(db.get(id).unwrap().is_some());
1607 drop(db);
1608 let db = Db::open(&path).unwrap();
1609 assert_eq!(db.len(), 1);
1610 let _ = std::fs::remove_file(&path);
1611 }
1612
1613 #[test]
1614 fn test_always_sync_persists_without_explicit_flush() {
1615 let path = temp_path();
1616 let id;
1617 {
1618 let mut db = Db::open_with(&path, DbOptions::new().sync(SyncPolicy::Always)).unwrap();
1619 id = db.insert(doc(&[("durable", 1)])).unwrap();
1620 // No flush() call: Always already synced each write.
1621 }
1622 let db = Db::open(&path).unwrap();
1623 assert!(db.get(id).unwrap().is_some());
1624 let _ = std::fs::remove_file(&path);
1625 }
1626
1627 #[test]
1628 fn test_dboptions_open_matches_db_open() {
1629 let path = temp_path();
1630 let db = DbOptions::new().open(&path).unwrap();
1631 assert_eq!(db.sync_policy(), SyncPolicy::Manual);
1632 let _ = std::fs::remove_file(&path);
1633 }
1634
1635 #[test]
1636 fn test_compact_reclaims_space_and_preserves_data() {
1637 let path = temp_path();
1638 let mut db = Db::open(&path).unwrap();
1639 let id = db.insert(doc(&[("v", 1)])).unwrap();
1640 for n in 2..500 {
1641 db.update(id, doc(&[("v", n)])).unwrap();
1642 }
1643 let before = db.stats().file_bytes;
1644
1645 db.compact().unwrap();
1646
1647 let after = db.stats().file_bytes;
1648 assert!(
1649 after < before,
1650 "compaction should shrink the file: {after} !< {before}"
1651 );
1652 assert_eq!(db.len(), 1);
1653 assert_eq!(
1654 db.get(id)
1655 .unwrap()
1656 .unwrap()
1657 .get("v")
1658 .and_then(Value::as_int),
1659 Some(499)
1660 );
1661 let _ = std::fs::remove_file(&path);
1662 }
1663
1664 #[test]
1665 fn test_compact_drops_deleted_records() {
1666 let path = temp_path();
1667 let mut db = Db::open(&path).unwrap();
1668 let keep = db.insert(doc(&[("k", 1)])).unwrap();
1669 let gone = db.insert(doc(&[("k", 2)])).unwrap();
1670 db.delete(gone).unwrap();
1671
1672 db.compact().unwrap();
1673
1674 assert_eq!(db.len(), 1);
1675 assert!(db.get(keep).unwrap().is_some());
1676 assert!(db.get(gone).unwrap().is_none());
1677 let _ = std::fs::remove_file(&path);
1678 }
1679
1680 #[test]
1681 fn test_compact_preserves_ids_and_indexes() {
1682 let path = temp_path();
1683 let mut db = Db::open(&path).unwrap();
1684 let a = db.insert(doc(&[("city", 1)])).unwrap();
1685 let b = db.insert(doc(&[("city", 2)])).unwrap();
1686 db.create_index("city").unwrap();
1687
1688 db.compact().unwrap();
1689
1690 // Ids are unchanged and the secondary index still resolves them.
1691 assert_eq!(db.find("city", &Value::from(1_i64)).unwrap(), vec![a]);
1692 assert_eq!(db.find("city", &Value::from(2_i64)).unwrap(), vec![b]);
1693 let _ = std::fs::remove_file(&path);
1694 }
1695
1696 #[test]
1697 fn test_compact_then_reopen_recovers() {
1698 let path = temp_path();
1699 let id;
1700 {
1701 let mut db = Db::open(&path).unwrap();
1702 id = db.insert(doc(&[("v", 7)])).unwrap();
1703 db.update(id, doc(&[("v", 8)])).unwrap();
1704 db.compact().unwrap();
1705 db.flush().unwrap();
1706 }
1707 let db = Db::open(&path).unwrap();
1708 assert_eq!(db.len(), 1);
1709 assert_eq!(
1710 db.get(id)
1711 .unwrap()
1712 .unwrap()
1713 .get("v")
1714 .and_then(Value::as_int),
1715 Some(8)
1716 );
1717 let _ = std::fs::remove_file(&path);
1718 }
1719
1720 #[test]
1721 fn test_compact_empty_db_is_ok() {
1722 let path = temp_path();
1723 let mut db = Db::open(&path).unwrap();
1724 db.compact().unwrap();
1725 assert!(db.is_empty());
1726 // Still writable afterwards.
1727 let id = db.insert(doc(&[("x", 1)])).unwrap();
1728 assert!(db.get(id).unwrap().is_some());
1729 let _ = std::fs::remove_file(&path);
1730 }
1731
1732 #[test]
1733 fn test_open_removes_stale_compacting_temp() {
1734 let path = temp_path();
1735 {
1736 let mut db = Db::open(&path).unwrap();
1737 db.insert(doc(&[("v", 1)])).unwrap();
1738 db.flush().unwrap();
1739 }
1740 // Simulate a compaction that died before swapping in its temp file.
1741 let stale = compacting_path(&path);
1742 std::fs::write(&stale, b"garbage from an interrupted compaction").unwrap();
1743 assert!(stale.exists());
1744
1745 let db = Db::open(&path).unwrap();
1746 assert!(!stale.exists(), "open should remove the stale temp");
1747 assert_eq!(db.len(), 1);
1748 let _ = std::fs::remove_file(&path);
1749 }
1750}