Skip to main content

nodedb_lite/engine/spatial/
index.rs

1//! Per-collection spatial index manager for Lite.
2//!
3//! Wraps `nodedb_spatial::RTree` with:
4//! - Incremental insert/delete on document put/delete
5//! - Geometry extraction from document fields
6//! - Checkpoint/restore via MessagePack + CRC32C (same pattern as HNSW/CSR)
7//! - Spatial query execution (range search, nearest neighbor)
8
9use std::collections::HashMap;
10
11type CheckpointData = (
12    Vec<(String, String, Vec<u8>)>,
13    HashMap<(String, String), u64>,
14    u64,
15);
16
17use nodedb_mem::ScopedMemory;
18use nodedb_spatial::rtree::{RTree, RTreeEntry};
19use nodedb_types::BoundingBox;
20use nodedb_types::geometry::Geometry;
21
22/// Manages per-collection R-tree spatial indexes.
23///
24/// Each collection that has geometry fields gets its own R-tree.
25/// The field name is stored alongside so we know which document field
26/// to extract geometry from.
27pub struct SpatialIndexManager {
28    /// (collection_name, field_name) → R-tree.
29    indices: HashMap<(String, String), RTree>,
30    /// Document ID → entry ID mapping for deletion.
31    /// Key: (collection, doc_id), Value: entry_id in R-tree.
32    doc_to_entry: HashMap<(String, String), u64>,
33    /// Inverse map: entry_id → (collection, doc_id) for scan result resolution.
34    entry_to_doc: HashMap<u64, (String, String)>,
35    /// Next entry ID (monotonically increasing).
36    next_id: u64,
37    /// Governor handle bound to the spatial engine budget. Cloned into every
38    /// R-tree this manager creates.
39    memory: ScopedMemory,
40}
41
42impl SpatialIndexManager {
43    pub fn new(memory: ScopedMemory) -> Self {
44        Self {
45            indices: HashMap::new(),
46            doc_to_entry: HashMap::new(),
47            entry_to_doc: HashMap::new(),
48            next_id: 1,
49            memory,
50        }
51    }
52
53    /// Resolve an R-tree entry ID to its document ID within a collection.
54    pub fn doc_id_for_entry(&self, entry_id: u64) -> Option<&str> {
55        self.entry_to_doc
56            .get(&entry_id)
57            .map(|(_, doc_id)| doc_id.as_str())
58    }
59
60    /// Index a geometry from a document. If the document already has an entry,
61    /// it is removed first (upsert semantics).
62    pub fn index_document(
63        &mut self,
64        collection: &str,
65        field: &str,
66        doc_id: &str,
67        geometry: &Geometry,
68    ) {
69        let key = (collection.to_string(), field.to_string());
70        let doc_key = (collection.to_string(), doc_id.to_string());
71
72        // Remove old entry if this document was previously indexed.
73        if let Some(old_id) = self.doc_to_entry.remove(&doc_key) {
74            self.entry_to_doc.remove(&old_id);
75            if let Some(tree) = self.indices.get_mut(&key) {
76                tree.delete(old_id);
77            }
78        }
79
80        let bbox = nodedb_types::geometry_bbox(geometry);
81        let entry_id = self.next_id;
82        self.next_id += 1;
83
84        let memory = self.memory.clone();
85        let tree = self
86            .indices
87            .entry(key)
88            .or_insert_with(|| RTree::new(memory));
89        tree.insert(RTreeEntry { id: entry_id, bbox });
90        self.doc_to_entry.insert(doc_key, entry_id);
91        self.entry_to_doc
92            .insert(entry_id, (collection.to_string(), doc_id.to_string()));
93    }
94
95    /// Remove a document's geometry from the index.
96    pub fn remove_document(&mut self, collection: &str, field: &str, doc_id: &str) {
97        let key = (collection.to_string(), field.to_string());
98        let doc_key = (collection.to_string(), doc_id.to_string());
99
100        if let Some(entry_id) = self.doc_to_entry.remove(&doc_key) {
101            self.entry_to_doc.remove(&entry_id);
102            if let Some(tree) = self.indices.get_mut(&key) {
103                tree.delete(entry_id);
104            }
105        }
106    }
107
108    /// Remove every entry of every R-tree `collection` owns. Each tree stays
109    /// registered under its `(collection, field)` key, so the next checkpoint
110    /// writes it out empty instead of leaving a stale blob behind. Returns
111    /// the `(field, doc_id)` of every entry removed.
112    pub fn truncate_collection(&mut self, collection: &str) -> Vec<(String, String)> {
113        let fields: Vec<String> = self
114            .indices
115            .keys()
116            .filter(|(coll, _)| coll == collection)
117            .map(|(_, field)| field.clone())
118            .collect();
119        let mut removed = Vec::new();
120        for field in fields {
121            let key = (collection.to_string(), field.clone());
122            let Some(tree) = self.indices.get_mut(&key) else {
123                continue;
124            };
125            for entry in tree.entries() {
126                if let Some((_, doc_id)) = self.entry_to_doc.get(&entry.id) {
127                    removed.push((field.clone(), doc_id.clone()));
128                }
129            }
130            *tree = RTree::new(self.memory.clone());
131        }
132        self.doc_to_entry.retain(|(coll, _), entry_id| {
133            let owned = coll == collection;
134            if owned {
135                self.entry_to_doc.remove(entry_id);
136            }
137            !owned
138        });
139        removed
140    }
141
142    /// Range search: find all document entry IDs whose bbox intersects the query.
143    pub fn search(&self, collection: &str, field: &str, query: &BoundingBox) -> Vec<&RTreeEntry> {
144        let key = (collection.to_string(), field.to_string());
145        match self.indices.get(&key) {
146            Some(tree) => tree.search(query),
147            None => Vec::new(),
148        }
149    }
150
151    /// Nearest-neighbor search.
152    pub fn nearest(
153        &self,
154        collection: &str,
155        field: &str,
156        lng: f64,
157        lat: f64,
158        k: usize,
159    ) -> Vec<nodedb_spatial::rtree::NnResult> {
160        let key = (collection.to_string(), field.to_string());
161        match self.indices.get(&key) {
162            Some(tree) => tree.nearest(lng, lat, k),
163            None => Vec::new(),
164        }
165    }
166
167    /// Number of indexed entries across all collections.
168    /// Whether no spatial indices exist.
169    pub fn is_empty(&self) -> bool {
170        self.indices.is_empty()
171    }
172
173    pub fn total_entries(&self) -> usize {
174        self.indices.values().map(|t| t.len()).sum()
175    }
176
177    /// Number of indexed collections.
178    pub fn collection_count(&self) -> usize {
179        self.indices.len()
180    }
181
182    /// Checkpoint all R-trees to bytes for persistence.
183    ///
184    /// Returns a vec of `(collection, field, rtree_bytes)`.
185    pub fn checkpoint_all(&self) -> Vec<(String, String, Vec<u8>)> {
186        let mut results = Vec::new();
187        for ((collection, field), tree) in &self.indices {
188            match tree.checkpoint_to_bytes(None) {
189                Ok(bytes) => results.push((collection.clone(), field.clone(), bytes)),
190                Err(e) => {
191                    tracing::error!(
192                        collection = %collection,
193                        field = %field,
194                        error = %e,
195                        "spatial index checkpoint failed"
196                    );
197                }
198            }
199        }
200        results
201    }
202
203    /// Return the data needed by the checkpoint module to persist full state.
204    ///
205    /// Returns `(rtree_checkpoints, doc_to_entry, next_id)`.
206    pub fn checkpoint_data(&self) -> CheckpointData {
207        (
208            self.checkpoint_all(),
209            self.doc_to_entry.clone(),
210            self.next_id,
211        )
212    }
213
214    /// Load a fully-restored checkpoint.
215    ///
216    /// Replaces the current in-memory state with the provided R-tree
217    /// checkpoints and the exact `doc_id → entry_id` mapping that was
218    /// serialised at flush time.
219    pub fn load_checkpoint(
220        &mut self,
221        checkpoints: &[(String, String, Vec<u8>)],
222        doc_to_entry: HashMap<(String, String), u64>,
223        next_id: u64,
224    ) {
225        // Rebuild inverse map from the restored forward map.
226        self.entry_to_doc = doc_to_entry
227            .iter()
228            .map(|((col, doc_id), &eid)| (eid, (col.clone(), doc_id.clone())))
229            .collect();
230        self.doc_to_entry = doc_to_entry;
231        self.next_id = next_id;
232        for (collection, field, bytes) in checkpoints {
233            match RTree::from_checkpoint(bytes, None, self.memory.clone()) {
234                Ok(tree) => {
235                    self.indices
236                        .insert((collection.clone(), field.clone()), tree);
237                }
238                Err(e) => {
239                    tracing::warn!(
240                        collection = %collection,
241                        field = %field,
242                        error = %e,
243                        "spatial R-tree restore failed; collection will be empty until rebuilt"
244                    );
245                }
246            }
247        }
248    }
249
250    /// Restore R-trees from raw checkpoint bytes only (no doc_to_entry).
251    ///
252    /// Used as a fallback when no docmap is available (e.g. legacy checkpoints
253    /// written before this field was introduced). After restoration, upserts
254    /// and deletes of already-indexed docs may not evict stale entries; a full
255    /// rebuild from documents is the reliable recovery path in that case.
256    pub fn restore_all(checkpoints: &[(String, String, Vec<u8>)], memory: ScopedMemory) -> Self {
257        let mut manager = Self::new(memory);
258        for (collection, field, bytes) in checkpoints {
259            match RTree::from_checkpoint(bytes, None, manager.memory.clone()) {
260                Ok(tree) => {
261                    let max_id = tree.entries().iter().map(|e| e.id).max().unwrap_or(0);
262                    if max_id >= manager.next_id {
263                        manager.next_id = max_id + 1;
264                    }
265                    manager
266                        .indices
267                        .insert((collection.clone(), field.clone()), tree);
268                }
269                Err(e) => {
270                    tracing::warn!(
271                        collection = %collection,
272                        field = %field,
273                        error = %e,
274                        "spatial index restore failed, will rebuild from documents"
275                    );
276                }
277            }
278        }
279        manager
280    }
281
282    /// Rebuild spatial index from a collection of documents.
283    ///
284    /// Scans all documents, extracts geometry from the specified field,
285    /// and builds the R-tree.
286    pub fn rebuild_from_documents(
287        &mut self,
288        collection: &str,
289        field: &str,
290        documents: &[(String, Geometry)],
291    ) {
292        let entries: Vec<RTreeEntry> = documents
293            .iter()
294            .map(|(doc_id, geom)| {
295                let id = self.next_id;
296                self.next_id += 1;
297                let doc_key = (collection.to_string(), doc_id.clone());
298                self.doc_to_entry.insert(doc_key, id);
299                self.entry_to_doc
300                    .insert(id, (collection.to_string(), doc_id.clone()));
301                RTreeEntry {
302                    id,
303                    bbox: nodedb_types::geometry_bbox(geom),
304                }
305            })
306            .collect();
307
308        let tree = RTree::bulk_load(entries, self.memory.clone());
309        self.indices
310            .insert((collection.to_string(), field.to_string()), tree);
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use std::sync::Arc;
317
318    use nodedb_mem::{EngineId, EngineLimits, GovernorConfig, MemoryGovernor};
319    use nodedb_types::{DatabaseId, TenantId};
320
321    use super::*;
322
323    /// Build a real, uncapped governor scoped to the spatial engine for tests.
324    fn test_memory() -> ScopedMemory {
325        let per_engine = usize::MAX / EngineId::ALL.len();
326        let governor = Arc::new(
327            MemoryGovernor::new(GovernorConfig {
328                global_ceiling: per_engine * EngineId::ALL.len(),
329                engine_limits: EngineLimits::uniform(per_engine),
330            })
331            .expect("test governor"),
332        );
333        ScopedMemory::new(
334            governor,
335            DatabaseId::DEFAULT,
336            TenantId::new(0),
337            EngineId::Spatial,
338        )
339    }
340
341    #[test]
342    fn index_and_search() {
343        let mut mgr = SpatialIndexManager::new(test_memory());
344        mgr.index_document("places", "location", "doc1", &Geometry::point(10.0, 20.0));
345        mgr.index_document("places", "location", "doc2", &Geometry::point(11.0, 21.0));
346        mgr.index_document("places", "location", "doc3", &Geometry::point(50.0, 50.0));
347
348        let results = mgr.search(
349            "places",
350            "location",
351            &BoundingBox::new(9.0, 19.0, 12.0, 22.0),
352        );
353        assert_eq!(results.len(), 2);
354    }
355
356    #[test]
357    fn truncate_collection_empties_only_that_collection() {
358        let mut mgr = SpatialIndexManager::new(test_memory());
359        mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
360        mgr.index_document("places", "loc", "doc2", &Geometry::point(11.0, 21.0));
361        mgr.index_document("other", "loc", "doc9", &Geometry::point(10.0, 20.0));
362
363        let mut removed = mgr.truncate_collection("places");
364        removed.sort();
365        assert_eq!(
366            removed,
367            vec![
368                ("loc".to_string(), "doc1".to_string()),
369                ("loc".to_string(), "doc2".to_string())
370            ]
371        );
372        let bbox = BoundingBox::new(9.0, 19.0, 12.0, 22.0);
373        assert!(mgr.search("places", "loc", &bbox).is_empty());
374        assert_eq!(mgr.search("other", "loc", &bbox).len(), 1);
375        assert_eq!(
376            mgr.collection_count(),
377            2,
378            "the emptied tree stays registered"
379        );
380        assert!(mgr.doc_id_for_entry(1).is_none());
381
382        mgr.index_document("places", "loc", "doc3", &Geometry::point(10.5, 20.5));
383        assert_eq!(mgr.search("places", "loc", &bbox).len(), 1);
384    }
385
386    #[test]
387    fn upsert_replaces_old_entry() {
388        let mut mgr = SpatialIndexManager::new(test_memory());
389        mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
390        mgr.index_document("places", "loc", "doc1", &Geometry::point(50.0, 50.0));
391
392        // Old location should not be found.
393        let old = mgr.search("places", "loc", &BoundingBox::new(9.0, 19.0, 12.0, 22.0));
394        assert!(old.is_empty());
395
396        // New location should be found.
397        let new = mgr.search("places", "loc", &BoundingBox::new(49.0, 49.0, 51.0, 51.0));
398        assert_eq!(new.len(), 1);
399    }
400
401    #[test]
402    fn remove_document() {
403        let mut mgr = SpatialIndexManager::new(test_memory());
404        mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
405        mgr.remove_document("places", "loc", "doc1");
406
407        let results = mgr.search("places", "loc", &BoundingBox::new(0.0, 0.0, 180.0, 90.0));
408        assert!(results.is_empty());
409    }
410
411    #[test]
412    fn checkpoint_restore_roundtrip() {
413        let mut mgr = SpatialIndexManager::new(test_memory());
414        for i in 0..50 {
415            mgr.index_document(
416                "buildings",
417                "geom",
418                &format!("b{i}"),
419                &Geometry::point(i as f64 * 0.5, i as f64 * 0.3),
420            );
421        }
422
423        let checkpoints = mgr.checkpoint_all();
424        assert_eq!(checkpoints.len(), 1);
425
426        let restored = SpatialIndexManager::restore_all(&checkpoints, test_memory());
427        assert_eq!(restored.total_entries(), 50);
428
429        let results = restored.search(
430            "buildings",
431            "geom",
432            &BoundingBox::new(-180.0, -90.0, 180.0, 90.0),
433        );
434        assert_eq!(results.len(), 50);
435    }
436
437    #[test]
438    fn nearest_neighbor() {
439        let mut mgr = SpatialIndexManager::new(test_memory());
440        mgr.index_document("pois", "loc", "a", &Geometry::point(0.0, 0.0));
441        mgr.index_document("pois", "loc", "b", &Geometry::point(10.0, 10.0));
442        mgr.index_document("pois", "loc", "c", &Geometry::point(1.0, 1.0));
443
444        let nn = mgr.nearest("pois", "loc", 0.5, 0.5, 2);
445        assert_eq!(nn.len(), 2);
446    }
447
448    #[test]
449    fn rebuild_from_documents() {
450        let mut mgr = SpatialIndexManager::new(test_memory());
451        let docs: Vec<(String, Geometry)> = (0..100)
452            .map(|i| {
453                (
454                    format!("d{i}"),
455                    Geometry::point(i as f64 * 0.1, i as f64 * 0.1),
456                )
457            })
458            .collect();
459        mgr.rebuild_from_documents("col", "geom", &docs);
460        assert_eq!(mgr.total_entries(), 100);
461    }
462
463    #[test]
464    fn multiple_collections() {
465        let mut mgr = SpatialIndexManager::new(test_memory());
466        mgr.index_document("a", "loc", "d1", &Geometry::point(0.0, 0.0));
467        mgr.index_document("b", "loc", "d1", &Geometry::point(50.0, 50.0));
468
469        assert_eq!(mgr.collection_count(), 2);
470
471        let a_results = mgr.search("a", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
472        assert_eq!(a_results.len(), 1);
473
474        let b_results = mgr.search("b", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
475        assert!(b_results.is_empty());
476    }
477}