Skip to main content

nodedb_lite/sync/outbound/
spatial.rs

1//! Spatial geometry insert/delete outbound queue for Lite sync.
2//!
3//! # Durability
4//!
5//! Two [`DurableOutboundQueue`]s (one per op-kind) back this outbound:
6//! [`Namespace::SpatialInsertPending`] and [`Namespace::SpatialDeletePending`].
7//!
8//! # Staging + spill model (no `block_on`)
9//!
10//! `spatial_insert` and `spatial_delete` are sync methods on `NodeDbLite`.
11//! They cannot `.await` a storage write. Instead they push to a bounded
12//! in-memory [`PendingQueue`] (staging buffer). The async `flush()` path
13//! drains the staging buffer and spills entries to the durable queues.
14//!
15//! If the staging buffer reaches `STAGING_CAP` entries before the next flush,
16//! new enqueues are dropped with a `warn!` — the geometry is already durable
17//! in the local R-tree; Origin will see it on the next full catch-up.
18//!
19//! The push transport calls [`drain_inserts`] / [`drain_deletes`] which reads
20//! from the durable queues. [`ack_insert_keys`] / [`ack_delete_keys`] delete
21//! confirmed entries by key.
22
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use nodedb_types::Namespace;
28use tokio::sync::Mutex;
29
30use super::durable_queue::DurableOutboundQueue;
31use super::queue::PendingQueue;
32use crate::error::LiteError;
33use crate::storage::engine::StorageEngine;
34
35/// Maximum number of entries that may accumulate in the in-memory staging
36/// buffer between flush cycles.
37const STAGING_CAP: usize = 4_096;
38
39/// A single pending spatial insert operation awaiting sync to Origin.
40#[derive(
41    Debug,
42    Clone,
43    serde::Serialize,
44    serde::Deserialize,
45    zerompk::ToMessagePack,
46    zerompk::FromMessagePack,
47)]
48pub struct PendingSpatialInsert {
49    /// Monotonic batch ID for ACK correlation.
50    pub batch_id: u64,
51    /// Collection name.
52    pub collection: String,
53    /// Geometry field name.
54    pub field: String,
55    /// Document ID.
56    pub doc_id: String,
57    /// MessagePack-serialised `nodedb_types::geometry::Geometry`.
58    pub geometry_bytes: Vec<u8>,
59    /// Stable idempotent-producer seq for this entry. 0 = not yet assigned;
60    /// assigned at first drain and persisted so re-sends after reconnect reuse
61    /// the same seq (Origin dedups instead of double-applying).
62    #[serde(default)]
63    pub seq: u64,
64}
65
66/// A single pending spatial delete operation awaiting sync to Origin.
67#[derive(
68    Debug,
69    Clone,
70    serde::Serialize,
71    serde::Deserialize,
72    zerompk::ToMessagePack,
73    zerompk::FromMessagePack,
74)]
75pub struct PendingSpatialDelete {
76    /// Monotonic batch ID for ACK correlation.
77    pub batch_id: u64,
78    /// Collection name.
79    pub collection: String,
80    /// Geometry field name.
81    pub field: String,
82    /// Document ID.
83    pub doc_id: String,
84    /// Stable idempotent-producer seq for this entry. 0 = not yet assigned;
85    /// assigned at first drain and persisted so re-sends after reconnect reuse
86    /// the same seq (Origin dedups instead of double-applying).
87    #[serde(default)]
88    pub seq: u64,
89}
90
91/// Durable outbound queue for spatial insert and delete sync.
92///
93/// Held as `Arc<SpatialOutbound<S>>` by `NodeDbLite` and shared with the sync
94/// transport. The inner storage is accessed only for `flush_staging()` (from
95/// the auto-flush timer) and `drain_inserts` / `drain_deletes` / `ack_*`
96/// (from the async sync transport task).
97pub struct SpatialOutbound<S: StorageEngine> {
98    /// In-memory staging buffer for insert ops (filled synchronously).
99    staging_inserts: PendingQueue<PendingSpatialInsert>,
100    /// In-memory staging buffer for delete ops (filled synchronously).
101    staging_deletes: PendingQueue<PendingSpatialDelete>,
102    /// Durable FIFO queue for insert ops.
103    durable_inserts: DurableOutboundQueue<S>,
104    /// Durable FIFO queue for delete ops.
105    durable_deletes: DurableOutboundQueue<S>,
106    /// Shared monotonic ID generator.
107    ids: AtomicU64,
108    /// batch_id → durable_key for in-flight insert entries.
109    in_flight_inserts: Mutex<HashMap<u64, Vec<u8>>>,
110    /// batch_id → durable_key for in-flight delete entries.
111    in_flight_deletes_map: Mutex<HashMap<u64, Vec<u8>>>,
112}
113
114impl<S: StorageEngine> SpatialOutbound<S> {
115    /// Open durable queues backed by [`Namespace::SpatialInsertPending`] and
116    /// [`Namespace::SpatialDeletePending`].
117    pub async fn open(storage: Arc<S>) -> Result<Self, LiteError> {
118        Self::open_with_cap(storage, DurableOutboundQueue::<S>::DEFAULT_CAP).await
119    }
120
121    /// Open with a custom cap (useful for tests with small limits).
122    pub async fn open_with_cap(storage: Arc<S>, cap: usize) -> Result<Self, LiteError> {
123        let durable_inserts = DurableOutboundQueue::open_with_cap(
124            Arc::clone(&storage),
125            Namespace::SpatialInsertPending,
126            cap,
127        )
128        .await?;
129        let durable_deletes = DurableOutboundQueue::open_with_cap(
130            Arc::clone(&storage),
131            Namespace::SpatialDeletePending,
132            cap,
133        )
134        .await?;
135        Ok(Self {
136            staging_inserts: PendingQueue::new(),
137            staging_deletes: PendingQueue::new(),
138            durable_inserts,
139            durable_deletes,
140            ids: AtomicU64::new(1),
141            in_flight_inserts: Mutex::new(HashMap::new()),
142            in_flight_deletes_map: Mutex::new(HashMap::new()),
143        })
144    }
145
146    /// Synchronously stage a spatial insert entry.
147    ///
148    /// Serialises the geometry to MessagePack; drops the entry with a `warn!`
149    /// if serialisation fails or the staging buffer is at [`STAGING_CAP`].
150    pub fn stage_insert(
151        &self,
152        collection: &str,
153        field: &str,
154        doc_id: &str,
155        geometry: &nodedb_types::geometry::Geometry,
156    ) {
157        if self.staging_inserts.len() >= STAGING_CAP {
158            tracing::warn!(
159                collection,
160                field,
161                doc_id,
162                "spatial_outbound: staging buffer full; dropping insert sync entry \
163                 (will re-sync on next catch-up)"
164            );
165            return;
166        }
167        let geometry_bytes = match zerompk::to_msgpack_vec(geometry) {
168            Ok(b) => b,
169            Err(e) => {
170                tracing::warn!(
171                    collection,
172                    field,
173                    doc_id,
174                    error = %e,
175                    "spatial_outbound: failed to serialise geometry; skipping sync enqueue"
176                );
177                return;
178            }
179        };
180        let batch_id = self.ids.fetch_add(1, Ordering::Relaxed);
181        self.staging_inserts.push(PendingSpatialInsert {
182            batch_id,
183            collection: collection.to_string(),
184            field: field.to_string(),
185            doc_id: doc_id.to_string(),
186            geometry_bytes,
187            seq: 0,
188        });
189    }
190
191    /// Synchronously stage a spatial delete entry.
192    ///
193    /// Drops the entry with a `warn!` if the staging buffer is at
194    /// [`STAGING_CAP`].
195    pub fn stage_delete(&self, collection: &str, field: &str, doc_id: &str) {
196        if self.staging_deletes.len() >= STAGING_CAP {
197            tracing::warn!(
198                collection,
199                field,
200                doc_id,
201                "spatial_outbound: staging buffer full; dropping delete sync entry \
202                 (will re-sync on next catch-up)"
203            );
204            return;
205        }
206        let batch_id = self.ids.fetch_add(1, Ordering::Relaxed);
207        self.staging_deletes.push(PendingSpatialDelete {
208            batch_id,
209            collection: collection.to_string(),
210            field: field.to_string(),
211            doc_id: doc_id.to_string(),
212            seq: 0,
213        });
214    }
215
216    /// Spill staged entries to durable storage.
217    ///
218    /// Called from the async `flush()` path (~every second). Drains the
219    /// in-memory staging buffers and appends each entry to the durable queues.
220    /// Returns [`LiteError::Backpressure`] on the first entry that cannot be
221    /// enqueued (durable queue at cap); remaining staged entries are re-queued
222    /// at the head of the staging buffer.
223    pub async fn flush_staging(&self) -> Result<(), LiteError> {
224        // Spill insert staging.
225        let staged_inserts = self.staging_inserts.drain();
226        let mut failed_inserts: Vec<PendingSpatialInsert> = Vec::new();
227        let mut backpressure: Option<LiteError> = None;
228        for entry in staged_inserts {
229            if backpressure.is_some() {
230                failed_inserts.push(entry);
231                continue;
232            }
233            let payload =
234                zerompk::to_msgpack_vec(&entry).map_err(|e| LiteError::Serialization {
235                    detail: format!("spatial insert outbound encode: {e}"),
236                })?;
237            match self.durable_inserts.enqueue(&payload).await {
238                Ok(()) => {}
239                Err(e @ LiteError::Backpressure { .. }) => {
240                    failed_inserts.push(entry);
241                    backpressure = Some(e);
242                }
243                Err(e) => return Err(e),
244            }
245        }
246        for entry in failed_inserts.into_iter().rev() {
247            self.staging_inserts.requeue(entry);
248        }
249
250        // Spill delete staging.
251        let staged_deletes = self.staging_deletes.drain();
252        let mut failed_deletes: Vec<PendingSpatialDelete> = Vec::new();
253        for entry in staged_deletes {
254            if backpressure.is_some() {
255                failed_deletes.push(entry);
256                continue;
257            }
258            let payload =
259                zerompk::to_msgpack_vec(&entry).map_err(|e| LiteError::Serialization {
260                    detail: format!("spatial delete outbound encode: {e}"),
261                })?;
262            match self.durable_deletes.enqueue(&payload).await {
263                Ok(()) => {}
264                Err(e @ LiteError::Backpressure { .. }) => {
265                    failed_deletes.push(entry);
266                    backpressure = Some(e);
267                }
268                Err(e) => return Err(e),
269            }
270        }
271        for entry in failed_deletes.into_iter().rev() {
272            self.staging_deletes.requeue(entry);
273        }
274
275        match backpressure {
276            Some(e) => Err(e),
277            None => Ok(()),
278        }
279    }
280
281    /// Drain up to `limit` pending inserts in FIFO order, skipping in-flight
282    /// entries (sent but not yet acked by Origin).
283    ///
284    /// Returns `(durable_key, insert)` pairs. On send success, call
285    /// [`mark_insert_in_flight`]. The durable entry is deleted only on Origin
286    /// ack via [`ack_insert_in_flight`].
287    pub async fn drain_inserts(
288        &self,
289        limit: usize,
290    ) -> Result<Vec<(Vec<u8>, PendingSpatialInsert)>, LiteError> {
291        // Spill staged entries every drain tick so replication doesn't depend on
292        // the auto-flush timer (absent in direct library/test usage).
293        if let Err(e) = self.flush_staging().await {
294            tracing::debug!(error = %e, "spatial_outbound: staging spill backpressured");
295        }
296        let in_flight = self.in_flight_inserts.lock().await;
297        let pairs = self.durable_inserts.drain_batch(limit).await?;
298        let mut out = Vec::with_capacity(pairs.len());
299        for (key, payload) in pairs {
300            if in_flight.values().any(|k| k == &key) {
301                continue;
302            }
303            let entry: PendingSpatialInsert =
304                zerompk::from_msgpack(&payload).map_err(|e| LiteError::Serialization {
305                    detail: format!("spatial insert outbound decode: {e}"),
306                })?;
307            out.push((key, entry));
308        }
309        Ok(out)
310    }
311
312    /// Drain up to `limit` pending deletes in FIFO order, skipping in-flight
313    /// entries.
314    ///
315    /// Returns `(durable_key, delete)` pairs. On send success, call
316    /// [`mark_delete_in_flight`]. The durable entry is deleted only on Origin
317    /// ack via [`ack_delete_in_flight`].
318    pub async fn drain_deletes(
319        &self,
320        limit: usize,
321    ) -> Result<Vec<(Vec<u8>, PendingSpatialDelete)>, LiteError> {
322        if let Err(e) = self.flush_staging().await {
323            tracing::debug!(error = %e, "spatial_outbound: staging spill backpressured");
324        }
325        let in_flight = self.in_flight_deletes_map.lock().await;
326        let pairs = self.durable_deletes.drain_batch(limit).await?;
327        let mut out = Vec::with_capacity(pairs.len());
328        for (key, payload) in pairs {
329            if in_flight.values().any(|k| k == &key) {
330                continue;
331            }
332            let entry: PendingSpatialDelete =
333                zerompk::from_msgpack(&payload).map_err(|e| LiteError::Serialization {
334                    detail: format!("spatial delete outbound decode: {e}"),
335                })?;
336            out.push((key, entry));
337        }
338        Ok(out)
339    }
340
341    /// Record that an insert has been sent to Origin and is awaiting its ack.
342    pub async fn mark_insert_in_flight(&self, batch_id: u64, durable_key: Vec<u8>) {
343        self.in_flight_inserts
344            .lock()
345            .await
346            .insert(batch_id, durable_key);
347    }
348
349    /// Remove the in-flight record for an insert and return its durable key.
350    pub async fn ack_insert_in_flight(&self, batch_id: u64) -> Option<Vec<u8>> {
351        self.in_flight_inserts.lock().await.remove(&batch_id)
352    }
353
354    /// Record that a delete has been sent to Origin and is awaiting its ack.
355    pub async fn mark_delete_in_flight(&self, batch_id: u64, durable_key: Vec<u8>) {
356        self.in_flight_deletes_map
357            .lock()
358            .await
359            .insert(batch_id, durable_key);
360    }
361
362    /// Remove the in-flight record for a delete and return its durable key.
363    pub async fn ack_delete_in_flight(&self, batch_id: u64) -> Option<Vec<u8>> {
364        self.in_flight_deletes_map.lock().await.remove(&batch_id)
365    }
366
367    /// Clear all in-flight records on reconnect so entries are re-drained.
368    pub async fn clear_in_flight(&self) {
369        self.in_flight_inserts.lock().await.clear();
370        self.in_flight_deletes_map.lock().await.clear();
371    }
372
373    /// Delete the durable insert entries identified by `keys` (Origin ack path).
374    pub async fn ack_insert_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
375        self.durable_inserts.ack_keys(keys).await
376    }
377
378    /// Delete the durable delete entries identified by `keys` (Origin ack path).
379    pub async fn ack_delete_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
380        self.durable_deletes.ack_keys(keys).await
381    }
382
383    /// Update the durable insert payload for `key` with the new seq.
384    pub async fn update_insert_entry(
385        &self,
386        key: &[u8],
387        insert: &PendingSpatialInsert,
388    ) -> Result<(), LiteError> {
389        let payload = zerompk::to_msgpack_vec(insert).map_err(|e| LiteError::Serialization {
390            detail: format!("spatial insert outbound update encode: {e}"),
391        })?;
392        self.durable_inserts.update_entry(key, &payload).await
393    }
394
395    /// Update the durable delete payload for `key` with the new seq.
396    pub async fn update_delete_entry(
397        &self,
398        key: &[u8],
399        delete: &PendingSpatialDelete,
400    ) -> Result<(), LiteError> {
401        let payload = zerompk::to_msgpack_vec(delete).map_err(|e| LiteError::Serialization {
402            detail: format!("spatial delete outbound update encode: {e}"),
403        })?;
404        self.durable_deletes.update_entry(key, &payload).await
405    }
406
407    /// Number of pending insert entries in durable storage.
408    pub async fn pending_insert_count(&self) -> Result<u64, LiteError> {
409        self.durable_inserts.len().await
410    }
411
412    /// Number of pending delete entries in durable storage.
413    pub async fn pending_delete_count(&self) -> Result<u64, LiteError> {
414        self.durable_deletes.len().await
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::storage::pagedb_storage::PagedbStorageMem;
422
423    fn point_geometry() -> nodedb_types::geometry::Geometry {
424        nodedb_types::geometry::Geometry::point(1.0, 2.0)
425    }
426
427    async fn make_queue() -> SpatialOutbound<PagedbStorageMem> {
428        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
429        SpatialOutbound::open(storage).await.unwrap()
430    }
431
432    #[tokio::test]
433    async fn stage_and_flush_inserts() {
434        let q = make_queue().await;
435        q.stage_insert("places", "loc", "doc1", &point_geometry());
436        q.stage_insert("places", "loc", "doc2", &point_geometry());
437
438        assert_eq!(q.pending_insert_count().await.unwrap(), 0);
439
440        q.flush_staging().await.unwrap();
441
442        let pairs = q.drain_inserts(usize::MAX).await.unwrap();
443        assert_eq!(pairs.len(), 2);
444        assert_eq!(pairs[0].1.doc_id, "doc1");
445        assert_eq!(pairs[1].1.doc_id, "doc2");
446    }
447
448    #[tokio::test]
449    async fn stage_and_flush_deletes() {
450        let q = make_queue().await;
451        q.stage_delete("places", "loc", "doc1");
452
453        q.flush_staging().await.unwrap();
454
455        let pairs = q.drain_deletes(usize::MAX).await.unwrap();
456        assert_eq!(pairs.len(), 1);
457        assert_eq!(pairs[0].1.doc_id, "doc1");
458    }
459
460    #[tokio::test]
461    async fn ack_insert_keys_removes_entries() {
462        let q = make_queue().await;
463        q.stage_insert("places", "loc", "doc1", &point_geometry());
464        q.stage_insert("places", "loc", "doc2", &point_geometry());
465        q.flush_staging().await.unwrap();
466
467        let pairs = q.drain_inserts(1).await.unwrap();
468        assert_eq!(pairs.len(), 1);
469        let keys: Vec<Vec<u8>> = pairs.into_iter().map(|(k, _)| k).collect();
470        q.ack_insert_keys(&keys).await.unwrap();
471
472        assert_eq!(q.pending_insert_count().await.unwrap(), 1);
473    }
474
475    #[tokio::test]
476    async fn ack_delete_keys_removes_entries() {
477        let q = make_queue().await;
478        q.stage_delete("places", "loc", "doc1");
479        q.stage_delete("places", "loc", "doc2");
480        q.flush_staging().await.unwrap();
481
482        let pairs = q.drain_deletes(1).await.unwrap();
483        assert_eq!(pairs.len(), 1);
484        let keys: Vec<Vec<u8>> = pairs.into_iter().map(|(k, _)| k).collect();
485        q.ack_delete_keys(&keys).await.unwrap();
486
487        assert_eq!(q.pending_delete_count().await.unwrap(), 1);
488    }
489
490    #[tokio::test]
491    async fn geometry_bytes_round_trip() {
492        let geom = point_geometry();
493        let q = make_queue().await;
494        q.stage_insert("places", "loc", "doc1", &geom);
495        q.flush_staging().await.unwrap();
496        let pairs = q.drain_inserts(usize::MAX).await.unwrap();
497        assert!(!pairs[0].1.geometry_bytes.is_empty());
498        let restored: nodedb_types::geometry::Geometry =
499            zerompk::from_msgpack(&pairs[0].1.geometry_bytes)
500                .expect("geometry round-trip deserialise");
501        assert_eq!(geom, restored);
502    }
503
504    #[tokio::test]
505    async fn durable_cap_returns_backpressure() {
506        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
507        let q = SpatialOutbound::open_with_cap(storage, 2).await.unwrap();
508        q.stage_insert("places", "loc", "a", &point_geometry());
509        q.stage_insert("places", "loc", "b", &point_geometry());
510        q.stage_insert("places", "loc", "c", &point_geometry());
511        let err = q.flush_staging().await.unwrap_err();
512        assert!(matches!(err, LiteError::Backpressure { .. }));
513        assert_eq!(q.staging_inserts.len(), 1);
514    }
515
516    #[tokio::test]
517    async fn survives_reload() {
518        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
519        {
520            let q = SpatialOutbound::open(Arc::clone(&storage)).await.unwrap();
521            q.stage_insert("places", "loc", "v1", &point_geometry());
522            q.flush_staging().await.unwrap();
523        }
524        let q = SpatialOutbound::open(Arc::clone(&storage)).await.unwrap();
525        assert_eq!(q.pending_insert_count().await.unwrap(), 1);
526        q.stage_insert("places", "loc", "v2", &point_geometry());
527        q.flush_staging().await.unwrap();
528        assert_eq!(q.pending_insert_count().await.unwrap(), 2);
529    }
530}