Skip to main content

marsdb_graph/
index.rs

1//! Property indexes: `(label, property) -> node_ids` keyed by an
2//! order-preserving encoding of the property's value. See
3//! `marsdb_storage::tables::{INDEX_DEFS, PROPERTY_INDEX}` for the on-disk
4//! layout this module builds keys for.
5
6use std::collections::BTreeMap;
7
8use marsdb_storage::{ReadableMultimapTable, ReadableTable, Txn};
9use serde::{Deserialize, Serialize};
10
11use crate::error::GraphError;
12use crate::labels::lookup_label_id;
13use crate::model::{NodeId, PropertyValue};
14use crate::props::lookup_prop_id;
15use crate::write_ctx::WriteCtx;
16
17#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
18pub struct IndexDef {
19    pub unique: bool,
20}
21
22/// `label_id(4 bytes BE) ++ property_id(4 bytes BE)` — the key `INDEX_DEFS`
23/// uses, and the fixed prefix every `PROPERTY_INDEX` entry for this
24/// (label, property) pair starts with.
25fn index_prefix(label_id: u32, prop_id: u32) -> [u8; 8] {
26    let mut out = [0u8; 8];
27    out[0..4].copy_from_slice(&label_id.to_be_bytes());
28    out[4..8].copy_from_slice(&prop_id.to_be_bytes());
29    out
30}
31
32/// Order-preserving byte encoding of a single `PropertyValue`, for use as
33/// a `PROPERTY_INDEX` key suffix. Lexicographic byte comparison matches
34/// real value ordering *within one type* (needed for a future range scan,
35/// not used yet — MVP only does exact-match lookups) — a leading type tag
36/// keeps different types from ever comparing as equal or interleaving.
37/// `Duration` has no meaningful total order (see `PropertyValue::Duration`'s
38/// own doc comment) — its encoding is only guaranteed consistent for
39/// equality, not real ordering, which is fine since nothing orders by it.
40pub(crate) fn encode_index_value(v: &PropertyValue) -> Vec<u8> {
41    match v {
42        PropertyValue::Null => vec![0x00],
43        PropertyValue::Bool(b) => vec![0x01, u8::from(*b)],
44        // Flip the sign bit so two's-complement ordering becomes correct
45        // unsigned big-endian byte ordering (the standard trick: the most
46        // negative i64 maps to all-zero bytes, the most positive to
47        // all-one bytes).
48        PropertyValue::Int(i) => {
49            let mut out = vec![0x02];
50            out.extend_from_slice(&((*i as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
51            out
52        }
53        // Standard sortable-float transform: flip the sign bit for a
54        // non-negative float (so it sorts above all negatives), flip every
55        // bit for a negative float (so more-negative sorts lower).
56        PropertyValue::Float(f) => {
57            let bits = f.to_bits();
58            let sortable = if bits & 0x8000_0000_0000_0000 != 0 {
59                !bits
60            } else {
61                bits | 0x8000_0000_0000_0000
62            };
63            let mut out = vec![0x03];
64            out.extend_from_slice(&sortable.to_be_bytes());
65            out
66        }
67        // Raw UTF-8 bytes compare correctly by codepoint for ASCII and
68        // "close enough" (not full Unicode collation) in general -- same
69        // tradeoff most embedded databases make without pulling in ICU.
70        PropertyValue::String(s) => {
71            let mut out = vec![0x04];
72            out.extend_from_slice(s.as_bytes());
73            out
74        }
75        PropertyValue::Date(days) => {
76            let mut out = vec![0x05];
77            out.extend_from_slice(&((*days as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
78            out
79        }
80        PropertyValue::Duration {
81            months,
82            days,
83            seconds,
84            nanos,
85        } => {
86            let mut out = vec![0x06];
87            out.extend_from_slice(&months.to_be_bytes());
88            out.extend_from_slice(&days.to_be_bytes());
89            out.extend_from_slice(&seconds.to_be_bytes());
90            out.extend_from_slice(&nanos.to_be_bytes());
91            out
92        }
93        // Always non-negative by construction (`0..86_400_000_000_000`) --
94        // plain BE bytes already sort correctly, no sign-flip needed.
95        PropertyValue::LocalTime(nanos_of_day) => {
96            let mut out = vec![0x07];
97            out.extend_from_slice(&nanos_of_day.to_be_bytes());
98            out
99        }
100        // Keyed by the UTC-equivalent instant-of-day (`nanos_of_day -
101        // offset_seconds`), not the raw wall-clock fields -- matches
102        // `Time`'s own equality/ordering rule (see its doc comment), so
103        // two structurally-different `Time`s that represent the same
104        // instant correctly collapse to the same index key.
105        PropertyValue::Time {
106            nanos_of_day,
107            offset_seconds,
108        } => {
109            let instant = nanos_of_day - *offset_seconds as i64 * 1_000_000_000;
110            let mut out = vec![0x08];
111            out.extend_from_slice(&((instant as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
112            out
113        }
114        PropertyValue::LocalDateTime {
115            epoch_seconds,
116            nanos,
117        } => {
118            let mut out = vec![0x09];
119            out.extend_from_slice(&((*epoch_seconds as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
120            out.extend_from_slice(&nanos.to_be_bytes());
121            out
122        }
123        // `offset_seconds` deliberately excluded -- `DateTime`'s equality/
124        // ordering is instant-only (see its doc comment), same reasoning
125        // as `Time` above.
126        PropertyValue::DateTime {
127            epoch_seconds,
128            nanos,
129            ..
130        } => {
131            let mut out = vec![0x0A];
132            out.extend_from_slice(&((*epoch_seconds as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
133            out.extend_from_slice(&nanos.to_be_bytes());
134            out
135        }
136        // No real ordering across two lists is defined/needed (same
137        // "consistent for equality, not real ordering" carve-out
138        // `Duration` above already has) -- MVP indexing only does exact-
139        // match lookups. Each element's own encoding is length-prefixed
140        // so two different lists can never collide onto the same byte
141        // string (e.g. `["ab", "c"]` vs `["a", "bc"]`, which otherwise
142        // concatenate to visually-different but genuinely ambiguous byte
143        // runs once strings' own raw-UTF-8, non-length-prefixed encoding
144        // is stacked back to back).
145        PropertyValue::List(items) => {
146            let mut out = vec![0x0B];
147            for item in items {
148                let encoded = encode_index_value(item);
149                out.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
150                out.extend_from_slice(&encoded);
151            }
152            out
153        }
154        // Never reaches here: `Map` is only ever constructed on the
155        // parameter-passing path (`PropertyValue`'s own doc comment), and
156        // nothing ever stores -- so nothing ever indexes -- a real node/
157        // edge property this way.
158        PropertyValue::Map(_) => {
159            unreachable!("PropertyValue::Map is never a real stored/indexed property value")
160        }
161    }
162}
163
164fn index_key(label_id: u32, prop_id: u32, value: &PropertyValue) -> Vec<u8> {
165    let mut out = index_prefix(label_id, prop_id).to_vec();
166    out.extend_from_slice(&encode_index_value(value));
167    out
168}
169
170/// Declares an index on `(label, prop)` and backfills it from every
171/// existing node carrying `label`. Errors (without creating the index) if
172/// `unique` is requested and two existing nodes already share a value.
173/// Idempotent by (label, prop) identity, not by `unique`-ness — calling
174/// this again on an already-indexed pair is an error, same as most
175/// databases' `CREATE INDEX` (no silent redefinition).
176pub(crate) fn create_index(
177    ctx: &mut WriteCtx,
178    label: &str,
179    prop: &str,
180    unique: bool,
181) -> Result<(), GraphError> {
182    let label_id = crate::labels::intern_label(ctx, label)?;
183    let prop_id = crate::props::intern_prop(ctx, prop)?;
184    let prefix = index_prefix(label_id, prop_id);
185    if ctx.index_defs()?.get(prefix.as_slice())?.is_some() {
186        return Err(GraphError::CorruptData(format!(
187            "index on label {label:?} property {prop:?} already exists"
188        )));
189    }
190
191    // Backfill: walk every node with this label (via the existing
192    // NODE_LABEL_INDEX secondary index, not a full NODES scan) and index
193    // whatever value it currently has for `prop` (skipping nodes missing
194    // it entirely -- a missing property never appears in the index, same
195    // as `IS NULL`/absence being indistinguishable elsewhere in this
196    // codebase).
197    let node_ids: Vec<u64> = ctx
198        .node_label_index()?
199        .get(label_id)?
200        .map(|entry| entry.map(|value| value.value()).map_err(GraphError::from))
201        .collect::<Result<Vec<_>, GraphError>>()?;
202    let mut entries: Vec<(Vec<u8>, u64)> = Vec::with_capacity(node_ids.len());
203    for node_id in &node_ids {
204        let Some(guard) = ctx.nodes()?.get(*node_id)? else {
205            continue;
206        };
207        // Directory-format fast path: `prop_id` is already interned above,
208        // so the backfill reads exactly the one indexed property per node —
209        // no full-record decode, no name resolution at all.
210        if let Some(raw) = crate::encode::node_prop_raw(guard.value(), prop_id)? {
211            let value = crate::encode::decode_value(raw)?;
212            entries.push((index_key(label_id, prop_id, &value), *node_id));
213        }
214    }
215    if unique {
216        let mut seen = std::collections::HashSet::with_capacity(entries.len());
217        for (key, _) in &entries {
218            if !seen.insert(key.clone()) {
219                return Err(GraphError::UniqueConstraintViolation {
220                    label: label.to_string(),
221                    property: prop.to_string(),
222                });
223            }
224        }
225    }
226
227    let encoded = postcard::to_allocvec(&IndexDef { unique })?;
228    ctx.index_defs()?
229        .insert(prefix.as_slice(), encoded.as_slice())?;
230    for (key, node_id) in entries {
231        ctx.property_index()?.insert(key.as_slice(), node_id)?;
232    }
233    Ok(())
234}
235
236/// `None` means no index is declared on `(label, prop)`.
237pub fn lookup_index_def(txn: Txn, label: &str, prop: &str) -> Result<Option<IndexDef>, GraphError> {
238    let Some(label_id) = lookup_label_id(txn, label)? else {
239        return Ok(None);
240    };
241    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
242        return Ok(None);
243    };
244    let prefix = index_prefix(label_id, prop_id);
245    let defs = txn.open_table(marsdb_storage::tables::INDEX_DEFS)?;
246    let found = defs
247        .get(prefix.as_slice())?
248        .map(|guard| guard.value().to_vec());
249    drop(defs);
250    match found {
251        Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)),
252        None => Ok(None),
253    }
254}
255
256/// Exact-match lookup: every node currently indexed under `(label, prop) =
257/// value`, up to `limit` of them if given. Caller (the planner, in a
258/// later change) is responsible for checking `lookup_index_def` first —
259/// this returns an empty result, not an error, if no such index exists
260/// (matching a genuinely-empty index would look the same, and this
261/// function has no way to tell those apart itself without the same
262/// lookup its caller likely already did). `limit` bounds the underlying
263/// multimap iterator itself (`.take(limit)` before collecting, not a
264/// truncate after) — same real fix this same class of bug needed for
265/// `NODE_LABEL_INDEX` (see `GraphStore::all_nodes_limited_in_txn`'s
266/// history): truncating *after* `collect()` would still walk every
267/// matching entry first, defeating the point of a `LIMIT` push-down.
268pub fn lookup_exact(
269    txn: Txn,
270    label: &str,
271    prop: &str,
272    value: &PropertyValue,
273    limit: Option<usize>,
274) -> Result<Vec<NodeId>, GraphError> {
275    let Some(label_id) = lookup_label_id(txn, label)? else {
276        return Ok(Vec::new());
277    };
278    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
279        return Ok(Vec::new());
280    };
281    let key = index_key(label_id, prop_id, value);
282    let index = txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
283    let iter = index.get(key.as_slice())?;
284    let ids: Vec<NodeId> = match limit {
285        Some(limit) => iter
286            .take(limit)
287            .map(|entry| {
288                entry
289                    .map(|value| NodeId(value.value()))
290                    .map_err(GraphError::from)
291            })
292            .collect::<Result<Vec<_>, GraphError>>()?,
293        None => iter
294            .map(|entry| {
295                entry
296                    .map(|value| NodeId(value.value()))
297                    .map_err(GraphError::from)
298            })
299            .collect::<Result<Vec<_>, GraphError>>()?,
300    };
301    drop(index);
302    Ok(ids)
303}
304
305/// Cheap, exact cardinality of `(label, prop) = value` under a declared
306/// index — the stat the query planner uses to pick the most selective
307/// candidate when several indexed equality conjuncts are available for the
308/// same scan (see `marsdb_query::planner::apply_index_seeks`). O(1): redb's
309/// `MultimapValue::len()` reports a count it already tracks per key, so
310/// this never walks the matching entries themselves, unlike `lookup_exact`.
311/// Returns 0 if no such index/value exists (same "caller already checked
312/// `lookup_index_def`" contract as `lookup_exact`).
313pub fn match_count(
314    txn: Txn,
315    label: &str,
316    prop: &str,
317    value: &PropertyValue,
318) -> Result<u64, GraphError> {
319    let Some(label_id) = lookup_label_id(txn, label)? else {
320        return Ok(0);
321    };
322    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
323        return Ok(0);
324    };
325    let key = index_key(label_id, prop_id, value);
326    let index = txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
327    let count = index.get(key.as_slice())?.len();
328    Ok(count)
329}
330
331/// Every declared index whose label is in `label_ids`, as `(label_id,
332/// prop_id, prop_name, IndexDef)`. `INDEX_DEFS` is scanned in full (not a
333/// prefix-range query — `TableHandle` only exposes `get`/`iter`, and the
334/// number of *declared indexes* is expected to be small, unlike node
335/// counts) and filtered in memory.
336fn indexes_for_labels(
337    ctx: &mut WriteCtx,
338    label_ids: &[u32],
339) -> Result<Vec<(u32, u32, String, IndexDef)>, GraphError> {
340    // Collected into an owned Vec first, not resolved inline in the loop
341    // below -- `ctx.index_defs()?.iter()?` holds `ctx` mutably borrowed for
342    // the iterator's whole lifetime, and `resolve_prop_ctx` below needs its
343    // own fresh `&mut ctx` (to lazily open `id_to_prop`), which can't
344    // coexist with that borrow.
345    let raw: Vec<(u32, u32, IndexDef)> = {
346        let mut raw = Vec::new();
347        for entry in ctx.index_defs()?.iter()? {
348            let (key, value) = entry?;
349            let key_bytes = key.value();
350            let label_id = u32::from_be_bytes(
351                key_bytes[0..4]
352                    .try_into()
353                    .expect("index key prefix is 8 bytes"),
354            );
355            if !label_ids.contains(&label_id) {
356                continue;
357            }
358            let prop_id = u32::from_be_bytes(
359                key_bytes[4..8]
360                    .try_into()
361                    .expect("index key prefix is 8 bytes"),
362            );
363            let def: IndexDef = postcard::from_bytes(value.value())?;
364            raw.push((label_id, prop_id, def));
365        }
366        raw
367    };
368    raw.into_iter()
369        .map(|(label_id, prop_id, def)| {
370            let prop_name = resolve_prop_ctx(ctx, prop_id)?;
371            Ok((label_id, prop_id, prop_name, def))
372        })
373        .collect()
374}
375
376/// `labels::resolve_label`/`props::resolve_prop` equivalents reading
377/// directly from an already-open `WriteCtx` handle, instead of opening
378/// `ID_TO_LABEL`/`ID_TO_PROP` again via `Txn` (which `WriteCtx` already
379/// holds open -- a second live handle to the same table would be
380/// `TableAlreadyOpen`). Small deliberate duplication, not a shared helper
381/// with the `Txn`-based versions -- those stay untouched for the read
382/// path (see `WriteCtx`'s own docs).
383pub(crate) fn resolve_label_ctx(ctx: &mut WriteCtx, label_id: u32) -> Result<String, GraphError> {
384    let value = ctx.id_to_label()?.get(label_id)?.ok_or_else(|| {
385        GraphError::CorruptData(format!("label id {label_id} has no interned string"))
386    })?;
387    Ok(value.value().to_string())
388}
389
390pub(crate) fn resolve_prop_ctx(ctx: &mut WriteCtx, prop_id: u32) -> Result<String, GraphError> {
391    let value = ctx.id_to_prop()?.get(prop_id)?.ok_or_else(|| {
392        GraphError::CorruptData(format!("prop id {prop_id} has no interned string"))
393    })?;
394    Ok(value.value().to_string())
395}
396
397/// Identifies one declared index, both by id (for the actual key/lookup)
398/// and by name (only needed for a `UniqueConstraintViolation`'s message).
399/// Bundled into one struct so `insert_entry` doesn't take 8 separate
400/// arguments (clippy's `too_many_arguments`, capped at 7).
401struct IndexTarget<'a> {
402    label_id: u32,
403    prop_id: u32,
404    label: &'a str,
405    prop: &'a str,
406}
407
408fn insert_entry(
409    ctx: &mut WriteCtx,
410    target: &IndexTarget<'_>,
411    value: &PropertyValue,
412    node_id: u64,
413    unique: bool,
414) -> Result<(), GraphError> {
415    let key = index_key(target.label_id, target.prop_id, value);
416    if unique && ctx.property_index()?.get(key.as_slice())?.next().is_some() {
417        return Err(GraphError::UniqueConstraintViolation {
418            label: target.label.to_string(),
419            property: target.prop.to_string(),
420        });
421    }
422    ctx.property_index()?.insert(key.as_slice(), node_id)?;
423    Ok(())
424}
425
426fn remove_entry(
427    ctx: &mut WriteCtx,
428    label_id: u32,
429    prop_id: u32,
430    value: &PropertyValue,
431    node_id: u64,
432) -> Result<(), GraphError> {
433    let key = index_key(label_id, prop_id, value);
434    ctx.property_index()?.remove(key.as_slice(), node_id)?;
435    Ok(())
436}
437
438/// Inserts index entries for `node_id` into every declared index whose
439/// label is in `label_ids` and whose property `props` has a value for.
440/// Called on node creation (`label_ids` = every label the node was just
441/// given) and on `SET n:Label` (`label_ids` = just the one newly-added
442/// label — indexes on labels the node already had are untouched, since
443/// nothing about their entries changed).
444pub(crate) fn on_node_created(
445    ctx: &mut WriteCtx,
446    node_id: u64,
447    label_ids: &[u32],
448    props: &BTreeMap<String, PropertyValue>,
449) -> Result<(), GraphError> {
450    for (label_id, prop_id, prop_name, def) in indexes_for_labels(ctx, label_ids)? {
451        if let Some(value) = props.get(&prop_name) {
452            let label = resolve_label_ctx(ctx, label_id)?;
453            let target = IndexTarget {
454                label_id,
455                prop_id,
456                label: &label,
457                prop: &prop_name,
458            };
459            insert_entry(ctx, &target, value, node_id, def.unique)?;
460        }
461    }
462    Ok(())
463}
464
465/// Removes `node_id`'s index entries from every declared index whose label
466/// is in `label_ids` and whose property `props` (the values *before* this
467/// change) has a value for. Called on node deletion (`label_ids` = every
468/// label the node had) and on `REMOVE n:Label` (`label_ids` = just the one
469/// removed label).
470pub(crate) fn on_node_deleted(
471    ctx: &mut WriteCtx,
472    node_id: u64,
473    label_ids: &[u32],
474    props: &BTreeMap<String, PropertyValue>,
475) -> Result<(), GraphError> {
476    for (label_id, prop_id, prop_name, _def) in indexes_for_labels(ctx, label_ids)? {
477        if let Some(value) = props.get(&prop_name) {
478            remove_entry(ctx, label_id, prop_id, value, node_id)?;
479        }
480    }
481    Ok(())
482}
483
484/// One property's value changed on an existing node (`SET n.prop = ..`/
485/// `REMOVE n.prop`) — removes the old index entry (if `old_value` is
486/// `Some` and an index covers `(label, prop)` for one of `label_ids`) and
487/// inserts the new one (if `new_value` is `Some`). `new_value: None`
488/// means the property was removed entirely, not set to `null` — a
489/// `PropertyValue::Null` value is still `Some(&PropertyValue::Null)` here
490/// and gets indexed like any other value (matches `create_index`'s own
491/// backfill, which only skips a property that's *absent*, not one whose
492/// value is `Null`).
493pub(crate) fn on_node_prop_changed(
494    ctx: &mut WriteCtx,
495    node_id: u64,
496    label_ids: &[u32],
497    prop: &str,
498    old_value: Option<&PropertyValue>,
499    new_value: Option<&PropertyValue>,
500) -> Result<(), GraphError> {
501    for (label_id, prop_id, prop_name, def) in indexes_for_labels(ctx, label_ids)? {
502        if prop_name != prop {
503            continue;
504        }
505        if let Some(old) = old_value {
506            remove_entry(ctx, label_id, prop_id, old, node_id)?;
507        }
508        if let Some(new) = new_value {
509            let label = resolve_label_ctx(ctx, label_id)?;
510            let target = IndexTarget {
511                label_id,
512                prop_id,
513                label: &label,
514                prop: &prop_name,
515            };
516            insert_entry(ctx, &target, new, node_id, def.unique)?;
517        }
518    }
519    Ok(())
520}