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
11use nodedb_spatial::rtree::{RTree, RTreeEntry};
12use nodedb_types::BoundingBox;
13use nodedb_types::geometry::Geometry;
14
15/// Manages per-collection R-tree spatial indexes.
16///
17/// Each collection that has geometry fields gets its own R-tree.
18/// The field name is stored alongside so we know which document field
19/// to extract geometry from.
20pub struct SpatialIndexManager {
21    /// (collection_name, field_name) → R-tree.
22    indices: HashMap<(String, String), RTree>,
23    /// Document ID → entry ID mapping for deletion.
24    /// Key: (collection, doc_id), Value: entry_id in R-tree.
25    doc_to_entry: HashMap<(String, String), u64>,
26    /// Next entry ID (monotonically increasing).
27    next_id: u64,
28}
29
30impl SpatialIndexManager {
31    pub fn new() -> Self {
32        Self {
33            indices: HashMap::new(),
34            doc_to_entry: HashMap::new(),
35            next_id: 1,
36        }
37    }
38
39    /// Index a geometry from a document. If the document already has an entry,
40    /// it is removed first (upsert semantics).
41    pub fn index_document(
42        &mut self,
43        collection: &str,
44        field: &str,
45        doc_id: &str,
46        geometry: &Geometry,
47    ) {
48        let key = (collection.to_string(), field.to_string());
49        let doc_key = (collection.to_string(), doc_id.to_string());
50
51        // Remove old entry if this document was previously indexed.
52        if let Some(old_id) = self.doc_to_entry.remove(&doc_key)
53            && let Some(tree) = self.indices.get_mut(&key)
54        {
55            tree.delete(old_id);
56        }
57
58        let bbox = nodedb_types::geometry_bbox(geometry);
59        let entry_id = self.next_id;
60        self.next_id += 1;
61
62        let tree = self.indices.entry(key).or_default();
63        tree.insert(RTreeEntry { id: entry_id, bbox });
64        self.doc_to_entry.insert(doc_key, entry_id);
65    }
66
67    /// Remove a document's geometry from the index.
68    pub fn remove_document(&mut self, collection: &str, field: &str, doc_id: &str) {
69        let key = (collection.to_string(), field.to_string());
70        let doc_key = (collection.to_string(), doc_id.to_string());
71
72        if let Some(entry_id) = self.doc_to_entry.remove(&doc_key)
73            && let Some(tree) = self.indices.get_mut(&key)
74        {
75            tree.delete(entry_id);
76        }
77    }
78
79    /// Range search: find all document entry IDs whose bbox intersects the query.
80    pub fn search(&self, collection: &str, field: &str, query: &BoundingBox) -> Vec<&RTreeEntry> {
81        let key = (collection.to_string(), field.to_string());
82        match self.indices.get(&key) {
83            Some(tree) => tree.search(query),
84            None => Vec::new(),
85        }
86    }
87
88    /// Nearest-neighbor search.
89    pub fn nearest(
90        &self,
91        collection: &str,
92        field: &str,
93        lng: f64,
94        lat: f64,
95        k: usize,
96    ) -> Vec<nodedb_spatial::rtree::NnResult> {
97        let key = (collection.to_string(), field.to_string());
98        match self.indices.get(&key) {
99            Some(tree) => tree.nearest(lng, lat, k),
100            None => Vec::new(),
101        }
102    }
103
104    /// Number of indexed entries across all collections.
105    /// Whether no spatial indices exist.
106    pub fn is_empty(&self) -> bool {
107        self.indices.is_empty()
108    }
109
110    pub fn total_entries(&self) -> usize {
111        self.indices.values().map(|t| t.len()).sum()
112    }
113
114    /// Number of indexed collections.
115    pub fn collection_count(&self) -> usize {
116        self.indices.len()
117    }
118
119    /// Checkpoint all R-trees to bytes for persistence.
120    ///
121    /// Returns a vec of `(collection, field, rtree_bytes)`.
122    pub fn checkpoint_all(&self) -> Vec<(String, String, Vec<u8>)> {
123        let mut results = Vec::new();
124        for ((collection, field), tree) in &self.indices {
125            match tree.checkpoint_to_bytes() {
126                Ok(bytes) => results.push((collection.clone(), field.clone(), bytes)),
127                Err(e) => {
128                    tracing::error!(
129                        collection = %collection,
130                        field = %field,
131                        error = %e,
132                        "spatial index checkpoint failed"
133                    );
134                }
135            }
136        }
137        results
138    }
139
140    /// Restore R-trees from checkpoint data.
141    ///
142    /// Takes a vec of `(collection, field, rtree_bytes)`.
143    pub fn restore_all(checkpoints: &[(String, String, Vec<u8>)]) -> Self {
144        let mut manager = Self::new();
145        for (collection, field, bytes) in checkpoints {
146            match RTree::from_checkpoint(bytes) {
147                Ok(tree) => {
148                    // Rebuild doc_to_entry from restored entries.
149                    // Entry IDs are opaque u64s; we reconstruct the mapping
150                    // assuming entry.id was originally assigned to collection:doc_id.
151                    // Since the R-tree doesn't store doc_ids, we record the
152                    // entry_id → (collection, entry_id_as_string) mapping so that
153                    // subsequent upserts can remove stale entries.
154                    let max_id = tree.entries().iter().map(|e| e.id).max().unwrap_or(0);
155                    if max_id >= manager.next_id {
156                        manager.next_id = max_id + 1;
157                    }
158
159                    // Rebuild doc_to_entry: entry IDs map back to themselves
160                    // as synthetic doc keys. The real doc_id mapping is rebuilt
161                    // when rebuild_from_documents() is called on cold start.
162                    for entry in tree.entries() {
163                        let doc_key = (collection.clone(), format!("__entry_{}", entry.id));
164                        manager.doc_to_entry.insert(doc_key, entry.id);
165                    }
166
167                    manager
168                        .indices
169                        .insert((collection.clone(), field.clone()), tree);
170                }
171                Err(e) => {
172                    tracing::warn!(
173                        collection = %collection,
174                        field = %field,
175                        error = %e,
176                        "spatial index restore failed, will rebuild from documents"
177                    );
178                }
179            }
180        }
181        manager
182    }
183
184    /// Rebuild spatial index from a collection of documents.
185    ///
186    /// Scans all documents, extracts geometry from the specified field,
187    /// and builds the R-tree.
188    pub fn rebuild_from_documents(
189        &mut self,
190        collection: &str,
191        field: &str,
192        documents: &[(String, Geometry)],
193    ) {
194        let entries: Vec<RTreeEntry> = documents
195            .iter()
196            .map(|(doc_id, geom)| {
197                let id = self.next_id;
198                self.next_id += 1;
199                let doc_key = (collection.to_string(), doc_id.clone());
200                self.doc_to_entry.insert(doc_key, id);
201                RTreeEntry {
202                    id,
203                    bbox: nodedb_types::geometry_bbox(geom),
204                }
205            })
206            .collect();
207
208        let tree = RTree::bulk_load(entries);
209        self.indices
210            .insert((collection.to_string(), field.to_string()), tree);
211    }
212}
213
214impl Default for SpatialIndexManager {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn index_and_search() {
226        let mut mgr = SpatialIndexManager::new();
227        mgr.index_document("places", "location", "doc1", &Geometry::point(10.0, 20.0));
228        mgr.index_document("places", "location", "doc2", &Geometry::point(11.0, 21.0));
229        mgr.index_document("places", "location", "doc3", &Geometry::point(50.0, 50.0));
230
231        let results = mgr.search(
232            "places",
233            "location",
234            &BoundingBox::new(9.0, 19.0, 12.0, 22.0),
235        );
236        assert_eq!(results.len(), 2);
237    }
238
239    #[test]
240    fn upsert_replaces_old_entry() {
241        let mut mgr = SpatialIndexManager::new();
242        mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
243        mgr.index_document("places", "loc", "doc1", &Geometry::point(50.0, 50.0));
244
245        // Old location should not be found.
246        let old = mgr.search("places", "loc", &BoundingBox::new(9.0, 19.0, 12.0, 22.0));
247        assert!(old.is_empty());
248
249        // New location should be found.
250        let new = mgr.search("places", "loc", &BoundingBox::new(49.0, 49.0, 51.0, 51.0));
251        assert_eq!(new.len(), 1);
252    }
253
254    #[test]
255    fn remove_document() {
256        let mut mgr = SpatialIndexManager::new();
257        mgr.index_document("places", "loc", "doc1", &Geometry::point(10.0, 20.0));
258        mgr.remove_document("places", "loc", "doc1");
259
260        let results = mgr.search("places", "loc", &BoundingBox::new(0.0, 0.0, 180.0, 90.0));
261        assert!(results.is_empty());
262    }
263
264    #[test]
265    fn checkpoint_restore_roundtrip() {
266        let mut mgr = SpatialIndexManager::new();
267        for i in 0..50 {
268            mgr.index_document(
269                "buildings",
270                "geom",
271                &format!("b{i}"),
272                &Geometry::point(i as f64 * 0.5, i as f64 * 0.3),
273            );
274        }
275
276        let checkpoints = mgr.checkpoint_all();
277        assert_eq!(checkpoints.len(), 1);
278
279        let restored = SpatialIndexManager::restore_all(&checkpoints);
280        assert_eq!(restored.total_entries(), 50);
281
282        let results = restored.search(
283            "buildings",
284            "geom",
285            &BoundingBox::new(-180.0, -90.0, 180.0, 90.0),
286        );
287        assert_eq!(results.len(), 50);
288    }
289
290    #[test]
291    fn nearest_neighbor() {
292        let mut mgr = SpatialIndexManager::new();
293        mgr.index_document("pois", "loc", "a", &Geometry::point(0.0, 0.0));
294        mgr.index_document("pois", "loc", "b", &Geometry::point(10.0, 10.0));
295        mgr.index_document("pois", "loc", "c", &Geometry::point(1.0, 1.0));
296
297        let nn = mgr.nearest("pois", "loc", 0.5, 0.5, 2);
298        assert_eq!(nn.len(), 2);
299    }
300
301    #[test]
302    fn rebuild_from_documents() {
303        let mut mgr = SpatialIndexManager::new();
304        let docs: Vec<(String, Geometry)> = (0..100)
305            .map(|i| {
306                (
307                    format!("d{i}"),
308                    Geometry::point(i as f64 * 0.1, i as f64 * 0.1),
309                )
310            })
311            .collect();
312        mgr.rebuild_from_documents("col", "geom", &docs);
313        assert_eq!(mgr.total_entries(), 100);
314    }
315
316    #[test]
317    fn multiple_collections() {
318        let mut mgr = SpatialIndexManager::new();
319        mgr.index_document("a", "loc", "d1", &Geometry::point(0.0, 0.0));
320        mgr.index_document("b", "loc", "d1", &Geometry::point(50.0, 50.0));
321
322        assert_eq!(mgr.collection_count(), 2);
323
324        let a_results = mgr.search("a", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
325        assert_eq!(a_results.len(), 1);
326
327        let b_results = mgr.search("b", "loc", &BoundingBox::new(-1.0, -1.0, 1.0, 1.0));
328        assert!(b_results.is_empty());
329    }
330}