1use 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
35const STAGING_CAP: usize = 4_096;
38
39#[derive(
41 Debug,
42 Clone,
43 serde::Serialize,
44 serde::Deserialize,
45 zerompk::ToMessagePack,
46 zerompk::FromMessagePack,
47)]
48pub struct PendingSpatialInsert {
49 pub batch_id: u64,
51 pub collection: String,
53 pub field: String,
55 pub doc_id: String,
57 pub geometry_bytes: Vec<u8>,
59 #[serde(default)]
63 pub seq: u64,
64}
65
66#[derive(
68 Debug,
69 Clone,
70 serde::Serialize,
71 serde::Deserialize,
72 zerompk::ToMessagePack,
73 zerompk::FromMessagePack,
74)]
75pub struct PendingSpatialDelete {
76 pub batch_id: u64,
78 pub collection: String,
80 pub field: String,
82 pub doc_id: String,
84 #[serde(default)]
88 pub seq: u64,
89}
90
91pub struct SpatialOutbound<S: StorageEngine> {
98 staging_inserts: PendingQueue<PendingSpatialInsert>,
100 staging_deletes: PendingQueue<PendingSpatialDelete>,
102 durable_inserts: DurableOutboundQueue<S>,
104 durable_deletes: DurableOutboundQueue<S>,
106 ids: AtomicU64,
108 in_flight_inserts: Mutex<HashMap<u64, Vec<u8>>>,
110 in_flight_deletes_map: Mutex<HashMap<u64, Vec<u8>>>,
112}
113
114impl<S: StorageEngine> SpatialOutbound<S> {
115 pub async fn open(storage: Arc<S>) -> Result<Self, LiteError> {
118 Self::open_with_cap(storage, DurableOutboundQueue::<S>::DEFAULT_CAP).await
119 }
120
121 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 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 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 pub async fn flush_staging(&self) -> Result<(), LiteError> {
224 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 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 pub async fn drain_inserts(
288 &self,
289 limit: usize,
290 ) -> Result<Vec<(Vec<u8>, PendingSpatialInsert)>, LiteError> {
291 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 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 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 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 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 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 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 pub async fn ack_insert_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
375 self.durable_inserts.ack_keys(keys).await
376 }
377
378 pub async fn ack_delete_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
380 self.durable_deletes.ack_keys(keys).await
381 }
382
383 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 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 pub async fn pending_insert_count(&self) -> Result<u64, LiteError> {
409 self.durable_inserts.len().await
410 }
411
412 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}