Skip to main content

highlandcows_isam/
secondary_index.rs

1/// Secondary index support for `Isam<K, V>`.
2///
3/// A secondary index maps a derived key (`SK`) to the set of primary keys
4/// (`K`) whose values produce that secondary key.  One secondary key can map
5/// to many primary keys (non-unique index).
6///
7/// # Files on disk
8///
9/// Each named secondary index uses two files alongside the primary store:
10///
11/// | File | Contents |
12/// |------|----------|
13/// | `<base>_<name>.sidb` | Append-only store of serialised `Vec<K>` buckets |
14/// | `<base>_<name>.sidx` | B-tree (`SK → RecordRef`) pointing into `.sidb` |
15use std::marker::PhantomData;
16use std::ops::Bound;
17use std::path::{Path, PathBuf};
18
19use serde::de::DeserializeOwned;
20use serde::Serialize;
21
22use crate::error::IsamResult;
23use crate::index::BTree;
24use crate::store::DataStore;
25
26// ── DeriveKey ─────────────────────────────────────────────────────────────── //
27
28/// Describes how to derive a secondary index key from a record value.
29///
30/// Implement this trait on a marker struct, one per secondary index.
31/// For composite indices in the future, set `Key` to a tuple type —
32/// no change to this trait is required.
33///
34/// # Correctness
35///
36/// The secondary index B-tree routes all inserts, lookups, and range queries
37/// through `Key`'s [`Ord`] implementation — **not** raw byte comparison.
38/// Your `Ord` implementation must be a valid *total order*:
39///
40/// - **Transitivity**: if `a ≤ b` and `b ≤ c` then `a ≤ c`.
41/// - **Antisymmetry**: if `a ≤ b` and `b ≤ a` then `a == b`.
42/// - **Totality**: every pair of keys is comparable (no `None` from `partial_cmp`).
43/// - **Consistency**: `PartialOrd::partial_cmp(a, b) == Some(Ord::cmp(a, b))` always.
44///
45/// Violating any of these properties will silently corrupt the B-tree's internal
46/// node ordering, causing missed lookups, phantom results, or incorrect range
47/// iteration — with no runtime error to signal the problem.
48///
49/// # Custom ordering
50///
51/// Because the B-tree uses `Key::cmp` for all inserts and range traversal, you
52/// can control the iteration order of a secondary-index range scan simply by
53/// choosing a `Key` type with the desired `Ord` behaviour.  For example,
54/// wrapping `String` in a newtype whose `Ord` reverses the comparison stores
55/// entries in reverse alphabetical order, so range scans return them
56/// last-to-first alphabetically.
57///
58/// Note that **identity** — which bucket a key lands in — is based on the
59/// serialised byte representation, not on `Ord`.  Two values that are `Equal`
60/// under a custom `Ord` but serialise to different bytes (e.g. `"London"` vs
61/// `"LONDON"` with a case-insensitive comparator) are stored as separate
62/// entries.  To make case variants share a bucket, normalise in `derive`
63/// (e.g. `value.city.to_lowercase()`) rather than relying on `Ord`.
64///
65/// # Example
66/// ```
67/// use serde::{Serialize, Deserialize};
68/// use highlandcows_isam::DeriveKey;
69///
70/// #[derive(Serialize, Deserialize, Clone)]
71/// struct User { name: String, city: String }
72///
73/// struct CityIndex;
74/// impl DeriveKey<User> for CityIndex {
75///     type Key = String;
76///     fn derive(value: &User) -> String { value.city.clone() }
77/// }
78/// ```
79pub trait DeriveKey<V>: Send + Sync + 'static {
80    /// The type of the derived secondary key.
81    type Key: Serialize + DeserializeOwned + Ord + Clone + Send;
82
83    /// Derive the secondary key from a value.
84    fn derive(value: &V) -> Self::Key;
85}
86
87// ── AnySecondaryIndex ─────────────────────────────────────────────────────── //
88
89/// Type-erased secondary index interface stored inside `IsamStorage`.
90///
91/// All methods receive the primary key and deserialized value so the concrete
92/// implementation can extract `SK` without the storage layer knowing about it.
93pub(crate) trait AnySecondaryIndex<K, V>: Send {
94    // ── Forward operations (called during CRUD) ───────────────────────── //
95
96    fn on_insert(&mut self, key: &K, value: &V) -> IsamResult<()>;
97    fn on_update(&mut self, key: &K, old_value: &V, new_value: &V) -> IsamResult<()>;
98    fn on_delete(&mut self, key: &K, value: &V) -> IsamResult<()>;
99
100    // ── Inverse operations (called during rollback) ───────────────────── //
101
102    fn undo_insert(&mut self, key: &K, value: &V) -> IsamResult<()>;
103    fn undo_update(&mut self, key: &K, old_value: &V, new_value: &V) -> IsamResult<()>;
104    fn undo_delete(&mut self, key: &K, value: &V) -> IsamResult<()>;
105
106    /// Return all primary keys whose secondary key serialises to `sk_bytes`.
107    fn lookup_primary_keys(&mut self, sk_bytes: &[u8]) -> IsamResult<Vec<K>>;
108
109    /// Return `(sk_bytes, primary_keys)` pairs for every secondary key within
110    /// the given byte-serialised bounds, in secondary-key order.
111    fn range_primary_keys(
112        &mut self,
113        start: Bound<&[u8]>,
114        end: Bound<&[u8]>,
115    ) -> IsamResult<Vec<(Vec<u8>, Vec<K>)>>;
116
117    fn fsync(&mut self) -> IsamResult<()>;
118    fn name(&self) -> &str;
119
120    /// Return the fully-qualified type name of the `DeriveKey` extractor.
121    ///
122    /// Uses [`std::any::type_name`] — suitable for display, not for persistent
123    /// storage (the value can change between compiler versions or refactors).
124    fn extractor_type_name(&self) -> &'static str;
125
126    /// Return the index schema version stored in the `.sidx` metadata.
127    fn stored_schema_version(&self) -> u32;
128
129    /// Write `version` into the `.sidx` metadata as the index schema version.
130    fn persist_schema_version(&mut self, version: u32) -> IsamResult<()>;
131
132    /// Discard all index data and recreate fresh, empty index files in place.
133    ///
134    /// Used by `migrate_index` to clear the index before repopulating it.
135    fn reset(&mut self) -> IsamResult<()>;
136}
137
138// ── SecondaryIndexImpl ────────────────────────────────────────────────────── //
139
140/// Concrete secondary index backed by a `DataStore` + `BTree<SK>` pair.
141///
142/// The data store holds serialised `Vec<K>` buckets (one per distinct SK value).
143/// The B-tree maps each SK to the `RecordRef` of its current bucket in the store.
144pub(crate) struct SecondaryIndexImpl<K, V, E>
145where
146    E: DeriveKey<V>,
147{
148    name: String,
149    base: PathBuf,
150    store: DataStore,
151    btree: BTree<E::Key>,
152    _phantom: PhantomData<(K, V)>,
153}
154
155impl<K, V, E> SecondaryIndexImpl<K, V, E>
156where
157    K: Serialize + DeserializeOwned + Ord + Clone + Send,
158    V: Send,
159    E: DeriveKey<V>,
160{
161    /// Create new secondary index files for `name` alongside `base`.
162    pub(crate) fn create(name: &str, base: &Path) -> IsamResult<Self> {
163        Ok(Self {
164            name: name.to_owned(),
165            base: base.to_path_buf(),
166            store: DataStore::create(&sidb_path(base, name))?,
167            btree: BTree::create(&sidx_path(base, name))?,
168            _phantom: PhantomData,
169        })
170    }
171
172    /// Open existing secondary index files for `name` alongside `base`.
173    pub(crate) fn open(name: &str, base: &Path) -> IsamResult<Self> {
174        Ok(Self {
175            name: name.to_owned(),
176            base: base.to_path_buf(),
177            store: DataStore::open(&sidb_path(base, name))?,
178            btree: BTree::open(&sidx_path(base, name))?,
179            _phantom: PhantomData,
180        })
181    }
182
183    /// Open existing files if present, otherwise create new ones.
184    pub(crate) fn create_or_open(name: &str, base: &Path) -> IsamResult<Self> {
185        if sidb_path(base, name).exists() {
186            Self::open(name, base)
187        } else {
188            Self::create(name, base)
189        }
190    }
191
192    // ── Private helpers ───────────────────────────────────────────────── //
193
194    /// Read the current primary-key bucket for `sk`, or an empty vec.
195    fn read_pks(&mut self, sk: &E::Key) -> IsamResult<Vec<K>> {
196        match self.btree.search(sk)? {
197            None => Ok(Vec::new()),
198            Some(rec) => self.store.read_value(rec),
199        }
200    }
201
202    /// Write (append + update index) a primary-key bucket for `sk`.
203    fn write_pks(&mut self, sk: &E::Key, pks: &[K]) -> IsamResult<()> {
204        let exists = self.btree.search(sk)?.is_some();
205        let rec = self.store.append(sk, &pks)?;
206        if exists {
207            self.btree.update(sk, rec)?;
208        } else {
209            self.btree.insert(sk, rec)?;
210        }
211        Ok(())
212    }
213
214    /// Add `pk` to the bucket for `sk` (no-op if already present).
215    fn add_pk(&mut self, sk: &E::Key, pk: &K) -> IsamResult<()> {
216        let mut pks = self.read_pks(sk)?;
217        if !pks.contains(pk) {
218            pks.push(pk.clone());
219            self.write_pks(sk, &pks)?;
220        }
221        Ok(())
222    }
223
224    /// Remove `pk` from the bucket for `sk`.  Deletes the bucket when empty.
225    fn remove_pk(&mut self, sk: &E::Key, pk: &K) -> IsamResult<()> {
226        let mut pks = self.read_pks(sk)?;
227        pks.retain(|k| k != pk);
228        if pks.is_empty() {
229            // Ignore KeyNotFound — bucket may already be absent.
230            let _ = self.btree.delete(sk);
231        } else {
232            self.write_pks(sk, &pks)?;
233        }
234        Ok(())
235    }
236}
237
238impl<K, V, E> AnySecondaryIndex<K, V> for SecondaryIndexImpl<K, V, E>
239where
240    K: Serialize + DeserializeOwned + Ord + Clone + Send,
241    V: Send,
242    E: DeriveKey<V>,
243{
244    fn on_insert(&mut self, key: &K, value: &V) -> IsamResult<()> {
245        let sk = E::derive(value);
246        self.add_pk(&sk, key)
247    }
248
249    fn on_update(&mut self, key: &K, old_value: &V, new_value: &V) -> IsamResult<()> {
250        let old_sk = E::derive(old_value);
251        let new_sk = E::derive(new_value);
252        if old_sk != new_sk {
253            self.remove_pk(&old_sk, key)?;
254            self.add_pk(&new_sk, key)?;
255        }
256        Ok(())
257    }
258
259    fn on_delete(&mut self, key: &K, value: &V) -> IsamResult<()> {
260        let sk = E::derive(value);
261        self.remove_pk(&sk, key)
262    }
263
264    // Undo operations are exact inverses of the forward operations.
265
266    fn undo_insert(&mut self, key: &K, value: &V) -> IsamResult<()> {
267        self.on_delete(key, value)
268    }
269
270    fn undo_update(&mut self, key: &K, old_value: &V, new_value: &V) -> IsamResult<()> {
271        // Swap old/new to reverse the direction.
272        self.on_update(key, new_value, old_value)
273    }
274
275    fn undo_delete(&mut self, key: &K, value: &V) -> IsamResult<()> {
276        self.on_insert(key, value)
277    }
278
279    fn lookup_primary_keys(&mut self, sk_bytes: &[u8]) -> IsamResult<Vec<K>> {
280        let sk: E::Key = bincode::deserialize(sk_bytes)?;
281        self.read_pks(&sk)
282    }
283
284    fn range_primary_keys(
285        &mut self,
286        start: Bound<&[u8]>,
287        end: Bound<&[u8]>,
288    ) -> IsamResult<Vec<(Vec<u8>, Vec<K>)>> {
289        let start_bound: Bound<E::Key> = match start {
290            Bound::Included(b) => Bound::Included(bincode::deserialize(b)?),
291            Bound::Excluded(b) => Bound::Excluded(bincode::deserialize(b)?),
292            Bound::Unbounded => Bound::Unbounded,
293        };
294        let end_bound: Bound<E::Key> = match end {
295            Bound::Included(b) => Bound::Included(bincode::deserialize(b)?),
296            Bound::Excluded(b) => Bound::Excluded(bincode::deserialize(b)?),
297            Bound::Unbounded => Bound::Unbounded,
298        };
299
300        let start_leaf_id = match &start_bound {
301            Bound::Included(k) | Bound::Excluded(k) => self.btree.find_leaf_for_key(k)?,
302            Bound::Unbounded => self.btree.first_leaf_id()?,
303        };
304
305        let mut results = Vec::new();
306        let mut leaf_id = start_leaf_id;
307
308        'outer: while leaf_id != 0 {
309            let (entries, next_leaf_id) = self.btree.read_leaf(leaf_id)?;
310
311            for (sk, rec) in entries {
312                let in_start = match &start_bound {
313                    Bound::Included(lo) => &sk >= lo,
314                    Bound::Excluded(lo) => &sk > lo,
315                    Bound::Unbounded => true,
316                };
317                let in_end = match &end_bound {
318                    Bound::Included(hi) => &sk <= hi,
319                    Bound::Excluded(hi) => &sk < hi,
320                    Bound::Unbounded => true,
321                };
322
323                if !in_end {
324                    break 'outer;
325                }
326                if in_start {
327                    let sk_bytes = bincode::serialize(&sk)?;
328                    let pks: Vec<K> = self.store.read_value(rec)?;
329                    results.push((sk_bytes, pks));
330                }
331            }
332
333            leaf_id = next_leaf_id;
334        }
335
336        Ok(results)
337    }
338
339    fn fsync(&mut self) -> IsamResult<()> {
340        self.store.fsync()?;
341        self.btree.fsync()
342    }
343
344    fn name(&self) -> &str {
345        &self.name
346    }
347
348    fn extractor_type_name(&self) -> &'static str {
349        std::any::type_name::<E>()
350    }
351
352    fn stored_schema_version(&self) -> u32 {
353        self.btree.index_schema_version()
354    }
355
356    fn persist_schema_version(&mut self, version: u32) -> IsamResult<()> {
357        self.btree.set_index_schema_version(version)
358    }
359
360    fn reset(&mut self) -> IsamResult<()> {
361        self.store = DataStore::create(&sidb_path(&self.base, &self.name))?;
362        self.btree = BTree::create(&sidx_path(&self.base, &self.name))?;
363        Ok(())
364    }
365}
366
367// ── Path helpers ──────────────────────────────────────────────────────────── //
368
369pub(crate) fn sidb_path(base: &Path, name: &str) -> PathBuf {
370    let parent = base.parent().unwrap_or(Path::new(""));
371    let stem = base.file_stem().unwrap_or_default().to_string_lossy();
372    parent.join(format!("{stem}_{name}.sidb"))
373}
374
375pub(crate) fn sidx_path(base: &Path, name: &str) -> PathBuf {
376    let parent = base.parent().unwrap_or(Path::new(""));
377    let stem = base.file_stem().unwrap_or_default().to_string_lossy();
378    parent.join(format!("{stem}_{name}.sidx"))
379}