Skip to main content

nodedb_lite/sync/array/inbound/
apply.rs

1//! [`LiteApplyEngine`] — adapts NodeDB-Lite's array engine to the
2//! [`ApplyEngine`] trait so [`nodedb_array::sync::apply::apply_op`] can drive
3//! local state from inbound wire messages.
4
5use std::collections::HashMap;
6use std::future::Future;
7use std::sync::{Arc, Mutex};
8
9use nodedb_array::error::ArrayError;
10use nodedb_array::sync::apply::ApplyEngine;
11use nodedb_array::sync::hlc::Hlc;
12use nodedb_array::sync::op::ArrayOp;
13use nodedb_array::sync::op_log::OpLog;
14use nodedb_array::types::coord::value::CoordValue;
15use nodedb_types::Namespace;
16
17use crate::engine::array::engine::{ArrayEngineState, CellWrite};
18use crate::storage::engine::StorageEngine;
19use crate::sync::array::op_log_store::KvOpLogStore;
20use crate::sync::array::schema_registry::SchemaRegistry;
21
22/// Key prefix for `last_applied_hlc` entries persisted under `Namespace::Meta`.
23const LAST_APPLIED_PREFIX: &str = "array.last_applied:";
24
25/// Bridge an async future into a sync context via `block_in_place`.
26///
27/// Used exclusively for bridging `StorageEngine` async calls inside the sync
28/// `ApplyEngine` trait methods.
29fn block<F, T>(f: F) -> T
30where
31    F: Future<Output = T>,
32{
33    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
34}
35
36/// Adapts NodeDB-Lite's array engine state to the [`ApplyEngine`] trait.
37///
38/// All fields are `Arc`-wrapped so their interior mutability can satisfy the
39/// trait's `&mut self` methods without requiring `&mut LiteApplyEngine`.
40/// We implement `ApplyEngine` for `&LiteApplyEngine<S>` — a mutable reference
41/// to a shared reference — which allows `apply_op(&mut engine_ref, &op)` where
42/// `engine_ref: &LiteApplyEngine<S>` without any additional allocation.
43///
44/// # Outbound loop avoidance
45///
46/// Operations applied here go directly through `ArrayEngineState` methods,
47/// which sit below `NodeDbLite::array_put_cell`. The `ArrayOutbound`
48/// hook is therefore never triggered, making the receive path loop-free by
49/// construction.
50pub struct LiteApplyEngine<S: StorageEngine> {
51    pub(super) storage: Arc<S>,
52    pub(super) array_state: Arc<tokio::sync::Mutex<ArrayEngineState>>,
53    pub(super) schemas: Arc<SchemaRegistry<S>>,
54    pub(super) op_log: Arc<KvOpLogStore<S>>,
55    /// In-memory cache of the last applied HLC per array.
56    /// Persisted under `Namespace::Meta` `"array.last_applied:{name}"`.
57    last_applied: Mutex<HashMap<String, Hlc>>,
58}
59
60impl<S: StorageEngine> LiteApplyEngine<S> {
61    /// Construct from the component parts shared with `NodeDbLite`.
62    pub async fn new(
63        storage: Arc<S>,
64        array_state: Arc<tokio::sync::Mutex<ArrayEngineState>>,
65        schemas: Arc<SchemaRegistry<S>>,
66        op_log: Arc<KvOpLogStore<S>>,
67    ) -> Self {
68        let last_applied = Self::load_last_applied(&storage).await;
69        Self {
70            storage,
71            array_state,
72            schemas,
73            op_log,
74            last_applied: Mutex::new(last_applied),
75        }
76    }
77
78    async fn load_last_applied(storage: &Arc<S>) -> HashMap<String, Hlc> {
79        let prefix = LAST_APPLIED_PREFIX.as_bytes();
80        let Ok(pairs) = storage
81            .scan_range(Namespace::Meta, prefix, usize::MAX)
82            .await
83        else {
84            return HashMap::new();
85        };
86        let mut map = HashMap::new();
87        for (key, value) in pairs {
88            if !key.starts_with(prefix) {
89                break;
90            }
91            let name_bytes = &key[prefix.len()..];
92            let Ok(name) = std::str::from_utf8(name_bytes) else {
93                continue;
94            };
95            if value.len() == 18 {
96                let bytes: [u8; 18] = match value.try_into() {
97                    Ok(b) => b,
98                    Err(_) => continue,
99                };
100                map.insert(name.to_owned(), Hlc::from_bytes(&bytes));
101            }
102        }
103        map
104    }
105
106    /// Return the highest HLC applied for `array`, or `None` if no ops applied.
107    pub fn last_applied_hlc(&self, array: &str) -> Option<Hlc> {
108        self.last_applied.lock().ok()?.get(array).copied()
109    }
110
111    /// Record that `hlc` has been successfully applied for `array`.
112    ///
113    /// Advances the in-memory record and persists under `Namespace::Meta`.
114    /// Uses `block_in_place` to bridge the async storage call from a sync context.
115    fn record_applied_hlc(&self, array: &str, hlc: Hlc) {
116        let should_persist = {
117            let mut map = match self.last_applied.lock() {
118                Ok(m) => m,
119                Err(_) => return,
120            };
121            let current = map.get(array).copied().unwrap_or(Hlc::ZERO);
122            if hlc > current {
123                map.insert(array.to_owned(), hlc);
124                true
125            } else {
126                false
127            }
128        };
129        if should_persist {
130            let key = format!("{LAST_APPLIED_PREFIX}{array}").into_bytes();
131            let storage = Arc::clone(&self.storage);
132            let bytes = hlc.to_bytes();
133            let _ = block(async move { storage.put(Namespace::Meta, &key, &bytes).await });
134        }
135    }
136}
137
138/// Implement `ApplyEngine` on a *borrowed* `LiteApplyEngine<S>`.
139///
140/// Because all state lives behind `Arc` / `Arc<tokio::sync::Mutex<...>>`, a
141/// shared reference carries enough indirection to perform all mutations. The
142/// trait requires `&mut self` (`E = &LiteApplyEngine<S>`), and `&mut E` is
143/// merely a rebindable outer reference that we never actually need to mutate.
144///
145/// `apply_put` and `apply_erase` call async `ArrayEngineState` methods via
146/// `block_in_place` + `blocking_lock`, which is safe inside a `multi_thread`
147/// Tokio runtime.
148impl<S: StorageEngine> ApplyEngine for &LiteApplyEngine<S> {
149    fn schema_hlc(&self, array: &str) -> nodedb_array::error::ArrayResult<Option<Hlc>> {
150        Ok(self.schemas.schema_hlc(array))
151    }
152
153    fn already_seen(&self, array: &str, hlc: Hlc) -> nodedb_array::error::ArrayResult<bool> {
154        let mut iter = self.op_log.scan_range(array, hlc, hlc)?;
155        Ok(iter.next().is_some())
156    }
157
158    fn apply_put(&mut self, op: &ArrayOp) -> nodedb_array::error::ArrayResult<()> {
159        let system_from_ms = op.header.system_from_ms;
160        let attrs = op.attrs.clone().unwrap_or_default();
161        let array_state = Arc::clone(&self.array_state);
162        let storage = Arc::clone(&self.storage);
163        let array = op.header.array.clone();
164        let coord = op.coord.clone();
165        let valid_from_ms = op.header.valid_from_ms;
166        let valid_until_ms = op.header.valid_until_ms;
167        block(async move {
168            let mut state = array_state.lock().await;
169            state
170                .put_cell(
171                    &storage,
172                    &array,
173                    CellWrite {
174                        coord,
175                        attrs,
176                        system_from_ms,
177                        valid_from_ms,
178                        valid_until_ms,
179                    },
180                )
181                .await
182                .map_err(|e| ArrayError::SegmentCorruption {
183                    detail: format!("apply_put: {e}"),
184                })
185        })?;
186        // Record in op-log so that subsequent `already_seen` returns true.
187        self.op_log.append(op)?;
188        self.record_applied_hlc(&op.header.array, op.header.hlc);
189        Ok(())
190    }
191
192    fn apply_delete(&mut self, op: &ArrayOp) -> nodedb_array::error::ArrayResult<()> {
193        {
194            let mut state = self.array_state.blocking_lock();
195            state
196                .delete_cell(&op.header.array, op.coord.clone(), op.header.system_from_ms)
197                .map_err(|e| ArrayError::SegmentCorruption {
198                    detail: format!("apply_delete: {e}"),
199                })?;
200        }
201        self.op_log.append(op)?;
202        self.record_applied_hlc(&op.header.array, op.header.hlc);
203        Ok(())
204    }
205
206    fn apply_erase(&mut self, op: &ArrayOp) -> nodedb_array::error::ArrayResult<()> {
207        let array_state = Arc::clone(&self.array_state);
208        let storage = Arc::clone(&self.storage);
209        let array = op.header.array.clone();
210        let coord = op.coord.clone();
211        let system_from_ms = op.header.system_from_ms;
212        block(async move {
213            let mut state = array_state.lock().await;
214            state
215                .gdpr_erase_cell(&storage, &array, coord, system_from_ms)
216                .await
217                .map_err(|e| ArrayError::SegmentCorruption {
218                    detail: format!("apply_erase: {e}"),
219                })
220        })?;
221        self.op_log.append(op)?;
222        self.record_applied_hlc(&op.header.array, op.header.hlc);
223        Ok(())
224    }
225
226    /// No-op: the Lite array engine reads from the durable memtable + segments
227    /// after each write, so there is no separate read cache that would need
228    /// explicit invalidation. If a tile read-cache is introduced later, this
229    /// hook is the right place to drop overlapping entries.
230    fn invalidate_tile(
231        &mut self,
232        _array: &str,
233        _coord: &[CoordValue],
234    ) -> nodedb_array::error::ArrayResult<()> {
235        Ok(())
236    }
237}