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