Skip to main content

nodedb_array/sync/
apply.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pure op-application dispatcher for array CRDT sync.
4//!
5//! [`apply_op`] is the single entry point for merging an incoming [`ArrayOp`]
6//! into local engine state. It enforces shape validation, schema-version
7//! gating, idempotency, and tile-cache invalidation in a fixed order.
8//!
9//! Engine implementations live in `nodedb-lite` and `nodedb`; this file
10//! ships only the abstract [`ApplyEngine`] trait, the outcome types, and the
11//! pure dispatcher function.
12
13#[cfg(test)]
14use std::collections::{HashMap, HashSet};
15#[cfg(test)]
16use std::sync::Mutex;
17
18use crate::error::{ArrayError, ArrayResult};
19use crate::sync::hlc::Hlc;
20use crate::sync::op::{ArrayOp, ArrayOpKind};
21use crate::types::coord::value::CoordValue;
22
23// ─── Outcome types ───────────────────────────────────────────────────────────
24
25/// Outcome returned by [`apply_op`].
26#[derive(Clone, Debug, PartialEq)]
27pub enum ApplyOutcome {
28    /// The op was already present; no state was changed.
29    Idempotent,
30    /// The op was applied successfully.
31    Applied,
32    /// The op was rejected; the reason is attached.
33    Rejected(ApplyRejection),
34}
35
36/// Reason an op was rejected by [`apply_op`].
37#[derive(Clone, Debug, PartialEq)]
38pub enum ApplyRejection {
39    /// The op's `schema_hlc` is strictly newer than the local schema HLC.
40    ///
41    /// The receiver should request the updated schema and retry.
42    SchemaTooNew {
43        /// Local schema HLC at the time of rejection.
44        local: Hlc,
45        /// Schema HLC carried by the op.
46        op: Hlc,
47    },
48    /// The named array is not known to this replica.
49    ///
50    /// The receiver should request the array schema before retrying.
51    ArrayUnknown {
52        /// Name of the unknown array.
53        name: String,
54    },
55    /// The op violates the shape contract (e.g. `Put` without attrs).
56    ShapeInvalid {
57        /// Human-readable description of the violation.
58        detail: String,
59    },
60    /// The engine rejected the op (e.g. a transient storage error).
61    ///
62    /// The rejection is wrapped here so callers can record it without
63    /// propagating a hard error. Only [`ArrayError::SegmentCorruption`] and
64    /// [`ArrayError::HlcLockPoisoned`] are considered corruption-grade and
65    /// propagated directly; all other engine errors become this variant.
66    EngineRejected {
67        /// Human-readable description of the engine error.
68        detail: String,
69    },
70}
71
72// ─── ApplyEngine trait ───────────────────────────────────────────────────────
73
74/// Abstract interface to local engine state consumed by [`apply_op`].
75///
76/// Implementations live in `nodedb-lite` and `nodedb` crates. The trait is
77/// intentionally *not* `Send + Sync`-bounded — implementations choose their
78/// own threading model.
79pub trait ApplyEngine {
80    /// Return the current schema HLC for `array`, or `None` if the array is
81    /// not known to this replica.
82    fn schema_hlc(&self, array: &str) -> ArrayResult<Option<Hlc>>;
83
84    /// Return `true` if `hlc` has already been applied to `array`.
85    ///
86    /// Used for idempotent re-delivery detection.
87    fn already_seen(&self, array: &str, hlc: Hlc) -> ArrayResult<bool>;
88
89    /// Apply a `Put` op to the engine.
90    fn apply_put(&mut self, op: &ArrayOp) -> ArrayResult<()>;
91
92    /// Apply a `Delete` op to the engine.
93    fn apply_delete(&mut self, op: &ArrayOp) -> ArrayResult<()>;
94
95    /// Apply an `Erase` op to the engine.
96    fn apply_erase(&mut self, op: &ArrayOp) -> ArrayResult<()>;
97
98    /// Invalidate any tile-cache entry covering `coord` in `array`.
99    ///
100    /// Called after every successful op application so that subsequent reads
101    /// see the updated state.
102    fn invalidate_tile(&mut self, array: &str, coord: &[CoordValue]) -> ArrayResult<()>;
103}
104
105// ─── Dispatcher ──────────────────────────────────────────────────────────────
106
107/// Apply `op` to `engine`, returning an outcome without panicking.
108///
109/// Steps:
110/// 1. Shape validation — on error: `Rejected(ShapeInvalid)`.
111/// 2. Schema HLC check — `None` → `Rejected(ArrayUnknown)`;
112///    `op.header.schema_hlc` strictly later on the clock than `local`
113///    → `Rejected(SchemaTooNew)`. Same-instant schemas are admitted.
114/// 3. Idempotency — already seen → `Idempotent`.
115/// 4. Dispatch to `apply_put`/`apply_delete`/`apply_erase`. Engine errors
116///    that indicate corruption (`SegmentCorruption`, `HlcLockPoisoned`) are
117///    propagated; all others become `Rejected(EngineRejected)`.
118/// 5. Tile-cache invalidation.
119/// 6. Return `Applied`.
120pub fn apply_op<E: ApplyEngine>(engine: &mut E, op: &ArrayOp) -> ArrayResult<ApplyOutcome> {
121    // 1. Shape validation.
122    if let Err(e) = op.validate_shape() {
123        return Ok(ApplyOutcome::Rejected(ApplyRejection::ShapeInvalid {
124            detail: e.to_string(),
125        }));
126    }
127
128    // 2. Schema HLC gating.
129    match engine.schema_hlc(&op.header.array)? {
130        None => {
131            return Ok(ApplyOutcome::Rejected(ApplyRejection::ArrayUnknown {
132                name: op.header.array.clone(),
133            }));
134        }
135        // Clock-only comparison: an op whose schema was stamped in the SAME
136        // instant as ours is not evidence that ours is stale, so it must be
137        // admitted. Using the full `Ord` here would let the arbitrary
138        // `replica_id` tiebreak decide admission — see `Hlc::is_later_than`.
139        Some(local_schema) if op.header.schema_hlc.is_later_than(&local_schema) => {
140            return Ok(ApplyOutcome::Rejected(ApplyRejection::SchemaTooNew {
141                local: local_schema,
142                op: op.header.schema_hlc,
143            }));
144        }
145        Some(_) => {}
146    }
147
148    // 3. Idempotency.
149    if engine.already_seen(&op.header.array, op.header.hlc)? {
150        return Ok(ApplyOutcome::Idempotent);
151    }
152
153    // 4. Dispatch.
154    let dispatch_result = match op.kind {
155        ArrayOpKind::Put => engine.apply_put(op),
156        ArrayOpKind::Delete => engine.apply_delete(op),
157        ArrayOpKind::Erase => engine.apply_erase(op),
158    };
159
160    if let Err(e) = dispatch_result {
161        // Corruption-grade errors propagate; everything else becomes a rejection.
162        match &e {
163            ArrayError::SegmentCorruption { .. } | ArrayError::HlcLockPoisoned => return Err(e),
164            _ => {
165                return Ok(ApplyOutcome::Rejected(ApplyRejection::EngineRejected {
166                    detail: e.to_string(),
167                }));
168            }
169        }
170    }
171
172    // 5. Tile invalidation.
173    engine.invalidate_tile(&op.header.array, &op.coord)?;
174
175    // 6. Success.
176    Ok(ApplyOutcome::Applied)
177}
178
179// ─── MockEngine (test / test-utils only) ────────────────────────────────────
180
181/// Minimal in-memory [`ApplyEngine`] for unit tests.
182///
183/// Tracks:
184/// - known arrays with their schema HLCs
185/// - seen `(array, hlc_bytes)` pairs for idempotency
186/// - successfully applied ops
187/// - an optional injected engine error consumed on the next apply call
188/// - a log of invalidated tile coords
189#[cfg(test)]
190pub struct MockEngine {
191    schemas: HashMap<String, Hlc>,
192    seen: HashSet<(String, [u8; 18])>,
193    pub applied: Vec<ArrayOp>,
194    pub invalidated: Vec<(String, Vec<CoordValue>)>,
195    reject_next_with: Mutex<Option<ArrayError>>,
196}
197
198#[cfg(test)]
199impl MockEngine {
200    /// Create an empty engine with no registered arrays.
201    pub fn new() -> Self {
202        Self {
203            schemas: HashMap::new(),
204            seen: HashSet::new(),
205            applied: Vec::new(),
206            invalidated: Vec::new(),
207            reject_next_with: Mutex::new(None),
208        }
209    }
210
211    /// Register an array so that ops targeting it can be applied.
212    pub fn register_array(&mut self, array: &str, schema_hlc: Hlc) {
213        self.schemas.insert(array.to_string(), schema_hlc);
214    }
215
216    /// Inject an error that will be returned by the next `apply_*` call.
217    pub fn set_reject_next(&self, err: ArrayError) {
218        *self
219            .reject_next_with
220            .lock()
221            .expect("invariant: MockEngine mutex is not poisoned") = Some(err);
222    }
223
224    fn take_inject(&self) -> Option<ArrayError> {
225        self.reject_next_with
226            .lock()
227            .expect("invariant: MockEngine mutex is not poisoned")
228            .take()
229    }
230}
231
232#[cfg(test)]
233impl Default for MockEngine {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239#[cfg(test)]
240impl ApplyEngine for MockEngine {
241    fn schema_hlc(&self, array: &str) -> ArrayResult<Option<Hlc>> {
242        Ok(self.schemas.get(array).copied())
243    }
244
245    fn already_seen(&self, array: &str, hlc: Hlc) -> ArrayResult<bool> {
246        Ok(self.seen.contains(&(array.to_string(), hlc.to_bytes())))
247    }
248
249    fn apply_put(&mut self, op: &ArrayOp) -> ArrayResult<()> {
250        if let Some(err) = self.take_inject() {
251            return Err(err);
252        }
253        self.seen
254            .insert((op.header.array.clone(), op.header.hlc.to_bytes()));
255        self.applied.push(op.clone());
256        Ok(())
257    }
258
259    fn apply_delete(&mut self, op: &ArrayOp) -> ArrayResult<()> {
260        if let Some(err) = self.take_inject() {
261            return Err(err);
262        }
263        self.seen
264            .insert((op.header.array.clone(), op.header.hlc.to_bytes()));
265        self.applied.push(op.clone());
266        Ok(())
267    }
268
269    fn apply_erase(&mut self, op: &ArrayOp) -> ArrayResult<()> {
270        if let Some(err) = self.take_inject() {
271            return Err(err);
272        }
273        self.seen
274            .insert((op.header.array.clone(), op.header.hlc.to_bytes()));
275        self.applied.push(op.clone());
276        Ok(())
277    }
278
279    fn invalidate_tile(&mut self, array: &str, coord: &[CoordValue]) -> ArrayResult<()> {
280        self.invalidated.push((array.to_string(), coord.to_vec()));
281        Ok(())
282    }
283}
284
285// ─── Tests ───────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::sync::hlc::Hlc;
291    use crate::sync::op::{ArrayOpHeader, ArrayOpKind};
292    use crate::sync::replica_id::ReplicaId;
293    use crate::types::cell_value::value::CellValue;
294    use crate::types::coord::value::CoordValue;
295
296    fn replica() -> ReplicaId {
297        ReplicaId::new(42)
298    }
299
300    fn hlc(ms: u64) -> Hlc {
301        Hlc::new(ms, 0, replica()).unwrap()
302    }
303
304    fn header(array: &str, op_ms: u64, schema_ms: u64) -> ArrayOpHeader {
305        ArrayOpHeader {
306            array: array.into(),
307            hlc: hlc(op_ms),
308            schema_hlc: hlc(schema_ms),
309            valid_from_ms: 0,
310            valid_until_ms: -1,
311            system_from_ms: op_ms as i64,
312        }
313    }
314
315    fn put_op(array: &str, op_ms: u64, schema_ms: u64) -> ArrayOp {
316        ArrayOp {
317            header: header(array, op_ms, schema_ms),
318            kind: ArrayOpKind::Put,
319            coord: vec![CoordValue::Int64(1)],
320            attrs: Some(vec![CellValue::Null]),
321        }
322    }
323
324    fn delete_op(array: &str, op_ms: u64, schema_ms: u64) -> ArrayOp {
325        ArrayOp {
326            header: header(array, op_ms, schema_ms),
327            kind: ArrayOpKind::Delete,
328            coord: vec![CoordValue::Int64(1)],
329            attrs: None,
330        }
331    }
332
333    #[test]
334    fn apply_put_succeeds() {
335        let mut engine = MockEngine::new();
336        engine.register_array("a", hlc(100));
337
338        let op = put_op("a", 50, 100);
339        let outcome = apply_op(&mut engine, &op).unwrap();
340        assert_eq!(outcome, ApplyOutcome::Applied);
341        assert_eq!(engine.applied.len(), 1);
342        assert_eq!(engine.invalidated.len(), 1);
343    }
344
345    #[test]
346    fn apply_idempotent_on_replay() {
347        let mut engine = MockEngine::new();
348        engine.register_array("a", hlc(100));
349
350        let op = put_op("a", 50, 100);
351        apply_op(&mut engine, &op).unwrap();
352        let outcome = apply_op(&mut engine, &op).unwrap();
353        assert_eq!(outcome, ApplyOutcome::Idempotent);
354        // Still only one applied op.
355        assert_eq!(engine.applied.len(), 1);
356    }
357
358    #[test]
359    fn apply_rejects_unknown_array() {
360        let mut engine = MockEngine::new();
361        let op = put_op("unknown", 50, 100);
362        let outcome = apply_op(&mut engine, &op).unwrap();
363        assert!(matches!(
364            outcome,
365            ApplyOutcome::Rejected(ApplyRejection::ArrayUnknown { name }) if name == "unknown"
366        ));
367    }
368
369    #[test]
370    fn apply_rejects_schema_too_new() {
371        let mut engine = MockEngine::new();
372        engine.register_array("a", hlc(50)); // local schema at ms=50
373        let op = put_op("a", 10, 100); // op schema_hlc at ms=100 > 50
374        let outcome = apply_op(&mut engine, &op).unwrap();
375        assert!(matches!(
376            outcome,
377            ApplyOutcome::Rejected(ApplyRejection::SchemaTooNew { local, op: op_hlc })
378            if local == hlc(50) && op_hlc == hlc(100)
379        ));
380    }
381
382    /// Two replicas that stamp a schema in the same millisecond must not have
383    /// admission decided by their replica ids. Before `Hlc::is_later_than` the
384    /// gate used the full `Ord`, so an op from a replica with a numerically
385    /// larger id was rejected as `SchemaTooNew` while the identical op from a
386    /// smaller id applied — a coin flip that showed up as a flaky sync test.
387    #[test]
388    fn same_instant_schema_is_admitted_regardless_of_replica_id() {
389        for (local_replica, op_replica) in [(1u64, 9u64), (9, 1)] {
390            let local_schema = Hlc::new(50, 0, ReplicaId::new(local_replica)).unwrap();
391            let op_schema = Hlc::new(50, 0, ReplicaId::new(op_replica)).unwrap();
392
393            let mut engine = MockEngine::new();
394            engine.register_array("a", local_schema);
395            let mut op = put_op("a", 10, 100);
396            op.header.schema_hlc = op_schema;
397
398            let outcome = apply_op(&mut engine, &op).unwrap();
399            assert_eq!(
400                outcome,
401                ApplyOutcome::Applied,
402                "same-instant schema must apply (local replica {local_replica}, \
403                 op replica {op_replica}); the replica_id tiebreak must not gate admission"
404            );
405        }
406    }
407
408    #[test]
409    fn apply_rejects_invalid_shape() {
410        let mut engine = MockEngine::new();
411        engine.register_array("a", hlc(100));
412        // Put op with no attrs is invalid.
413        let op = ArrayOp {
414            header: header("a", 50, 100),
415            kind: ArrayOpKind::Put,
416            coord: vec![CoordValue::Int64(1)],
417            attrs: None,
418        };
419        let outcome = apply_op(&mut engine, &op).unwrap();
420        assert!(matches!(
421            outcome,
422            ApplyOutcome::Rejected(ApplyRejection::ShapeInvalid { .. })
423        ));
424    }
425
426    #[test]
427    fn apply_wraps_engine_error_as_rejection() {
428        let mut engine = MockEngine::new();
429        engine.register_array("a", hlc(100));
430        engine.set_reject_next(ArrayError::InvalidOp {
431            detail: "simulated engine error".into(),
432        });
433        let op = put_op("a", 50, 100);
434        let outcome = apply_op(&mut engine, &op).unwrap();
435        assert!(matches!(
436            outcome,
437            ApplyOutcome::Rejected(ApplyRejection::EngineRejected { .. })
438        ));
439        // No tile invalidation on rejection.
440        assert!(engine.invalidated.is_empty());
441    }
442
443    #[test]
444    fn apply_invalidates_tile_after_success() {
445        let mut engine = MockEngine::new();
446        engine.register_array("b", hlc(100));
447        let op = delete_op("b", 30, 50);
448        apply_op(&mut engine, &op).unwrap();
449        assert_eq!(engine.invalidated.len(), 1);
450        assert_eq!(engine.invalidated[0].0, "b");
451    }
452}