Skip to main content

nodedb_array/sync/
schema_crdt.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Loro-backed CRDT schema document for arrays.
4//!
5//! [`SchemaDoc`] wraps a [`LoroDoc`] to give each array a CRDT-replicated
6//! schema. The schema is stored as a MessagePack blob under the key
7//! `"content"` in a root Loro map named `"root"`. HLC tracking ensures that
8//! schema changes can be causally ordered with cell ops.
9//!
10//! This is the minimum surface needed for the initial Array sync. ALTER
11//! ARRAY support (incremental dimension/attribute add, domain expansion)
12//! will build on top of the `replace_schema` path exposed here.
13
14use loro::{LoroDoc, LoroMap, LoroValue};
15
16use crate::error::{ArrayError, ArrayResult};
17use crate::schema::array_schema::ArraySchema;
18use crate::sync::hlc::{Hlc, HlcGenerator};
19use crate::sync::replica_id::ReplicaId;
20
21/// Envelope version for the Loro snapshot bytes produced and consumed by this
22/// module.
23///
24/// Format: `[LORO_FORMAT_VERSION: u8][loro snapshot bytes…]`
25///
26/// Increment this constant (and add a migration path) whenever the Loro
27/// snapshot wire format changes in a backward-incompatible way. The version is
28/// checked on every import so that snapshots from an older binary are rejected
29/// with a clear error rather than silently corrupting state.
30pub const LORO_FORMAT_VERSION: u8 = 1;
31
32/// Maximum enveloped Loro schema snapshot bytes accepted before metadata
33/// decoding or import allocation.
34pub const MAX_LORO_SCHEMA_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024;
35/// Maximum operations physically encoded in an admitted schema snapshot.
36pub const MAX_LORO_SCHEMA_SNAPSHOT_OPERATIONS: usize = 100_000;
37
38#[derive(Debug, Clone, Copy)]
39struct SchemaSnapshotLimits {
40    max_bytes: usize,
41    max_operations: usize,
42}
43
44impl Default for SchemaSnapshotLimits {
45    fn default() -> Self {
46        Self {
47            max_bytes: MAX_LORO_SCHEMA_SNAPSHOT_BYTES,
48            max_operations: MAX_LORO_SCHEMA_SNAPSHOT_OPERATIONS,
49        }
50    }
51}
52
53/// Loro-backed CRDT document tracking a single array's schema.
54///
55/// The schema is stored as a MessagePack blob at root map key `"content"`.
56/// `schema_hlc` is the HLC of the most-recent schema write on this replica;
57/// it is compared against the `schema_hlc` embedded in each [`ArrayOp`]
58/// header to gate op application.
59///
60/// `LoroDoc` is not `Clone`, so [`SchemaDoc`] is not `Clone` either.
61pub struct SchemaDoc {
62    doc: LoroDoc,
63    schema_hlc: Hlc,
64    replica_id: ReplicaId,
65}
66
67impl SchemaDoc {
68    /// Create an empty schema doc for `replica_id`.
69    ///
70    /// `schema_hlc` starts at `Hlc::ZERO`. Call [`SchemaDoc::from_schema`]
71    /// or [`SchemaDoc::import_snapshot`] to populate it.
72    pub fn new(replica_id: ReplicaId) -> Self {
73        Self {
74            doc: LoroDoc::new(),
75            schema_hlc: Hlc::ZERO,
76            replica_id,
77        }
78    }
79
80    /// Construct a schema doc pre-populated with `schema`.
81    ///
82    /// The schema is encoded as MessagePack and stored under
83    /// `root["content"]`. `generator.next()` is called to assign the initial
84    /// `schema_hlc`.
85    pub fn from_schema(
86        replica_id: ReplicaId,
87        schema: &ArraySchema,
88        generator: &HlcGenerator,
89    ) -> ArrayResult<Self> {
90        let mut doc_self = Self::new(replica_id);
91        doc_self.write_schema_to_doc(schema)?;
92        doc_self.schema_hlc = generator.next()?;
93        Ok(doc_self)
94    }
95
96    /// Return the current schema HLC.
97    pub fn schema_hlc(&self) -> Hlc {
98        self.schema_hlc
99    }
100
101    /// Return the replica ID of this doc.
102    pub fn replica_id(&self) -> ReplicaId {
103        self.replica_id
104    }
105
106    /// Decode the stored schema from the Loro doc.
107    ///
108    /// Reads the MessagePack blob at `root["content"]` and decodes it via
109    /// zerompk. Errors map to [`ArrayError::SegmentCorruption`].
110    pub fn to_schema(&self) -> ArrayResult<ArraySchema> {
111        let root = self.doc.get_map("root");
112        let bytes = self.read_content_bytes(&root)?;
113        zerompk::from_msgpack(&bytes).map_err(|e| ArrayError::SegmentCorruption {
114            detail: format!("schema decode failed: {e}"),
115        })
116    }
117
118    /// Export the full Loro snapshot as an enveloped byte buffer.
119    ///
120    /// The returned bytes have the format:
121    /// `[LORO_FORMAT_VERSION: u8][loro snapshot bytes…]`
122    ///
123    /// Pass the result to [`SchemaDoc::import_snapshot`] (or
124    /// [`SchemaDoc::import_snapshot_replicated`]) on another replica to
125    /// converge schema state.
126    pub fn export_snapshot(&self) -> ArrayResult<Vec<u8>> {
127        let loro_bytes = self.doc.export(loro::ExportMode::Snapshot).map_err(|e| {
128            ArrayError::SegmentCorruption {
129                detail: format!("loro snapshot export failed: {e}"),
130            }
131        })?;
132        let mut envelope = Vec::with_capacity(1 + loro_bytes.len());
133        envelope.push(LORO_FORMAT_VERSION);
134        envelope.extend_from_slice(&loro_bytes);
135        Ok(envelope)
136    }
137
138    /// Import a Loro snapshot from a remote replica.
139    ///
140    /// After merging the snapshot, `generator.observe(remote_hlc)` is called
141    /// so the local generator incorporates the remote clock. A fresh
142    /// `schema_hlc` is then generated via `generator.next()` so that any
143    /// subsequent local writes have an HLC strictly greater than
144    /// `remote_hlc`.
145    pub fn import_snapshot(
146        &mut self,
147        bytes: &[u8],
148        remote_hlc: Hlc,
149        generator: &HlcGenerator,
150    ) -> ArrayResult<()> {
151        self.import_snapshot_with_limits(
152            bytes,
153            remote_hlc,
154            generator,
155            SchemaSnapshotLimits::default(),
156        )
157    }
158
159    fn import_snapshot_with_limits(
160        &mut self,
161        bytes: &[u8],
162        remote_hlc: Hlc,
163        generator: &HlcGenerator,
164        limits: SchemaSnapshotLimits,
165    ) -> ArrayResult<()> {
166        let loro_bytes = admit_snapshot(bytes, limits)?;
167        self.doc
168            .import(loro_bytes)
169            .map_err(|e| ArrayError::LoroError {
170                detail: format!("loro import failed: {e}"),
171            })?;
172        generator.observe(remote_hlc)?;
173        self.schema_hlc = generator.next()?;
174        Ok(())
175    }
176
177    /// Import a Loro snapshot from a Raft-committed entry.
178    ///
179    /// Unlike [`import_snapshot`], this method sets `schema_hlc` to exactly
180    /// `remote_hlc` rather than bumping past it. This is the correct
181    /// behaviour for Raft replication: every replica must converge to the
182    /// *same* `schema_hlc` after applying the same log entry so that
183    /// schema-gating checks on ops are consistent across the cluster.
184    ///
185    /// Call this only from the distributed applier (Raft commit path), not
186    /// from the CRDT sync path where bumping is required.
187    pub fn import_snapshot_replicated(
188        &mut self,
189        bytes: &[u8],
190        committed_hlc: Hlc,
191    ) -> ArrayResult<()> {
192        self.import_snapshot_replicated_with_limits(
193            bytes,
194            committed_hlc,
195            SchemaSnapshotLimits::default(),
196        )
197    }
198
199    fn import_snapshot_replicated_with_limits(
200        &mut self,
201        bytes: &[u8],
202        committed_hlc: Hlc,
203        limits: SchemaSnapshotLimits,
204    ) -> ArrayResult<()> {
205        let loro_bytes = admit_snapshot(bytes, limits)?;
206        self.doc
207            .import(loro_bytes)
208            .map_err(|e| ArrayError::LoroError {
209                detail: format!("loro import (replicated) failed: {e}"),
210            })?;
211        self.schema_hlc = committed_hlc;
212        Ok(())
213    }
214
215    /// Replace the stored schema with `schema`.
216    ///
217    /// Re-encodes the schema as MessagePack and overwrites `root["content"]`.
218    /// Bumps `schema_hlc` via `generator.next()`.
219    ///
220    /// This is the stub entry point for Phase F ALTER ARRAY support.
221    /// Incremental dim/attr add will build on this path.
222    pub fn replace_schema(
223        &mut self,
224        schema: &ArraySchema,
225        generator: &HlcGenerator,
226    ) -> ArrayResult<()> {
227        self.write_schema_to_doc(schema)?;
228        self.schema_hlc = generator.next()?;
229        Ok(())
230    }
231
232    // ─── Internal helpers ────────────────────────────────────────────────────
233
234    fn write_schema_to_doc(&self, schema: &ArraySchema) -> ArrayResult<()> {
235        let schema_bytes =
236            zerompk::to_msgpack_vec(schema).map_err(|e| ArrayError::SegmentCorruption {
237                detail: format!("schema encode failed: {e}"),
238            })?;
239        let root: LoroMap = self.doc.get_map("root");
240        root.insert("content", LoroValue::Binary(schema_bytes.into()))
241            .map_err(|e| ArrayError::LoroError {
242                detail: format!("loro map insert failed: {e}"),
243            })?;
244        Ok(())
245    }
246
247    fn read_content_bytes(&self, root: &LoroMap) -> ArrayResult<Vec<u8>> {
248        match root.get("content") {
249            Some(loro::ValueOrContainer::Value(LoroValue::Binary(b))) => Ok(b.to_vec()),
250            Some(other) => Err(ArrayError::SegmentCorruption {
251                detail: format!("expected Binary at root[\"content\"], got {:?}", other),
252            }),
253            None => Err(ArrayError::SegmentCorruption {
254                detail: "root[\"content\"] not found".into(),
255            }),
256        }
257    }
258}
259
260// ─── Envelope helpers ─────────────────────────────────────────────────────────
261
262/// Admit an enveloped Loro schema snapshot before its raw bytes reach Loro.
263///
264/// The byte and encoded-operation budgets, authenticated metadata parse, and
265/// checked operation range all complete before the caller can mutate schema
266/// state or observe an HLC.
267fn admit_snapshot(bytes: &[u8], limits: SchemaSnapshotLimits) -> ArrayResult<&[u8]> {
268    if bytes.len() > limits.max_bytes {
269        return Err(ArrayError::LoroSnapshotTooLarge {
270            limit: limits.max_bytes,
271            actual: bytes.len(),
272        });
273    }
274    let loro_bytes = strip_envelope(bytes)?;
275    let metadata = loro::LoroDoc::decode_import_blob_meta(loro_bytes, true).map_err(|error| {
276        ArrayError::LoroSnapshotMalformed {
277            detail: error.to_string(),
278        }
279    })?;
280    let operation_count =
281        encoded_operation_count(&metadata.partial_start_vv, &metadata.partial_end_vv)?;
282    if operation_count > limits.max_operations {
283        return Err(ArrayError::LoroSnapshotOperationLimitExceeded {
284            limit: limits.max_operations,
285            actual: operation_count,
286        });
287    }
288    Ok(loro_bytes)
289}
290
291fn encoded_operation_count(
292    start: &loro::VersionVector,
293    end: &loro::VersionVector,
294) -> ArrayResult<usize> {
295    for (peer, start_counter) in start.iter() {
296        if end.get(peer).copied().unwrap_or_default() < *start_counter {
297            return Err(ArrayError::LoroSnapshotMalformed {
298                detail: "Loro operation range regresses".into(),
299            });
300        }
301    }
302    end.iter().try_fold(0usize, |total, (peer, end_counter)| {
303        let start_counter = start.get(peer).copied().unwrap_or_default();
304        let operations = end_counter.checked_sub(start_counter).ok_or_else(|| {
305            ArrayError::LoroSnapshotMalformed {
306                detail: "Loro operation range regresses".into(),
307            }
308        })?;
309        let operations =
310            usize::try_from(operations).map_err(|_| ArrayError::LoroSnapshotMalformed {
311                detail: "Loro operation count does not fit platform usize".into(),
312            })?;
313        total
314            .checked_add(operations)
315            .ok_or_else(|| ArrayError::LoroSnapshotMalformed {
316                detail: "Loro operation count overflow".into(),
317            })
318    })
319}
320
321fn strip_envelope(bytes: &[u8]) -> ArrayResult<&[u8]> {
322    match bytes.first() {
323        None => Err(ArrayError::SegmentCorruption {
324            detail: "loro snapshot envelope is empty".into(),
325        }),
326        Some(&v) if v != LORO_FORMAT_VERSION => Err(ArrayError::LoroSnapshotVersionMismatch {
327            expected: LORO_FORMAT_VERSION,
328            got: v,
329        }),
330        Some(_) => Ok(&bytes[1..]),
331    }
332}
333
334// ─── Tests ───────────────────────────────────────────────────────────────────
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::schema::array_schema::ArraySchema;
340    use crate::schema::attr_spec::{AttrSpec, AttrType};
341    use crate::schema::cell_order::{CellOrder, TileOrder};
342    use crate::schema::dim_spec::{DimSpec, DimType};
343    use crate::sync::hlc::HlcGenerator;
344    use crate::sync::replica_id::ReplicaId;
345    use crate::types::domain::{Domain, DomainBound};
346
347    fn replica(id: u64) -> ReplicaId {
348        ReplicaId::new(id)
349    }
350
351    fn generator(id: u64) -> HlcGenerator {
352        HlcGenerator::new(replica(id))
353    }
354
355    fn simple_schema(name: &str) -> ArraySchema {
356        ArraySchema {
357            name: name.into(),
358            dims: vec![DimSpec::new(
359                "x",
360                DimType::Int64,
361                Domain::new(DomainBound::Int64(0), DomainBound::Int64(99)),
362            )],
363            attrs: vec![AttrSpec::new("v", AttrType::Float64, true)],
364            tile_extents: vec![10],
365            cell_order: CellOrder::RowMajor,
366            tile_order: TileOrder::RowMajor,
367        }
368    }
369
370    #[test]
371    fn from_schema_then_to_schema_roundtrips() {
372        let g = generator(1);
373        let schema = simple_schema("arr");
374        let doc = SchemaDoc::from_schema(replica(1), &schema, &g).unwrap();
375        let back = doc.to_schema().unwrap();
376        assert_eq!(schema, back);
377        assert!(doc.schema_hlc() > Hlc::ZERO);
378    }
379
380    #[test]
381    fn replace_schema_bumps_hlc() {
382        let g = generator(1);
383        let schema = simple_schema("arr");
384        let mut doc = SchemaDoc::from_schema(replica(1), &schema, &g).unwrap();
385        let hlc_before = doc.schema_hlc();
386
387        let schema2 = simple_schema("arr2");
388        doc.replace_schema(&schema2, &g).unwrap();
389        assert!(doc.schema_hlc() > hlc_before);
390        assert_eq!(doc.to_schema().unwrap(), schema2);
391    }
392
393    #[test]
394    fn export_then_import_converges() {
395        let g_a = generator(1);
396        let schema = simple_schema("shared");
397        let doc_a = SchemaDoc::from_schema(replica(1), &schema, &g_a).unwrap();
398        let snapshot = doc_a.export_snapshot().unwrap();
399
400        let g_b = generator(2);
401        let mut doc_b = SchemaDoc::new(replica(2));
402        doc_b
403            .import_snapshot(&snapshot, doc_a.schema_hlc(), &g_b)
404            .unwrap();
405
406        assert_eq!(doc_a.to_schema().unwrap(), doc_b.to_schema().unwrap());
407    }
408
409    #[test]
410    fn import_observes_remote_hlc() {
411        let g_a = generator(1);
412        let schema = simple_schema("x");
413        let doc_a = SchemaDoc::from_schema(replica(1), &schema, &g_a).unwrap();
414        let remote_hlc = doc_a.schema_hlc();
415        let snapshot = doc_a.export_snapshot().unwrap();
416
417        let g_b = generator(2);
418        let mut doc_b = SchemaDoc::new(replica(2));
419        doc_b.import_snapshot(&snapshot, remote_hlc, &g_b).unwrap();
420
421        // After import, any new local write must produce hlc > remote_hlc.
422        doc_b.replace_schema(&simple_schema("x2"), &g_b).unwrap();
423        assert!(doc_b.schema_hlc() > remote_hlc);
424    }
425
426    #[test]
427    fn import_garbage_errors() {
428        let g = generator(1);
429        let mut doc = SchemaDoc::new(replica(1));
430        // Prefix with a valid version byte so we get past the envelope check
431        // and into Loro's own parser — which should reject the payload.
432        let mut bad = vec![LORO_FORMAT_VERSION];
433        bad.extend_from_slice(b"not valid loro data");
434        let result = doc.import_snapshot(&bad, Hlc::ZERO, &g);
435        assert!(result.is_err());
436    }
437
438    #[test]
439    fn export_snapshot_has_version_prefix() {
440        let g = generator(1);
441        let schema = simple_schema("arr");
442        let doc = SchemaDoc::from_schema(replica(1), &schema, &g).unwrap();
443        let snapshot = doc.export_snapshot().unwrap();
444        assert!(!snapshot.is_empty());
445        assert_eq!(snapshot[0], LORO_FORMAT_VERSION);
446    }
447
448    #[test]
449    fn pre_import_admission_rejections_preserve_schema_and_hlc() {
450        let generator = generator(1);
451        let baseline = simple_schema("baseline");
452        let mut doc = SchemaDoc::from_schema(replica(1), &baseline, &generator).unwrap();
453        let before_hlc = doc.schema_hlc();
454        let limits = SchemaSnapshotLimits {
455            max_bytes: 1,
456            max_operations: 1,
457        };
458        let result = doc.import_snapshot_with_limits(
459            &[LORO_FORMAT_VERSION, 0],
460            Hlc::ZERO,
461            &generator,
462            limits,
463        );
464        assert!(matches!(
465            result,
466            Err(ArrayError::LoroSnapshotTooLarge { .. })
467        ));
468        assert_eq!(doc.to_schema().unwrap(), baseline);
469        assert_eq!(doc.schema_hlc(), before_hlc);
470
471        let result = doc.import_snapshot_with_limits(
472            &[LORO_FORMAT_VERSION, 0xff],
473            Hlc::ZERO,
474            &generator,
475            SchemaSnapshotLimits::default(),
476        );
477        assert!(matches!(
478            result,
479            Err(ArrayError::LoroSnapshotMalformed { .. })
480        ));
481        assert_eq!(doc.to_schema().unwrap(), baseline);
482        assert_eq!(doc.schema_hlc(), before_hlc);
483    }
484
485    #[test]
486    fn operation_admission_rejects_before_mutating_replicated_schema() {
487        let source_generator = generator(2);
488        let source =
489            SchemaDoc::from_schema(replica(2), &simple_schema("source"), &source_generator)
490                .unwrap();
491        let snapshot = source.export_snapshot().unwrap();
492
493        let target_generator = generator(1);
494        let baseline = simple_schema("baseline");
495        let mut target = SchemaDoc::from_schema(replica(1), &baseline, &target_generator).unwrap();
496        let before_hlc = target.schema_hlc();
497        let result = target.import_snapshot_replicated_with_limits(
498            &snapshot,
499            source.schema_hlc(),
500            SchemaSnapshotLimits {
501                max_bytes: snapshot.len(),
502                max_operations: 0,
503            },
504        );
505        assert!(matches!(
506            result,
507            Err(ArrayError::LoroSnapshotOperationLimitExceeded { .. })
508        ));
509        assert_eq!(target.to_schema().unwrap(), baseline);
510        assert_eq!(target.schema_hlc(), before_hlc);
511    }
512
513    #[test]
514    fn import_snapshot_rejects_wrong_version() {
515        let g_a = generator(1);
516        let schema = simple_schema("v");
517        let doc_a = SchemaDoc::from_schema(replica(1), &schema, &g_a).unwrap();
518        let mut snapshot = doc_a.export_snapshot().unwrap();
519
520        // Corrupt the version byte.
521        snapshot[0] = LORO_FORMAT_VERSION.wrapping_add(1);
522
523        let g_b = generator(2);
524        let mut doc_b = SchemaDoc::new(replica(2));
525        let err = doc_b
526            .import_snapshot(&snapshot, doc_a.schema_hlc(), &g_b)
527            .unwrap_err();
528        assert!(
529            matches!(
530                err,
531                ArrayError::LoroSnapshotVersionMismatch { expected, got }
532                    if expected == LORO_FORMAT_VERSION && got == LORO_FORMAT_VERSION.wrapping_add(1)
533            ),
534            "unexpected error: {err}"
535        );
536    }
537
538    #[test]
539    fn import_snapshot_replicated_rejects_wrong_version() {
540        let g_a = generator(1);
541        let schema = simple_schema("v2");
542        let doc_a = SchemaDoc::from_schema(replica(1), &schema, &g_a).unwrap();
543        let mut snapshot = doc_a.export_snapshot().unwrap();
544
545        snapshot[0] = 0; // version 0 has never existed
546
547        let mut doc_b = SchemaDoc::new(replica(2));
548        let err = doc_b
549            .import_snapshot_replicated(&snapshot, doc_a.schema_hlc())
550            .unwrap_err();
551        assert!(
552            matches!(
553                err,
554                ArrayError::LoroSnapshotVersionMismatch { expected, got }
555                    if expected == LORO_FORMAT_VERSION && got == 0
556            ),
557            "unexpected error: {err}"
558        );
559    }
560}