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