Skip to main content

nodedb_lite/sync/array/
replica_state.rs

1//! Persistent replica identity and HLC generator for the array CRDT sync subsystem.
2//!
3//! [`ReplicaState`] is loaded once at [`NodeDbLite::open`] and shared across
4//! the sync subsystem via [`Arc`]. It combines the stable [`ReplicaId`]
5//! (persisted under `Namespace::Meta` key `b"array.replica_id"`) with a
6//! monotonic [`HlcGenerator`] seeded from that identity.
7
8use std::sync::Arc;
9
10use nodedb_array::sync::hlc::{Hlc, HlcGenerator};
11use nodedb_array::sync::replica_id::ReplicaId;
12use nodedb_types::Namespace;
13
14use crate::error::LiteError;
15use crate::storage::engine::StorageEngine;
16
17/// Storage key for the persistent replica id.
18const REPLICA_ID_KEY: &[u8] = b"array.replica_id";
19
20/// Persistent replica identity + monotonic HLC generator.
21///
22/// Constructed once at database open. Multiple subsystems share an
23/// `Arc<ReplicaState>` so they all mint HLCs from the same generator.
24pub struct ReplicaState {
25    replica_id: ReplicaId,
26    hlc_gen: Arc<HlcGenerator>,
27}
28
29impl ReplicaState {
30    /// Load the [`ReplicaId`] from `Namespace::Meta` key `b"array.replica_id"`.
31    ///
32    /// If no stored id is found, generates a fresh UUID-v7-derived [`ReplicaId`]
33    /// and persists it before returning. On subsequent opens the same id is
34    /// returned.
35    pub async fn load_or_init<S: StorageEngine>(storage: &S) -> Result<Self, LiteError> {
36        let existing = storage.get(Namespace::Meta, REPLICA_ID_KEY).await?;
37
38        let replica_id = if let Some(bytes) = existing {
39            if bytes.len() != 8 {
40                return Err(LiteError::Storage {
41                    detail: format!("array.replica_id: expected 8 bytes, got {}", bytes.len()),
42                });
43            }
44            let arr: [u8; 8] = bytes.try_into().map_err(|_| LiteError::Storage {
45                detail: "array.replica_id: byte conversion failed".into(),
46            })?;
47            ReplicaId::new(u64::from_be_bytes(arr))
48        } else {
49            let id = ReplicaId::generate();
50            storage
51                .put(Namespace::Meta, REPLICA_ID_KEY, &id.as_u64().to_be_bytes())
52                .await?;
53            id
54        };
55
56        let hlc_gen = Arc::new(HlcGenerator::new(replica_id));
57        Ok(Self {
58            replica_id,
59            hlc_gen,
60        })
61    }
62
63    /// The stable 64-bit replica identity.
64    pub fn replica_id(&self) -> ReplicaId {
65        self.replica_id
66    }
67
68    /// The shared [`HlcGenerator`] for this replica.
69    pub fn hlc_gen(&self) -> Arc<HlcGenerator> {
70        Arc::clone(&self.hlc_gen)
71    }
72
73    /// Generate the next monotonic [`Hlc`].
74    ///
75    /// Delegates to the internal [`HlcGenerator`]. Maps [`ArrayError`] into
76    /// [`LiteError::Storage`].
77    pub fn next_hlc(&self) -> Result<Hlc, LiteError> {
78        self.hlc_gen.next().map_err(|e| LiteError::Storage {
79            detail: format!("array sync HLC: {e}"),
80        })
81    }
82
83    /// Observe a remote [`Hlc`] and advance the local generator past it.
84    ///
85    /// Called when receiving any op or ack from a remote replica so that
86    /// subsequent locally minted HLCs are causally greater.
87    pub fn observe(&self, remote: Hlc) -> Result<(), LiteError> {
88        self.hlc_gen
89            .observe(remote)
90            .map_err(|e| LiteError::Storage {
91                detail: format!("array sync HLC observe: {e}"),
92            })
93    }
94}
95
96// ─── Tests ────────────────────────────────────────────────────────────────────
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::storage::pagedb_storage::PagedbStorageMem;
102
103    async fn open_storage() -> Arc<PagedbStorageMem> {
104        Arc::new(PagedbStorageMem::open_in_memory().await.unwrap())
105    }
106
107    use crate::storage::engine::StorageEngine;
108
109    #[tokio::test]
110    async fn load_or_init_generates_new_on_empty() {
111        let storage = open_storage().await;
112        let state = ReplicaState::load_or_init(&*storage).await.unwrap();
113        // ReplicaId must be non-zero (UUID-v7 low 64 bits is never 0 in practice).
114        // More importantly, it must be persisted.
115        let bytes = storage
116            .get(Namespace::Meta, REPLICA_ID_KEY)
117            .await
118            .unwrap()
119            .expect("replica_id must be persisted");
120        assert_eq!(bytes.len(), 8);
121        let stored = u64::from_be_bytes(bytes.try_into().unwrap());
122        assert_eq!(stored, state.replica_id().as_u64());
123    }
124
125    #[tokio::test]
126    async fn load_or_init_returns_same_on_reload() {
127        let storage = open_storage().await;
128        let id1 = ReplicaState::load_or_init(&*storage)
129            .await
130            .unwrap()
131            .replica_id();
132        let id2 = ReplicaState::load_or_init(&*storage)
133            .await
134            .unwrap()
135            .replica_id();
136        assert_eq!(id1, id2, "reload must return the same replica_id");
137    }
138
139    #[tokio::test]
140    async fn next_hlc_monotonic() {
141        let storage = open_storage().await;
142        let state = ReplicaState::load_or_init(&*storage).await.unwrap();
143        let mut prev = state.next_hlc().unwrap();
144        for _ in 0..99 {
145            let curr = state.next_hlc().unwrap();
146            assert!(
147                curr > prev,
148                "HLC must be strictly increasing: {curr:?} <= {prev:?}"
149            );
150            prev = curr;
151        }
152    }
153
154    #[tokio::test]
155    async fn observe_advances_clock() {
156        let storage = open_storage().await;
157        let state = ReplicaState::load_or_init(&*storage).await.unwrap();
158
159        let baseline = state.next_hlc().unwrap();
160        // Synthesise a remote HLC far in the future.
161        let future_replica = ReplicaId::new(0xdead);
162        let future_hlc = Hlc::new(baseline.physical_ms + 100_000, 0, future_replica).unwrap();
163
164        state.observe(future_hlc).unwrap();
165
166        let next = state.next_hlc().unwrap();
167        assert!(
168            next > future_hlc,
169            "local HLC after observe must exceed observed remote: {next:?} <= {future_hlc:?}"
170        );
171    }
172}