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