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, 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
139fn decode_segment_keys(bytes: &[u8]) -> Result<Vec<Vec<u8>>, StoreError> {
140    let mut keys = Vec::new();
141    let mut input = bytes;
142    while !input.is_empty() {
143        let (len_bytes, rest) = input
144            .split_at_checked(8)
145            .ok_or_else(|| StoreError::Io(std::io::Error::other("truncated segment")))?;
146        let len = usize::try_from(u64::from_be_bytes(len_bytes.try_into().unwrap_or_default()))
147            .map_err(|_| StoreError::Io(std::io::Error::other("segment key too large")))?;
148        let (key, rest) = rest
149            .split_at_checked(len)
150            .ok_or_else(|| StoreError::Io(std::io::Error::other("truncated segment key")))?;
151        keys.push(key.to_vec());
152        input = rest;
153    }
154    Ok(keys)
155}
156
157/// Read-through segment source over a blob/root store.
158pub struct SegmentSource<S> {
159    store: Arc<S>,
160    cache: SegmentCache,
161}
162
163impl<S: BlobStore + RootStore> SegmentSource<S> {
164    /// Wraps a store with an empty cache.
165    #[must_use]
166    pub fn new(store: Arc<S>) -> Self {
167        Self {
168            store,
169            cache: SegmentCache::default(),
170        }
171    }
172
173    /// Reads the current published index root for `db`.
174    ///
175    /// # Errors
176    /// Returns an error when the root store cannot be read.
177    pub async fn index_root(&self, db: &str) -> Result<Option<DbRoot>, StoreError> {
178        Ok(self
179            .store
180            .get_root(&db_root_name(db))
181            .await?
182            .as_deref()
183            .and_then(DbRoot::decode))
184    }
185
186    /// Rediscovers the current lease holder's advertised client endpoint
187    /// from the root record — peers with storage credentials can rebuild
188    /// their endpoint preference after an HA takeover without any static
189    /// configuration.
190    ///
191    /// # Errors
192    /// Returns an error when the root store cannot be read.
193    pub async fn lease_holder_endpoint(&self, db: &str) -> Result<Option<String>, StoreError> {
194        Ok(self
195            .index_root(db)
196            .await?
197            .and_then(|root| (!root.owner_endpoint.is_empty()).then_some(root.owner_endpoint)))
198    }
199
200    /// Loads the full key stream for one index order of a published root,
201    /// through the cache: a format-3 manifest's chunks concatenated in
202    /// order, or a pre-format-3 flat segment as stored.
203    ///
204    /// # Errors
205    /// Returns an error when a blob cannot be loaded or is missing.
206    pub async fn segment(
207        &self,
208        root: &DbRoot,
209        order: IndexOrder,
210    ) -> Result<Option<Arc<[u8]>>, StoreError> {
211        let Some(roots) = &root.roots else {
212            return Ok(None);
213        };
214        let slot = match order {
215            IndexOrder::Eavt => 0,
216            IndexOrder::Aevt => 1,
217            IndexOrder::Avet => 2,
218            IndexOrder::Vaet => 3,
219        };
220        let Some(blob) = self
221            .cache
222            .get_or_load(self.store.as_ref(), &roots[slot])
223            .await?
224        else {
225            return Ok(None);
226        };
227        if !is_index_manifest(&blob) {
228            return Ok(Some(blob));
229        }
230        let mut bytes = Vec::new();
231        for child in decode_index_manifest(&blob)? {
232            let chunk = self
233                .cache
234                .get_or_load(self.store.as_ref(), &child)
235                .await?
236                .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
237            bytes.extend_from_slice(&chunk);
238        }
239        Ok(Some(bytes.into()))
240    }
241
242    /// Decodes a segment's length-prefixed key entries.
243    ///
244    /// # Errors
245    /// Returns [`StoreError::CorruptBlob`]-free decode failures as `None`
246    /// entries never occur; malformed framing yields an error.
247    pub fn segment_keys(bytes: &[u8]) -> Result<Vec<Vec<u8>>, StoreError> {
248        decode_segment_keys(bytes)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use corium_core::{EntityId, KeywordInterner, Value};
255    use corium_db::Idents;
256    use corium_store::{MemoryStore, RootStore};
257
258    use super::*;
259
260    #[tokio::test]
261    async fn loads_published_eavt_snapshot() {
262        let store = MemoryStore::default();
263        let datom = Datom {
264            e: EntityId::from_raw(1_001),
265            a: EntityId::from_raw(101),
266            v: Value::Str("snapshot".into()),
267            tx: EntityId::from_raw(37),
268            added: true,
269        };
270        let key = datom.key(IndexOrder::Eavt);
271        let mut segment = Vec::new();
272        segment.extend_from_slice(&(key.len() as u64).to_be_bytes());
273        segment.extend_from_slice(&key);
274        let id = store.put(&segment).await.expect("put segment");
275        let root = DbRoot {
276            format_version: FORMAT_VERSION,
277            lease_version: 1,
278            owner: "test".into(),
279            lease_expires_unix_ms: 0,
280            owner_endpoint: String::new(),
281            index_basis_t: 37,
282            roots: Some([id.clone(), id.clone(), id.clone(), id]),
283        };
284        RootStore::cas_root(&store, &db_root_name("music"), None, &root.encode())
285            .await
286            .expect("put root");
287        let metadata = codec::encode_metadata(
288            &corium_core::Schema::default(),
289            &Idents::default(),
290            &KeywordInterner::default(),
291        );
292        RootStore::cas_root(&store, &meta_root_name("music"), None, &metadata)
293            .await
294            .expect("put metadata");
295
296        let db = load_current_snapshot(&store, "music")
297            .await
298            .expect("load snapshot")
299            .expect("published snapshot");
300        assert_eq!(db.basis_t(), 37);
301        assert_eq!(db.datoms(), vec![datom]);
302    }
303
304    #[tokio::test]
305    async fn loads_chunked_manifest_snapshot() {
306        let store = MemoryStore::default();
307        let datoms: Vec<Datom> = (0..4u64)
308            .map(|n| Datom {
309                e: EntityId::from_raw(1_001 + n),
310                a: EntityId::from_raw(101),
311                v: Value::Long(i64::try_from(n).unwrap()),
312                tx: EntityId::from_raw(37),
313                added: true,
314            })
315            .collect();
316        // Two chunks of two keys each, under one manifest per index.
317        let mut chunk_ids = Vec::new();
318        for pair in datoms.chunks(2) {
319            let mut chunk = Vec::new();
320            for datom in pair {
321                let key = datom.key(IndexOrder::Eavt);
322                chunk.extend_from_slice(&(key.len() as u64).to_be_bytes());
323                chunk.extend_from_slice(&key);
324            }
325            chunk_ids.push(store.put(&chunk).await.expect("put chunk"));
326        }
327        let manifest = corium_store::encode_index_manifest(&chunk_ids);
328        let id = store.put(&manifest).await.expect("put manifest");
329        let root = DbRoot {
330            format_version: FORMAT_VERSION,
331            lease_version: 1,
332            owner: "test".into(),
333            lease_expires_unix_ms: 0,
334            owner_endpoint: String::new(),
335            index_basis_t: 37,
336            roots: Some([id.clone(), id.clone(), id.clone(), id]),
337        };
338        RootStore::cas_root(&store, &db_root_name("music"), None, &root.encode())
339            .await
340            .expect("put root");
341        let metadata = codec::encode_metadata(
342            &corium_core::Schema::default(),
343            &Idents::default(),
344            &KeywordInterner::default(),
345        );
346        RootStore::cas_root(&store, &meta_root_name("music"), None, &metadata)
347            .await
348            .expect("put metadata");
349
350        let db = load_current_snapshot(&store, "music")
351            .await
352            .expect("load snapshot")
353            .expect("published snapshot");
354        assert_eq!(db.basis_t(), 37);
355        assert_eq!(db.datoms(), datoms);
356    }
357
358    #[tokio::test]
359    async fn absent_publication_falls_back_to_log_replay() {
360        assert!(
361            load_current_snapshot(&MemoryStore::default(), "music")
362                .await
363                .expect("load snapshot")
364                .is_none()
365        );
366    }
367}