Skip to main content

corium_peer/
segment.rs

1//! Direct blob-store segment access for peers.
2//!
3//! Segments never travel over gRPC: peers with storage credentials read
4//! published index segments straight from the blob store through a local
5//! read-through cache (see `docs/design/protocol.md`). Blobs are immutable
6//! and content-addressed, so cache entries never invalidate.
7
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use corium_core::{Datom, IndexOrder, encoding::DecodeError};
12use corium_db::Db;
13use corium_protocol::codec::{self, CodecError};
14use corium_store::{
15    BlobId, BlobStore, DbRoot, FORMAT_VERSION, RootStore, SegmentCache, StoreError, db_root_name,
16    decode_index_manifest, decode_segment_keys, is_index_manifest, meta_root_name,
17};
18use thiserror::Error;
19
20/// Read-only storage operations needed by a storage-aware peer.
21///
22/// The separate trait makes a backend that implements both [`BlobStore`] and
23/// [`RootStore`] usable as one trait object in [`crate::ConnectConfig`].
24#[async_trait]
25pub trait PeerStorage: Send + Sync {
26    /// Loads one immutable blob.
27    async fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError>;
28    /// Loads one named root record.
29    async fn get_peer_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError>;
30}
31
32#[async_trait]
33impl<S> PeerStorage for S
34where
35    S: BlobStore + RootStore + Send + Sync,
36{
37    async fn get_blob(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
38        BlobStore::get(self, id).await
39    }
40
41    async fn get_peer_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
42        RootStore::get_root(self, name).await
43    }
44}
45
46/// Failure while bootstrapping a peer from published storage.
47#[derive(Debug, Error)]
48pub enum SnapshotError {
49    /// Storage read failed.
50    #[error(transparent)]
51    Store(#[from] StoreError),
52    /// Durable schema/naming metadata was malformed.
53    #[error(transparent)]
54    Codec(#[from] CodecError),
55    /// A covering-index key was malformed.
56    #[error(transparent)]
57    Key(#[from] DecodeError),
58    /// A root record was present but malformed.
59    #[error("malformed published root for database {0:?}")]
60    MalformedRoot(String),
61    /// The root uses a newer storage format than this peer understands.
62    #[error("storage format {found} is newer than supported format {supported}")]
63    UnsupportedFormat {
64        /// Version found in storage.
65        found: u32,
66        /// Newest version understood by this peer.
67        supported: u32,
68    },
69    /// An indexed snapshot had no matching durable metadata.
70    #[error("published snapshot for database {0:?} has no metadata root")]
71    MissingMetadata(String),
72}
73
74/// Loads the newest published current-state snapshot for `db`.
75///
76/// `None` means the database has not published an index yet, in which case a
77/// peer must subscribe from basis zero. Immutable segment reads can race with
78/// later publications safely because the root selects a complete snapshot.
79///
80/// # Errors
81/// Returns [`SnapshotError`] for corrupt or unsupported published state.
82pub async fn load_current_snapshot(
83    store: &dyn PeerStorage,
84    db: &str,
85) -> Result<Option<Db>, SnapshotError> {
86    let Some(root_bytes) = store.get_peer_root(&db_root_name(db)).await? else {
87        return Ok(None);
88    };
89    let root =
90        DbRoot::decode(&root_bytes).ok_or_else(|| SnapshotError::MalformedRoot(db.into()))?;
91    if root.format_version > FORMAT_VERSION {
92        return Err(SnapshotError::UnsupportedFormat {
93            found: root.format_version,
94            supported: FORMAT_VERSION,
95        });
96    }
97    let Some(roots) = root.roots else {
98        return Ok(None);
99    };
100    let Some(metadata) = store.get_peer_root(&meta_root_name(db)).await? else {
101        return Err(SnapshotError::MissingMetadata(db.into()));
102    };
103    let (schema, idents, interner) = codec::decode_metadata(&metadata)?;
104    let datoms = load_index_keys(store, &roots[0])
105        .await?
106        .into_iter()
107        .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
108        .collect::<Result<Vec<_>, _>>()?;
109    Ok(Some(Db::from_current_snapshot(
110        root.index_basis_t,
111        schema,
112        idents,
113        interner,
114        datoms,
115    )))
116}
117
118/// Loads one covering index's sorted keys: a format-3 manifest's chunks in
119/// order, or a pre-format-3 flat key stream.
120async fn load_index_keys(store: &dyn PeerStorage, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
121    let blob = store
122        .get_blob(id)
123        .await?
124        .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
125    if !is_index_manifest(&blob) {
126        return decode_segment_keys(&blob);
127    }
128    let mut keys = Vec::new();
129    for child in decode_index_manifest(&blob)? {
130        let chunk = store
131            .get_blob(&child)
132            .await?
133            .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
134        keys.extend(decode_segment_keys(&chunk)?);
135    }
136    Ok(keys)
137}
138
139/// Read-through segment source over a blob/root store.
140pub struct SegmentSource<S> {
141    store: Arc<S>,
142    cache: SegmentCache,
143}
144
145impl<S: BlobStore + RootStore> SegmentSource<S> {
146    /// Wraps a store with an empty cache.
147    #[must_use]
148    pub fn new(store: Arc<S>) -> Self {
149        Self {
150            store,
151            cache: SegmentCache::default(),
152        }
153    }
154
155    /// Reads the current published index root for `db`.
156    ///
157    /// # Errors
158    /// Returns an error when the root store cannot be read.
159    pub async fn index_root(&self, db: &str) -> Result<Option<DbRoot>, StoreError> {
160        Ok(self
161            .store
162            .get_root(&db_root_name(db))
163            .await?
164            .as_deref()
165            .and_then(DbRoot::decode))
166    }
167
168    /// Rediscovers the current lease holder's advertised client endpoint
169    /// from the root record — peers with storage credentials can rebuild
170    /// their endpoint preference after an HA takeover without any static
171    /// configuration.
172    ///
173    /// # Errors
174    /// Returns an error when the root store cannot be read.
175    pub async fn lease_holder_endpoint(&self, db: &str) -> Result<Option<String>, StoreError> {
176        Ok(self
177            .index_root(db)
178            .await?
179            .and_then(|root| (!root.owner_endpoint.is_empty()).then_some(root.owner_endpoint)))
180    }
181
182    /// Loads the full key stream for one index order of a published root,
183    /// through the cache: a format-3 manifest's chunks concatenated in
184    /// order, or a pre-format-3 flat segment as stored.
185    ///
186    /// # Errors
187    /// Returns an error when a blob cannot be loaded or is missing.
188    pub async fn segment(
189        &self,
190        root: &DbRoot,
191        order: IndexOrder,
192    ) -> Result<Option<Arc<[u8]>>, StoreError> {
193        let Some(roots) = &root.roots else {
194            return Ok(None);
195        };
196        let slot = match order {
197            IndexOrder::Eavt => 0,
198            IndexOrder::Aevt => 1,
199            IndexOrder::Avet => 2,
200            IndexOrder::Vaet => 3,
201        };
202        let Some(blob) = self
203            .cache
204            .get_or_load(self.store.as_ref(), &roots[slot])
205            .await?
206        else {
207            return Ok(None);
208        };
209        if !is_index_manifest(&blob) {
210            return Ok(Some(blob));
211        }
212        let mut bytes = Vec::new();
213        for child in decode_index_manifest(&blob)? {
214            let chunk = self
215                .cache
216                .get_or_load(self.store.as_ref(), &child)
217                .await?
218                .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
219            bytes.extend_from_slice(&chunk);
220        }
221        Ok(Some(bytes.into()))
222    }
223
224    /// Decodes a segment's length-prefixed key entries.
225    ///
226    /// # Errors
227    /// Returns [`StoreError::CorruptBlob`]-free decode failures as `None`
228    /// entries never occur; malformed framing yields an error.
229    pub fn segment_keys(bytes: &[u8]) -> Result<Vec<Vec<u8>>, StoreError> {
230        decode_segment_keys(bytes)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use corium_core::{EntityId, KeywordInterner, Value};
237    use corium_db::Idents;
238    use corium_store::{MemoryStore, RootStore};
239
240    use super::*;
241
242    #[tokio::test]
243    async fn loads_published_eavt_snapshot() {
244        let store = MemoryStore::default();
245        let datom = Datom {
246            e: EntityId::from_raw(1_001),
247            a: EntityId::from_raw(101),
248            v: Value::Str("snapshot".into()),
249            tx: EntityId::from_raw(37),
250            added: true,
251        };
252        let key = datom.key(IndexOrder::Eavt);
253        let mut segment = Vec::new();
254        segment.extend_from_slice(&(key.len() as u64).to_be_bytes());
255        segment.extend_from_slice(&key);
256        let id = store.put(&segment).await.expect("put segment");
257        let root = DbRoot {
258            format_version: FORMAT_VERSION,
259            lease_version: 1,
260            owner: "test".into(),
261            lease_expires_unix_ms: 0,
262            owner_endpoint: String::new(),
263            index_basis_t: 37,
264            roots: Some([id.clone(), id.clone(), id.clone(), id]),
265            next_entity_id: 1_005,
266            last_tx_instant: 0,
267        };
268        RootStore::cas_root(&store, &db_root_name("music"), None, &root.encode())
269            .await
270            .expect("put root");
271        let metadata = codec::encode_metadata(
272            &corium_core::Schema::default(),
273            &Idents::default(),
274            &KeywordInterner::default(),
275        );
276        RootStore::cas_root(&store, &meta_root_name("music"), None, &metadata)
277            .await
278            .expect("put metadata");
279
280        let db = load_current_snapshot(&store, "music")
281            .await
282            .expect("load snapshot")
283            .expect("published snapshot");
284        assert_eq!(db.basis_t(), 37);
285        assert_eq!(db.datoms(), vec![datom]);
286    }
287
288    #[tokio::test]
289    async fn loads_chunked_manifest_snapshot() {
290        let store = MemoryStore::default();
291        let datoms: Vec<Datom> = (0..4u64)
292            .map(|n| Datom {
293                e: EntityId::from_raw(1_001 + n),
294                a: EntityId::from_raw(101),
295                v: Value::Long(i64::try_from(n).unwrap()),
296                tx: EntityId::from_raw(37),
297                added: true,
298            })
299            .collect();
300        // Two chunks of two keys each, under one manifest per index.
301        let mut chunk_ids = Vec::new();
302        for pair in datoms.chunks(2) {
303            let mut chunk = Vec::new();
304            for datom in pair {
305                let key = datom.key(IndexOrder::Eavt);
306                chunk.extend_from_slice(&(key.len() as u64).to_be_bytes());
307                chunk.extend_from_slice(&key);
308            }
309            chunk_ids.push(store.put(&chunk).await.expect("put chunk"));
310        }
311        let manifest = corium_store::encode_index_manifest(&chunk_ids);
312        let id = store.put(&manifest).await.expect("put manifest");
313        let root = DbRoot {
314            format_version: FORMAT_VERSION,
315            lease_version: 1,
316            owner: "test".into(),
317            lease_expires_unix_ms: 0,
318            owner_endpoint: String::new(),
319            index_basis_t: 37,
320            roots: Some([id.clone(), id.clone(), id.clone(), id]),
321            next_entity_id: 1_005,
322            last_tx_instant: 0,
323        };
324        RootStore::cas_root(&store, &db_root_name("music"), None, &root.encode())
325            .await
326            .expect("put root");
327        let metadata = codec::encode_metadata(
328            &corium_core::Schema::default(),
329            &Idents::default(),
330            &KeywordInterner::default(),
331        );
332        RootStore::cas_root(&store, &meta_root_name("music"), None, &metadata)
333            .await
334            .expect("put metadata");
335
336        let db = load_current_snapshot(&store, "music")
337            .await
338            .expect("load snapshot")
339            .expect("published snapshot");
340        assert_eq!(db.basis_t(), 37);
341        assert_eq!(db.datoms(), datoms);
342    }
343
344    #[tokio::test]
345    async fn absent_publication_falls_back_to_log_replay() {
346        assert!(
347            load_current_snapshot(&MemoryStore::default(), "music")
348                .await
349                .expect("load snapshot")
350                .is_none()
351        );
352    }
353}