Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions poi-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,4 @@ iota-types.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["alloc"] }
thiserror.workspace = true

[dev-dependencies]
tokio.workspace = true
45 changes: 45 additions & 0 deletions poi-rs/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

use iota_types::committee::{Committee, EpochId};

use crate::BoxError;

mod in_memory;

pub use in_memory::MemoryCommitteeCache;

/// Error returned by a committee cache.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CommitteeCacheError {
/// A cached committee conflicts with authenticated committee data.
#[error("cached committee conflicts at epoch {epoch}")]
Conflict {
/// Epoch whose cached material conflicts.
epoch: EpochId,
},
/// A cache backend failed to read or write committee material.
#[error("committee cache backend failed at epoch {epoch}")]
Backend {
/// Epoch being accessed when the backend failed.
epoch: EpochId,
/// Underlying backend error.
#[source]
source: BoxError,
},
}

/// Stores authenticated committees for anchored resolution.
///
/// A cache is part of the caller's trust boundary. Implementations must return
/// only committees previously authenticated for the same network and must
/// preserve their integrity after storage.
#[async_trait::async_trait]
pub trait CommitteeCache: Send + Sync {
/// Returns the authenticated committee for `epoch`, when available.
async fn committee(&self, epoch: EpochId) -> Result<Option<Committee>, CommitteeCacheError>;

/// Stores a committee after the resolver has authenticated it.
async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>;
}
122 changes: 122 additions & 0 deletions poi-rs/src/cache/in_memory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2020-2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

use std::{collections::BTreeMap, sync::Arc};

use iota_types::committee::{Committee, EpochId};
use tokio::sync::RwLock;

use super::{CommitteeCache, CommitteeCacheError};

/// In-memory committee cache for library usage and tests.
#[derive(Clone, Debug, Default)]
pub struct MemoryCommitteeCache {
committees: Arc<RwLock<BTreeMap<EpochId, Committee>>>,
}

impl MemoryCommitteeCache {
/// Creates an empty in-memory committee cache.
pub fn new() -> Self {
Self::default()
}

/// Returns the number of cached committees.
pub async fn len(&self) -> usize {
self.committees.read().await.len()
}

/// Returns whether the cache contains no committees.
pub async fn is_empty(&self) -> bool {
self.committees.read().await.is_empty()
}
}

#[async_trait::async_trait]
impl CommitteeCache for MemoryCommitteeCache {
async fn committee(&self, epoch: EpochId) -> Result<Option<Committee>, CommitteeCacheError> {
Ok(self.committees.read().await.get(&epoch).cloned())
}

async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> {
let epoch = committee.epoch;
let mut committees = self.committees.write().await;

if committees.get(&epoch).is_some_and(|cached| cached != committee) {
return Err(CommitteeCacheError::Conflict { epoch });
}

committees.entry(epoch).or_insert_with(|| committee.clone());

Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

fn committee_at(epoch: EpochId) -> Committee {
let (committee, _) = Committee::new_simple_test_committee();

Committee::new(epoch, committee.voting_rights.iter().cloned().collect())
}

#[tokio::test]
async fn new_cache_is_empty() {
let cache = MemoryCommitteeCache::new();

assert!(cache.is_empty().await);
assert_eq!(cache.len().await, 0);
assert!(cache.committee(7).await.unwrap().is_none());
}

#[tokio::test]
async fn store_makes_a_committee_available_by_epoch() {
let cache = MemoryCommitteeCache::new();
let committee = committee_at(7);

cache.store(&committee).await.unwrap();

assert_eq!(cache.committee(7).await.unwrap(), Some(committee));
assert_eq!(cache.len().await, 1);
assert!(!cache.is_empty().await);
}

#[tokio::test]
async fn storing_the_same_committee_is_idempotent() {
let cache = MemoryCommitteeCache::new();
let committee = committee_at(7);

cache.store(&committee).await.unwrap();
cache.store(&committee).await.unwrap();

assert_eq!(cache.committee(7).await.unwrap(), Some(committee));
assert_eq!(cache.len().await, 1);
}

#[tokio::test]
async fn conflicting_committee_is_rejected_without_replacing_the_original() {
let cache = MemoryCommitteeCache::new();
let original = committee_at(7);
let (conflicting, _) = Committee::new_simple_test_committee_of_size(5);
let conflicting = Committee::new(7, conflicting.voting_rights.iter().cloned().collect());
cache.store(&original).await.unwrap();

let error = cache.store(&conflicting).await.unwrap_err();

assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 7 }));
assert_eq!(cache.committee(7).await.unwrap(), Some(original));
assert_eq!(cache.len().await, 1);
}

#[tokio::test]
async fn clones_share_cached_committees() {
let cache = MemoryCommitteeCache::new();
let clone = cache.clone();
let committee = committee_at(7);

cache.store(&committee).await.unwrap();

assert_eq!(clone.committee(7).await.unwrap(), Some(committee));
}
}
Loading
Loading