Skip to main content

nodedb_lite/sync/array/
schema_registry.rs

1//! Per-array [`SchemaDoc`] registry with durable cold-start snapshots.
2//!
3//! Each registered array has one Loro-backed [`SchemaDoc`] that tracks the
4//! array's schema and its [`Hlc`] version. Snapshots are persisted under
5//! `Namespace::Meta` keys of the form `b"array.schema_doc:{name}"` so the
6//! registry can be reconstructed after process restart without a full sync.
7//!
8//! # Storage format
9//!
10//! Each key maps to a MessagePack-encoded [`PersistedSchema`] tuple:
11//! `(replica_id: u64, schema_hlc_bytes: [u8; 18], loro_snapshot: Vec<u8>)`.
12//!
13//! # Cold-start scan
14//!
15//! [`SchemaRegistry::load`] reads every key under the prefix
16//! `b"array.schema_doc:"` with one prefix scan.
17
18use std::collections::HashMap;
19use std::collections::hash_map::Entry;
20use std::sync::{Arc, Mutex};
21
22use nodedb_array::schema::array_schema::ArraySchema;
23use nodedb_array::sync::hlc::Hlc;
24use nodedb_array::sync::schema_crdt::SchemaDoc;
25use nodedb_types::Namespace;
26
27use crate::error::LiteError;
28use crate::storage::engine::StorageEngine;
29use crate::sync::array::replica_state::ReplicaState;
30
31/// Prefix used for schema snapshot storage keys.
32const SCHEMA_KEY_PREFIX: &str = "array.schema_doc:";
33
34/// Msgpack-serialisable payload persisted per schema.
35#[derive(zerompk::ToMessagePack, zerompk::FromMessagePack)]
36struct PersistedSchema {
37    replica_id: u64,
38    schema_hlc_bytes: Vec<u8>,
39    loro_snapshot: Vec<u8>,
40}
41
42/// Storage key for a named array schema snapshot.
43fn schema_key(name: &str) -> Vec<u8> {
44    format!("{SCHEMA_KEY_PREFIX}{name}").into_bytes()
45}
46
47/// Per-array [`SchemaDoc`] registry.
48///
49/// Thread-safe via an internal [`Mutex`]. Multiple subsystems hold an
50/// `Arc<SchemaRegistry<S>>` and call into it independently.
51pub struct SchemaRegistry<S: StorageEngine> {
52    storage: Arc<S>,
53    docs: Mutex<HashMap<String, SchemaDoc>>,
54    replica: Arc<ReplicaState>,
55    /// Serializes each schema mutation with its snapshot write.
56    ///
57    /// A mutation edits `docs` under a `std::sync` guard, releases it, then
58    /// awaits the storage write. Without this lock two writers to one array
59    /// can reorder their writes, and storage keeps the older snapshot.
60    write_lock: tokio::sync::Mutex<()>,
61}
62
63impl<S: StorageEngine> SchemaRegistry<S> {
64    /// Create an empty registry (no cold-start scan).
65    ///
66    /// Use [`load`] to reconstruct persisted schemas on startup.
67    pub fn new(storage: Arc<S>, replica: Arc<ReplicaState>) -> Self {
68        Self {
69            storage,
70            docs: Mutex::new(HashMap::new()),
71            replica,
72            write_lock: tokio::sync::Mutex::new(()),
73        }
74    }
75
76    /// Load all persisted [`SchemaDoc`] snapshots from storage.
77    ///
78    /// Reads every `Namespace::Meta` key under `b"array.schema_doc:"`.
79    pub async fn load(storage: Arc<S>, replica: Arc<ReplicaState>) -> Result<Self, LiteError> {
80        let prefix = SCHEMA_KEY_PREFIX.as_bytes();
81        let pairs = storage.scan_prefix(Namespace::Meta, prefix).await?;
82
83        let mut docs = HashMap::new();
84        for (key, value) in pairs {
85            let name_bytes = &key[prefix.len()..];
86            let name = std::str::from_utf8(name_bytes).map_err(|e| LiteError::Storage {
87                detail: format!("schema_registry: non-UTF8 array name in storage: {e}"),
88            })?;
89
90            let persisted: PersistedSchema =
91                zerompk::from_msgpack(&value).map_err(|e| LiteError::Storage {
92                    detail: format!("schema_registry: decode PersistedSchema for '{name}': {e}"),
93                })?;
94
95            let hlc_arr: [u8; 18] =
96                persisted
97                    .schema_hlc_bytes
98                    .try_into()
99                    .map_err(|v: Vec<u8>| LiteError::Storage {
100                        detail: format!(
101                            "schema_registry: schema_hlc_bytes wrong length ({}) for '{name}'",
102                            v.len()
103                        ),
104                    })?;
105            let schema_hlc = Hlc::from_bytes(&hlc_arr);
106
107            let mut doc = SchemaDoc::new(replica.replica_id());
108            doc.import_snapshot(&persisted.loro_snapshot, schema_hlc, &replica.hlc_gen())
109                .map_err(|e| LiteError::Storage {
110                    detail: format!("schema_registry: loro import for '{name}': {e}"),
111                })?;
112
113            docs.insert(name.to_owned(), doc);
114        }
115
116        Ok(Self {
117            storage,
118            docs: Mutex::new(docs),
119            replica,
120            write_lock: tokio::sync::Mutex::new(()),
121        })
122    }
123
124    /// Register or replace the schema for `name`, minting a new [`Hlc`].
125    ///
126    /// Persists a Loro snapshot under the schema key so the registry
127    /// survives restarts. Returns the freshly minted `schema_hlc`.
128    pub async fn put_schema(&self, name: &str, schema: &ArraySchema) -> Result<Hlc, LiteError> {
129        let _write = self.write_lock.lock().await;
130        let (schema_hlc, snapshot) = {
131            let mut docs = self.docs.lock().map_err(|_| LiteError::LockPoisoned)?;
132
133            let doc = match docs.entry(name.to_owned()) {
134                Entry::Occupied(entry) => {
135                    let existing = entry.into_mut();
136                    existing
137                        .replace_schema(schema, &self.replica.hlc_gen())
138                        .map_err(|e| LiteError::Storage {
139                            detail: format!("schema_registry put_schema '{name}': {e}"),
140                        })?;
141                    existing
142                }
143                Entry::Vacant(entry) => {
144                    let new_doc = SchemaDoc::from_schema(
145                        self.replica.replica_id(),
146                        schema,
147                        &self.replica.hlc_gen(),
148                    )
149                    .map_err(|e| LiteError::Storage {
150                        detail: format!("schema_registry from_schema '{name}': {e}"),
151                    })?;
152                    entry.insert(new_doc)
153                }
154            };
155
156            let schema_hlc = doc.schema_hlc();
157            let snapshot = doc.export_snapshot().map_err(|e| LiteError::Storage {
158                detail: format!("schema_registry export '{name}': {e}"),
159            })?;
160            (schema_hlc, snapshot)
161        };
162
163        self.persist(name, schema_hlc, snapshot).await?;
164        Ok(schema_hlc)
165    }
166
167    /// Return the current `schema_hlc` for `name`, or `None` if unknown.
168    pub fn schema_hlc(&self, name: &str) -> Option<Hlc> {
169        let docs = self.docs.lock().ok()?;
170        docs.get(name).map(|d| d.schema_hlc())
171    }
172
173    /// Apply a remote Loro snapshot for `name`.
174    ///
175    /// Creates the entry if absent. Persists the updated snapshot.
176    /// The inbound schema-sync handler calls this for each schema message.
177    pub async fn import_snapshot(
178        &self,
179        name: &str,
180        snapshot_bytes: &[u8],
181        remote_hlc: Hlc,
182    ) -> Result<(), LiteError> {
183        let _write = self.write_lock.lock().await;
184        let (schema_hlc, snapshot) = {
185            let mut docs = self.docs.lock().map_err(|_| LiteError::LockPoisoned)?;
186
187            let doc = docs
188                .entry(name.to_owned())
189                .or_insert_with(|| SchemaDoc::new(self.replica.replica_id()));
190
191            doc.import_snapshot(snapshot_bytes, remote_hlc, &self.replica.hlc_gen())
192                .map_err(|e| LiteError::Storage {
193                    detail: format!("schema_registry import_snapshot '{name}': {e}"),
194                })?;
195
196            let schema_hlc = doc.schema_hlc();
197            let snapshot = doc.export_snapshot().map_err(|e| LiteError::Storage {
198                detail: format!("schema_registry export after import '{name}': {e}"),
199            })?;
200            (schema_hlc, snapshot)
201        };
202
203        self.persist(name, schema_hlc, snapshot).await
204    }
205
206    /// Return all array names currently registered in this registry.
207    pub fn list_arrays(&self) -> Vec<String> {
208        self.docs
209            .lock()
210            .ok()
211            .map(|docs| docs.keys().cloned().collect())
212            .unwrap_or_default()
213    }
214
215    /// Export the current Loro snapshot bytes for `name`.
216    ///
217    /// Returns `None` if `name` is not registered.
218    pub fn export_snapshot(&self, name: &str) -> Result<Option<Vec<u8>>, LiteError> {
219        let docs = self.docs.lock().map_err(|_| LiteError::LockPoisoned)?;
220        if let Some(doc) = docs.get(name) {
221            let bytes = doc.export_snapshot().map_err(|e| LiteError::Storage {
222                detail: format!("schema_registry export_snapshot '{name}': {e}"),
223            })?;
224            Ok(Some(bytes))
225        } else {
226            Ok(None)
227        }
228    }
229
230    /// Export `name`'s current `schema_hlc` and Loro snapshot together.
231    ///
232    /// Both come from one lock acquisition, so the HLC always describes the
233    /// snapshot it travels with. Returns `None` if `name` is not registered.
234    pub fn export_versioned(&self, name: &str) -> Result<Option<(Hlc, Vec<u8>)>, LiteError> {
235        let docs = self.docs.lock().map_err(|_| LiteError::LockPoisoned)?;
236        let Some(doc) = docs.get(name) else {
237            return Ok(None);
238        };
239        let bytes = doc.export_snapshot().map_err(|e| LiteError::Storage {
240            detail: format!("schema_registry export_versioned '{name}': {e}"),
241        })?;
242        Ok(Some((doc.schema_hlc(), bytes)))
243    }
244
245    // ─── Internal helpers ─────────────────────────────────────────────────────
246
247    async fn persist(
248        &self,
249        name: &str,
250        schema_hlc: Hlc,
251        loro_snapshot: Vec<u8>,
252    ) -> Result<(), LiteError> {
253        let persisted = PersistedSchema {
254            replica_id: self.replica.replica_id().as_u64(),
255            schema_hlc_bytes: schema_hlc.to_bytes().to_vec(),
256            loro_snapshot,
257        };
258        let bytes = zerompk::to_msgpack_vec(&persisted).map_err(|e| LiteError::Serialization {
259            detail: format!("schema_registry persist '{name}': {e}"),
260        })?;
261        self.storage
262            .put(Namespace::Meta, &schema_key(name), &bytes)
263            .await
264    }
265}
266
267// ─── Tests ────────────────────────────────────────────────────────────────────
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::storage::pagedb_storage::{PagedbStorageDefault, PagedbStorageMem};
273    use nodedb_array::schema::array_schema::ArraySchema;
274    use nodedb_array::schema::attr_spec::{AttrSpec, AttrType};
275    use nodedb_array::schema::cell_order::{CellOrder, TileOrder};
276    use nodedb_array::schema::dim_spec::{DimSpec, DimType};
277    use nodedb_array::sync::replica_id::ReplicaId;
278    use nodedb_array::types::domain::{Domain, DomainBound};
279
280    fn simple_schema(name: &str) -> ArraySchema {
281        ArraySchema {
282            name: name.into(),
283            dims: vec![DimSpec::new(
284                "x",
285                DimType::Int64,
286                Domain::new(DomainBound::Int64(0), DomainBound::Int64(99)),
287            )],
288            attrs: vec![AttrSpec::new("v", AttrType::Float64, true)],
289            tile_extents: vec![10],
290            cell_order: CellOrder::RowMajor,
291            tile_order: TileOrder::RowMajor,
292        }
293    }
294
295    use crate::storage::engine::StorageEngine;
296
297    async fn make_replica(storage: &Arc<PagedbStorageMem>) -> Arc<ReplicaState> {
298        Arc::new(ReplicaState::load_or_init(&**storage).await.unwrap())
299    }
300
301    async fn make_registry(storage: Arc<PagedbStorageMem>) -> SchemaRegistry<PagedbStorageMem> {
302        let replica = make_replica(&storage).await;
303        SchemaRegistry::new(storage, replica)
304    }
305
306    #[tokio::test]
307    async fn put_schema_persists() {
308        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
309        let reg = make_registry(Arc::clone(&storage)).await;
310        let hlc = reg.put_schema("arr", &simple_schema("arr")).await.unwrap();
311        assert!(hlc > Hlc::ZERO);
312
313        // Storage must have the key.
314        let raw = storage
315            .get(Namespace::Meta, &schema_key("arr"))
316            .await
317            .unwrap();
318        assert!(raw.is_some(), "schema must be persisted");
319    }
320
321    #[tokio::test]
322    async fn load_restores_after_restart() {
323        let dir = tempfile::tempdir().unwrap();
324        let path = dir.path().join("schema_reg.pagedb");
325
326        let schema_hlc;
327        {
328            let storage = Arc::new(
329                PagedbStorageDefault::open(
330                    &path,
331                    crate::storage::encryption::Encryption::Plaintext,
332                )
333                .await
334                .unwrap(),
335            );
336            let replica = Arc::new(ReplicaState::load_or_init(&*storage).await.unwrap());
337            let reg = SchemaRegistry::new(Arc::clone(&storage), Arc::clone(&replica));
338            schema_hlc = reg.put_schema("arr", &simple_schema("arr")).await.unwrap();
339        }
340
341        {
342            let storage = Arc::new(
343                PagedbStorageDefault::open(
344                    &path,
345                    crate::storage::encryption::Encryption::Plaintext,
346                )
347                .await
348                .unwrap(),
349            );
350            let replica = Arc::new(ReplicaState::load_or_init(&*storage).await.unwrap());
351            let reg = SchemaRegistry::load(Arc::clone(&storage), replica)
352                .await
353                .unwrap();
354            let loaded_hlc = reg.schema_hlc("arr").expect("arr must be loaded");
355            // After import the HLC is bumped, so it must be >= the stored one.
356            assert!(loaded_hlc >= schema_hlc);
357        }
358    }
359
360    #[tokio::test]
361    async fn import_snapshot_creates_entry() {
362        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
363        let replica = make_replica(&storage).await;
364
365        // Create a schema on replica A.
366        let reg_a = SchemaRegistry::new(Arc::clone(&storage), Arc::clone(&replica));
367        reg_a.put_schema("x", &simple_schema("x")).await.unwrap();
368        let snapshot = reg_a.export_snapshot("x").unwrap().unwrap();
369        let remote_hlc = reg_a.schema_hlc("x").unwrap();
370
371        // Import on registry B.
372        let storage_b = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
373        let replica_b = make_replica(&storage_b).await;
374        let reg_b = SchemaRegistry::new(storage_b, replica_b);
375
376        assert!(reg_b.schema_hlc("x").is_none(), "not yet present");
377        reg_b
378            .import_snapshot("x", &snapshot, remote_hlc)
379            .await
380            .unwrap();
381        assert!(reg_b.schema_hlc("x").is_some(), "must exist after import");
382    }
383
384    #[tokio::test]
385    async fn import_snapshot_advances_hlc() {
386        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
387        let replica = make_replica(&storage).await;
388        let reg = SchemaRegistry::new(Arc::clone(&storage), Arc::clone(&replica));
389
390        // Seed a schema.
391        reg.put_schema("y", &simple_schema("y")).await.unwrap();
392        let hlc_before = reg.schema_hlc("y").unwrap();
393
394        // Import a snapshot with a higher HLC.
395        let snapshot = reg.export_snapshot("y").unwrap().unwrap();
396        let future_hlc = Hlc::new(hlc_before.physical_ms + 50_000, 0, ReplicaId::new(99)).unwrap();
397        reg.import_snapshot("y", &snapshot, future_hlc)
398            .await
399            .unwrap();
400
401        let hlc_after = reg.schema_hlc("y").unwrap();
402        assert!(hlc_after > hlc_before, "HLC must advance on import");
403    }
404
405    #[tokio::test]
406    async fn export_returns_none_for_unknown() {
407        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
408        let reg = make_registry(storage).await;
409        let result = reg.export_snapshot("nonexistent").unwrap();
410        assert!(result.is_none());
411    }
412
413    #[tokio::test]
414    async fn export_versioned_pairs_the_schema_hlc_with_its_snapshot() {
415        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
416        let reg = make_registry(storage).await;
417        let put_hlc = reg.put_schema("v", &simple_schema("v")).await.unwrap();
418
419        let (hlc, snapshot) = reg.export_versioned("v").unwrap().unwrap();
420        assert_eq!(hlc, put_hlc);
421        assert!(!snapshot.is_empty(), "the snapshot carries the schema");
422        assert!(reg.export_versioned("nonexistent").unwrap().is_none());
423    }
424}