Skip to main content

iqdb_persist/
lib.rs

1//! # iqdb-persist
2//!
3//! On-disk persistence for the **iQDB** vector database. Provides atomic
4//! snapshot save / load with a versioned file header and a CRC32 integrity
5//! check, generic over any type implementing [`iqdb_index::Index`].
6//!
7//! ## Tiered API
8//!
9//! - **Tier 1 — the lazy path.** [`PersistConfig::new`] plus
10//!   [`PersistedIndex::open_with`] / [`PersistedIndex::save`] /
11//!   [`PersistedIndex::load`] cover the whole common case — wrap an index,
12//!   save it, load it back — with no builder and no generics to name
13//!   beyond the index type itself. With the WAL on,
14//!   [`PersistedIndex::insert`] / [`PersistedIndex::delete`] /
15//!   [`PersistedIndex::checkpoint`] add durable, crash-recoverable
16//!   mutation.
17//! - **Tier 2 — the configured path.** The [`PersistConfig`] fields
18//!   ([`fsync_policy`](PersistConfig::fsync_policy),
19//!   [`compression`](PersistConfig::compression),
20//!   [`wal_enabled`](PersistConfig::wal_enabled)) tune durability and
21//!   on-disk size.
22//! - **Tier 3 — the trait seam.** An index opts into persistence by
23//!   implementing [`Persistable`]; everything in Tier 1 and Tier 2 then
24//!   works against it unchanged.
25//!
26//! ## Surface
27//!
28//! - [`Persistable`] — the trait an index implements. Two methods,
29//!   `save_to(&mut dyn Write)` and `load_from(&mut dyn Read) -> Result<Self>`,
30//!   plus a stable [`INDEX_TYPE`](Persistable::INDEX_TYPE) tag. The impl
31//!   serializes **only** the index's self-contained payload; the file
32//!   header, CRC32, and atomic write are added by [`PersistedIndex`]
33//!   around it.
34//! - [`PersistedIndex`] — wraps an `I: Index + Persistable`. Two honest
35//!   constructors: [`open_with`](PersistedIndex::open_with) wraps an
36//!   in-memory index for later [`save`](PersistedIndex::save);
37//!   [`load`](PersistedIndex::load) reconstructs an index from disk and
38//!   errors if the file does not exist.
39//! - [`FileHeader`] + [`MAGIC`] + [`CURRENT_VERSION`] — the wire format.
40//! - [`PersistConfig`] / [`FsyncPolicy`] / [`Compression`] — configuration.
41//!   `wal_enabled = true` turns on the write-ahead log (v0.3);
42//!   `Compression::Zstd|Lz4` compress the snapshot payload (v0.4, behind
43//!   the `zstd` / `lz4` cargo features — selecting a scheme whose feature
44//!   is off yields [`PersistError::Unsupported`]).
45//! - [`PersistError`] — `#[non_exhaustive]` and `error_forge::ForgeError`-
46//!   integrated.
47//!
48//! ## Three guards
49//!
50//! 1. The trait impl writes / reads **only** the index's self-contained
51//!    payload. Framing (header + CRC32) lives in [`PersistedIndex`].
52//! 2. This crate stays generic over `I` — it never names a concrete
53//!    index. The `index_type` → concrete-type registry that
54//!    `Database::open` needs lives in the umbrella `iqdb` crate.
55//! 3. Tests use a tiny in-crate mock `Persistable`; `iqdb-persist` never
56//!    dev-deps a concrete index crate.
57//!
58//! ## Scope
59//!
60//! **Stable as of v1.0.0.** The full surface — atomic snapshots + CRC32,
61//! the write-ahead log with replay and crash recovery, and optional Zstd /
62//! LZ4 snapshot compression — is complete, the parse/recovery paths are
63//! adversarially hardened, and the public API and on-disk format are frozen
64//! under the SemVer 1.x guarantee (no breaking changes before 2.0). The
65//! external `storage-io` substrate is deferred behind the internal storage
66//! seam and is out of scope for 1.0 (see `dev/ROADMAP.md`). See
67//! `CHANGELOG.md`.
68//!
69//! ## Example
70//!
71//! ```
72//! use iqdb_persist::{FileHeader, CURRENT_VERSION, MAGIC};
73//! use iqdb_types::DistanceMetric;
74//!
75//! // A header is just data; tools can inspect a snapshot file without
76//! // loading the index it carries.
77//! let header = FileHeader {
78//!     magic: MAGIC,
79//!     version: CURRENT_VERSION,
80//!     index_type: "flat".to_string(),
81//!     dim: 128,
82//!     metric: DistanceMetric::Cosine,
83//!     n_vectors: 1_000,
84//!     crc32: 0,
85//! };
86//! assert_eq!(header.version, 2);
87//! assert_eq!(&header.magic, b"IQDBPRST");
88//! ```
89
90#![cfg_attr(docsrs, feature(doc_cfg))]
91#![deny(warnings)]
92#![deny(missing_docs)]
93#![deny(unsafe_op_in_unsafe_fn)]
94#![deny(unused_must_use)]
95#![deny(unused_results)]
96#![deny(clippy::unwrap_used)]
97#![deny(clippy::expect_used)]
98#![deny(clippy::todo)]
99#![deny(clippy::unimplemented)]
100#![deny(clippy::print_stdout)]
101#![deny(clippy::print_stderr)]
102#![deny(clippy::dbg_macro)]
103#![deny(clippy::unreachable)]
104#![deny(clippy::undocumented_unsafe_blocks)]
105#![forbid(unsafe_code)]
106
107pub mod checksum;
108mod compression;
109mod config;
110mod error;
111pub mod format;
112mod persisted;
113mod recovery;
114mod storage;
115mod wal;
116
117use std::io::{Read, Write};
118
119use iqdb_index::Index;
120
121pub use crate::config::{Compression, FsyncPolicy, PersistConfig};
122pub use crate::error::{PersistError, Result};
123pub use crate::format::{CURRENT_VERSION, FileHeader, MAGIC};
124pub use crate::persisted::PersistedIndex;
125
126// `Storage` stays internal to the `storage` module — it is the
127// substrate seam for the future `storage-io` swap, not a v0.2 public
128// extension point.
129
130/// The version of this crate, taken from `Cargo.toml` at compile time.
131///
132/// # Examples
133///
134/// ```
135/// let v = iqdb_persist::VERSION;
136/// assert_eq!(v.split('.').count(), 3);
137/// ```
138pub const VERSION: &str = env!("CARGO_PKG_VERSION");
139
140/// An index that can be written to and read from a byte stream.
141///
142/// The two methods serialize the index's **self-contained payload**: the
143/// vectors, the ids, and the metadata. They do **not** write the file
144/// header or the CRC32 — that framing is added by [`PersistedIndex`]
145/// around the payload. Keeping the impl payload-only is what lets the
146/// wire format stay centralized in this crate and uniform across every
147/// future index implementation.
148///
149/// ## On-disk format contract
150///
151/// [`INDEX_TYPE`](Persistable::INDEX_TYPE) is stamped into the file
152/// header on save and matched on load. Once snapshot files exist on
153/// real users' disks with a given tag, **renaming the tag is a breaking
154/// format change** — treat it with the same care as the magic bytes.
155/// [`CURRENT_VERSION`] is for evolving the wire format; the type tag is
156/// identity, not version.
157///
158/// ## Self-describing payload
159///
160/// [`load_from`](Persistable::load_from) reconstructs `Self` from the
161/// payload alone — no header is passed in. The payload MUST therefore be
162/// self-describing: the impl should re-state any state the constructor
163/// needs (typically `dim` and `metric` for a vector index) at the start
164/// of the payload. [`PersistedIndex::load`] cross-checks the
165/// payload-reconstructed `Self`'s [`dim`](iqdb_index::IndexCore::dim) /
166/// [`metric`](iqdb_index::IndexCore::metric) /
167/// [`len`](iqdb_index::IndexCore::len) against the header values and
168/// errors loudly on mismatch — a header claiming `dim = 128` over a
169/// payload that says `96` is a corrupted file we catch the same way
170/// CRC32 catches bit flips.
171///
172/// # Examples
173///
174/// ```no_run
175/// use std::io::{Read, Write};
176///
177/// use iqdb_index::{Index, IndexCore, IndexStats};
178/// use iqdb_persist::{Persistable, Result};
179/// use iqdb_types::{
180///     DistanceMetric, Hit, Metadata, Result as IqdbResult, SearchParams, VectorId,
181/// };
182///
183/// # struct DummyIndex { dim: usize, metric: DistanceMetric }
184/// # impl IndexCore for DummyIndex {
185/// #     fn insert(&mut self, _: VectorId, _: std::sync::Arc<[f32]>, _: Option<Metadata>) -> IqdbResult<()> { Ok(()) }
186/// #     fn delete(&mut self, _: &VectorId) -> IqdbResult<()> { Ok(()) }
187/// #     fn search(&self, _: &[f32], _: &SearchParams) -> IqdbResult<Vec<Hit>> { Ok(Vec::new()) }
188/// #     fn len(&self) -> usize { 0 }
189/// #     fn dim(&self) -> usize { self.dim }
190/// #     fn metric(&self) -> DistanceMetric { self.metric }
191/// #     fn flush(&mut self) -> IqdbResult<()> { Ok(()) }
192/// #     fn stats(&self) -> IndexStats { IndexStats { index_type: "dummy", ..IndexStats::default() } }
193/// # }
194/// # impl Index for DummyIndex {
195/// #     type Config = ();
196/// #     fn new(dim: usize, metric: DistanceMetric, _: ()) -> IqdbResult<Self> { Ok(Self { dim, metric }) }
197/// # }
198/// impl Persistable for DummyIndex {
199///     const INDEX_TYPE: &'static str = "dummy";
200///     fn save_to(&self, _w: &mut dyn Write) -> Result<()> { Ok(()) }
201///     fn load_from(_r: &mut dyn Read) -> Result<Self> {
202///         Ok(DummyIndex { dim: 1, metric: DistanceMetric::Cosine })
203///     }
204/// }
205/// ```
206pub trait Persistable: Index {
207    /// Stable, short identifier written into the file header.
208    ///
209    /// Examples: `"flat"`, `"hnsw"`.
210    ///
211    /// IMPORTANT: this string is part of the on-disk format contract.
212    /// Once snapshot files exist on real users' disks with this tag,
213    /// renaming it is a breaking format change — treat it with the
214    /// same care as the magic bytes. [`CURRENT_VERSION`] is for
215    /// evolving the format; this tag is identity, not version.
216    const INDEX_TYPE: &'static str;
217
218    /// Write ONLY the index's self-contained payload.
219    ///
220    /// The framing ([`FileHeader`] + CRC32) is added by
221    /// [`PersistedIndex`] around this. Do not write magic bytes or a
222    /// checksum from inside the impl.
223    ///
224    /// # Errors
225    ///
226    /// Returns a [`PersistError`] if a write to `writer` fails or if a
227    /// `usize` field of the index does not fit in `u64`.
228    fn save_to(&self, writer: &mut dyn Write) -> Result<()>;
229
230    /// Reconstruct `Self` from the payload alone (no framing).
231    ///
232    /// The payload must be self-describing — see the trait-level
233    /// "Self-describing payload" note.
234    ///
235    /// # Errors
236    ///
237    /// Returns a [`PersistError`] if a read from `reader` fails or if
238    /// the payload bytes do not decode to a valid `Self`.
239    fn load_from(reader: &mut dyn Read) -> Result<Self>
240    where
241        Self: Sized;
242}