1use 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
22pub struct SpatialIndexManager {
28 indices: HashMap<(String, String), RTree>,
30 doc_to_entry: HashMap<(String, String), u64>,
33 entry_to_doc: HashMap<u64, (String, String)>,
35 next_id: u64,
37 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 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 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 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 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 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 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 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 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 pub fn collection_count(&self) -> usize {
179 self.indices.len()
180 }
181
182 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 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 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 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 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 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 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 let old = mgr.search("places", "loc", &BoundingBox::new(9.0, 19.0, 12.0, 22.0));
394 assert!(old.is_empty());
395
396 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}