use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use sha2::{Digest, Sha256};
pub const SEEN_VERIFICATIONS_CAPACITY: usize = 100_000;
#[derive(Debug, Default)]
struct ReplayCacheInner {
seen: HashSet<[u8; 32]>,
order: VecDeque<[u8; 32]>,
}
#[derive(Debug, Default)]
pub struct ReplayCache {
inner: Mutex<ReplayCacheInner>,
evictions: AtomicU64,
}
impl ReplayCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_and_check(&self, link_id: &str, signature: &[u8], nonce: &str) -> ReplayDecision {
let fp = Self::fingerprint(link_id, signature, nonce);
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if guard.seen.contains(&fp) {
return ReplayDecision::Replay;
}
if guard.order.len() >= SEEN_VERIFICATIONS_CAPACITY {
if let Some(evicted) = guard.order.pop_front() {
guard.seen.remove(&evicted);
self.evictions.fetch_add(1, Ordering::Relaxed);
}
}
guard.order.push_back(fp);
guard.seen.insert(fp);
ReplayDecision::Fresh
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.lock().map(|g| g.order.len()).unwrap_or(0)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn evictions_since_boot(&self) -> u64 {
self.evictions.load(Ordering::Relaxed)
}
fn fingerprint(link_id: &str, signature: &[u8], nonce: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
let lid = link_id.as_bytes();
let sig = signature;
let non = nonce.as_bytes();
#[allow(clippy::cast_possible_truncation)]
hasher.update((lid.len() as u32).to_be_bytes());
hasher.update(lid);
#[allow(clippy::cast_possible_truncation)]
hasher.update((sig.len() as u32).to_be_bytes());
hasher.update(sig);
#[allow(clippy::cast_possible_truncation)]
hasher.update((non.len() as u32).to_be_bytes());
hasher.update(non);
hasher.finalize().into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayDecision {
Fresh,
Replay,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_seen_returns_fresh() {
let cache = ReplayCache::new();
let d = cache.record_and_check("link-a", b"sig", "nonce-1");
assert_eq!(d, ReplayDecision::Fresh);
assert_eq!(cache.len(), 1);
}
#[test]
fn exact_repeat_returns_replay() {
let cache = ReplayCache::new();
assert_eq!(
cache.record_and_check("link-a", b"sig", "nonce-1"),
ReplayDecision::Fresh
);
assert_eq!(
cache.record_and_check("link-a", b"sig", "nonce-1"),
ReplayDecision::Replay
);
assert_eq!(cache.len(), 1);
}
#[test]
fn different_nonces_for_same_link_and_sig_are_fresh() {
let cache = ReplayCache::new();
assert_eq!(
cache.record_and_check("link-a", b"sig", "nonce-1"),
ReplayDecision::Fresh
);
assert_eq!(
cache.record_and_check("link-a", b"sig", "nonce-2"),
ReplayDecision::Fresh
);
assert_eq!(cache.len(), 2);
}
#[test]
fn different_links_with_same_nonce_are_fresh() {
let cache = ReplayCache::new();
assert_eq!(
cache.record_and_check("link-a", b"sig", "nonce"),
ReplayDecision::Fresh
);
assert_eq!(
cache.record_and_check("link-b", b"sig", "nonce"),
ReplayDecision::Fresh
);
}
#[test]
fn fifo_eviction_at_capacity() {
let cache = ReplayCache::new();
for i in 0..SEEN_VERIFICATIONS_CAPACITY {
assert_eq!(
cache.record_and_check("link", b"sig", &format!("nonce-{i}")),
ReplayDecision::Fresh
);
}
assert_eq!(cache.len(), SEEN_VERIFICATIONS_CAPACITY);
assert_eq!(
cache.record_and_check("link", b"sig", "nonce-new"),
ReplayDecision::Fresh
);
assert_eq!(cache.len(), SEEN_VERIFICATIONS_CAPACITY);
assert_eq!(
cache.record_and_check("link", b"sig", "nonce-0"),
ReplayDecision::Fresh
);
}
#[test]
fn length_prefixed_fingerprint_avoids_concatenation_collision() {
let fp1 = ReplayCache::fingerprint("ab", b"c", "");
let fp2 = ReplayCache::fingerprint("a", b"bc", "");
assert_ne!(fp1, fp2);
}
#[test]
fn is_empty_starts_true() {
let cache = ReplayCache::new();
assert!(cache.is_empty());
let _ = cache.record_and_check("a", b"b", "c");
assert!(!cache.is_empty());
}
#[test]
fn evictions_counter_starts_at_zero_1033() {
let cache = ReplayCache::new();
assert_eq!(cache.evictions_since_boot(), 0);
for i in 0..16 {
let _ = cache.record_and_check("l", b"s", &format!("n{i}"));
}
assert_eq!(cache.evictions_since_boot(), 0);
}
#[test]
fn evictions_counter_bumps_on_capacity_overflow_1033() {
let cache = ReplayCache::new();
for i in 0..SEEN_VERIFICATIONS_CAPACITY {
assert_eq!(
cache.record_and_check("l", b"s", &format!("n{i}")),
ReplayDecision::Fresh
);
}
assert_eq!(
cache.evictions_since_boot(),
0,
"no evictions at exactly capacity"
);
assert_eq!(
cache.record_and_check("l", b"s", "n-new-1"),
ReplayDecision::Fresh
);
assert_eq!(
cache.evictions_since_boot(),
1,
"exactly one eviction at capacity+1"
);
assert_eq!(
cache.record_and_check("l", b"s", "n-new-2"),
ReplayDecision::Fresh
);
assert_eq!(
cache.evictions_since_boot(),
2,
"two evictions at capacity+2"
);
}
#[test]
fn o1_lookup_under_sustained_load_1033() {
let cache = ReplayCache::new();
let start = std::time::Instant::now();
for i in 0..5_000 {
let _ = cache.record_and_check("link", b"sig", &format!("n{i}"));
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"post-#1033: 5000 record_and_check calls MUST complete \
in <500ms (HashSet lookup). Pre-#1033 O(N) shape would \
take seconds; got {elapsed:?}"
);
}
}
use std::collections::HashMap;
pub const FEDERATION_NONCE_CAPACITY_PER_PEER: usize = 10_000;
pub const FEDERATION_NONCE_MAX_PEERS: usize = 1024;
#[derive(Debug, Default)]
struct PeerNonceSlot {
seen: HashSet<[u8; 32]>,
order: VecDeque<[u8; 32]>,
last_touch: u64,
}
#[derive(Debug, Default)]
pub struct FederationNonceCache {
inner: Mutex<HashMap<String, PeerNonceSlot>>,
touch_counter: std::sync::atomic::AtomicU64,
peer_evictions: std::sync::atomic::AtomicU64,
db_path: Option<PathBuf>,
}
fn prune_nonce_cache_to_per_peer_cap(
conn: &rusqlite::Connection,
per_peer_cap: usize,
) -> rusqlite::Result<usize> {
#[allow(clippy::cast_possible_wrap)]
let cap = per_peer_cap as i64;
conn.execute(
"DELETE FROM federation_nonce_cache
WHERE (peer_id, fingerprint) IN (
SELECT peer_id, fingerprint FROM (
SELECT peer_id, fingerprint,
ROW_NUMBER() OVER (
PARTITION BY peer_id ORDER BY last_touch DESC
) AS rn
FROM federation_nonce_cache
) WHERE rn > ?1
)",
rusqlite::params![cap],
)
}
impl FederationNonceCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn new_with_db_persistence(db_path: impl Into<PathBuf>) -> anyhow::Result<Self> {
let db_path = db_path.into();
let cache = Self {
inner: Mutex::new(HashMap::new()),
touch_counter: AtomicU64::new(0),
peer_evictions: AtomicU64::new(0),
db_path: Some(db_path.clone()),
};
cache.hydrate_from_disk(&db_path)?;
Ok(cache)
}
fn hydrate_from_disk(&self, db_path: &Path) -> anyhow::Result<()> {
let conn = crate::db::open(db_path)
.map_err(|e| anyhow::anyhow!("FederationNonceCache: open ai-memory db: {e}"))?;
let mut stmt = conn.prepare(
"SELECT peer_id, fingerprint, last_touch
FROM federation_nonce_cache
ORDER BY last_touch ASC",
)?;
let mut max_touch: u64 = 0;
let rows = stmt.query_map([], |row| {
let peer_id: String = row.get(0)?;
let fp_bytes: Vec<u8> = row.get(1)?;
let last_touch: i64 = row.get(2)?;
Ok((peer_id, fp_bytes, last_touch))
})?;
let mut guard = self
.inner
.lock()
.map_err(|_| anyhow::anyhow!("FederationNonceCache: hydration mutex poisoned"))?;
for row in rows {
let (peer_id, fp_bytes, last_touch) = row?;
let fp: [u8; 32] = match fp_bytes.as_slice().try_into() {
Ok(fp) => fp,
Err(_) => {
tracing::warn!(
target: "ai_memory::identity::replay",
peer_id = %peer_id,
len = fp_bytes.len(),
"FederationNonceCache: skipping persisted row with non-32-byte \
fingerprint blob (forensic noise; not produced by any v0.7.x writer)",
);
continue;
}
};
#[allow(clippy::cast_sign_loss)]
let touch_u64 = last_touch.max(0) as u64;
if touch_u64 > max_touch {
max_touch = touch_u64;
}
let slot = guard.entry(peer_id).or_default();
if slot.order.len() >= FEDERATION_NONCE_CAPACITY_PER_PEER {
if let Some(evicted) = slot.order.pop_front() {
slot.seen.remove(&evicted);
}
}
slot.order.push_back(fp);
slot.seen.insert(fp);
slot.last_touch = touch_u64;
}
drop(guard);
drop(stmt);
match prune_nonce_cache_to_per_peer_cap(&conn, FEDERATION_NONCE_CAPACITY_PER_PEER) {
Ok(0) => {}
Ok(n) => tracing::info!(
target: "ai_memory::identity::replay",
"FederationNonceCache: pruned {n} over-cap disk row(s) on hydration \
(#1690 legacy-bloat repair); disk now bounded to the per-peer cap"
),
Err(e) => tracing::warn!(
target: "ai_memory::identity::replay",
err = %e,
"FederationNonceCache: hydration over-cap prune failed (non-fatal; in-memory \
cache still bounded)"
),
}
self.touch_counter
.store(max_touch.saturating_add(1), Ordering::Relaxed);
Ok(())
}
fn persist_fingerprint_and_evict(
&self,
peer_id: &str,
fp: &[u8; 32],
last_touch: u64,
evicted_fp: Option<&[u8; 32]>,
evicted_peer: Option<&str>,
) {
let Some(path) = self.db_path.as_deref() else {
return;
};
let conn = match crate::db::open(path) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
target: "ai_memory::identity::replay",
peer_id = %peer_id,
path = %path.display(),
err = %e,
"FederationNonceCache: persist open failed; in-memory cache still holds \
(#1255 graceful degradation)",
);
return;
}
};
#[allow(clippy::cast_possible_wrap)]
let last_touch_i64 = last_touch as i64;
let now = chrono::Utc::now().to_rfc3339();
if let Err(e) = conn.execute(
"INSERT OR REPLACE INTO federation_nonce_cache
(peer_id, fingerprint, last_touch, inserted_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![peer_id, fp.as_slice(), last_touch_i64, now],
) {
tracing::warn!(
target: "ai_memory::identity::replay",
peer_id = %peer_id,
err = %e,
"FederationNonceCache: persist insert failed; in-memory cache still holds \
(#1255 graceful degradation)",
);
}
if let Some(efp) = evicted_fp {
if let Err(e) = conn.execute(
"DELETE FROM federation_nonce_cache WHERE peer_id = ?1 AND fingerprint = ?2",
rusqlite::params![peer_id, efp.as_slice()],
) {
tracing::warn!(
target: "ai_memory::identity::replay",
peer_id = %peer_id,
err = %e,
"FederationNonceCache: evicted-fingerprint delete failed; disk row lingers \
(#1690 graceful degradation)",
);
}
}
if let Some(ep) = evicted_peer {
if let Err(e) = conn.execute(
"DELETE FROM federation_nonce_cache WHERE peer_id = ?1",
rusqlite::params![ep],
) {
tracing::warn!(
target: "ai_memory::identity::replay",
evicted_peer = %ep,
err = %e,
"FederationNonceCache: evicted-peer delete failed; disk rows linger \
(#1690 graceful degradation)",
);
}
}
}
pub fn record_and_check(&self, peer_id: &str, nonce: &str) -> ReplayDecision {
use std::sync::atomic::Ordering;
let fp = Self::fingerprint(peer_id, nonce);
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let mut evicted_peer: Option<String> = None;
if !guard.contains_key(peer_id) && guard.len() >= FEDERATION_NONCE_MAX_PEERS {
if let Some((evict_id, _)) = guard
.iter()
.min_by_key(|(_, s)| s.last_touch)
.map(|(k, s)| (k.clone(), s.last_touch))
{
guard.remove(&evict_id);
self.peer_evictions.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
target: "ai_memory::identity::replay",
evicted_peer = %evict_id,
"FederationNonceCache: at peer ceiling ({}); evicted LRU peer slot to make \
room. Operator-visible via peer_evictions_since_boot() (#1038).",
FEDERATION_NONCE_MAX_PEERS,
);
evicted_peer = Some(evict_id);
}
}
let touch = self.touch_counter.fetch_add(1, Ordering::Relaxed);
let slot = guard.entry(peer_id.to_string()).or_default();
slot.last_touch = touch;
if slot.seen.contains(&fp) {
return ReplayDecision::Replay;
}
let mut evicted_fp: Option<[u8; 32]> = None;
if slot.order.len() >= FEDERATION_NONCE_CAPACITY_PER_PEER {
if let Some(evicted) = slot.order.pop_front() {
slot.seen.remove(&evicted);
evicted_fp = Some(evicted);
}
}
slot.order.push_back(fp);
slot.seen.insert(fp);
drop(guard);
self.persist_fingerprint_and_evict(
peer_id,
&fp,
touch,
evicted_fp.as_ref(),
evicted_peer.as_deref(),
);
ReplayDecision::Fresh
}
#[must_use]
pub fn peer_evictions_since_boot(&self) -> u64 {
self.peer_evictions
.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn peer_count(&self) -> usize {
self.inner.lock().map(|g| g.len()).unwrap_or(0)
}
#[must_use]
pub fn len_for_peer(&self, peer_id: &str) -> usize {
self.inner
.lock()
.map(|g| g.get(peer_id).map_or(0, |s| s.order.len()))
.unwrap_or(0)
}
fn fingerprint(peer_id: &str, nonce: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
let pid = peer_id.as_bytes();
let non = nonce.as_bytes();
#[allow(clippy::cast_possible_truncation)]
hasher.update((pid.len() as u32).to_be_bytes());
hasher.update(pid);
#[allow(clippy::cast_possible_truncation)]
hasher.update((non.len() as u32).to_be_bytes());
hasher.update(non);
hasher.finalize().into()
}
}
#[cfg(test)]
mod federation_nonce_cache_tests {
use super::*;
#[test]
fn first_seen_returns_fresh() {
let cache = FederationNonceCache::new();
assert_eq!(cache.record_and_check("p", "n"), ReplayDecision::Fresh);
assert_eq!(cache.len_for_peer("p"), 1);
}
#[test]
fn exact_repeat_returns_replay() {
let cache = FederationNonceCache::new();
assert_eq!(cache.record_and_check("p", "n"), ReplayDecision::Fresh);
assert_eq!(cache.record_and_check("p", "n"), ReplayDecision::Replay);
assert_eq!(cache.len_for_peer("p"), 1);
}
#[test]
fn different_peers_can_use_same_nonce() {
let cache = FederationNonceCache::new();
assert_eq!(cache.record_and_check("a", "s"), ReplayDecision::Fresh);
assert_eq!(cache.record_and_check("b", "s"), ReplayDecision::Fresh);
assert_eq!(cache.peer_count(), 2);
}
#[test]
fn fifo_eviction_at_per_peer_capacity() {
let cache = FederationNonceCache::new();
for i in 0..FEDERATION_NONCE_CAPACITY_PER_PEER {
assert_eq!(
cache.record_and_check("p", &format!("n-{i}")),
ReplayDecision::Fresh
);
}
assert_eq!(cache.len_for_peer("p"), FEDERATION_NONCE_CAPACITY_PER_PEER);
assert_eq!(cache.record_and_check("p", "n-new"), ReplayDecision::Fresh);
assert_eq!(cache.record_and_check("p", "n-0"), ReplayDecision::Fresh);
}
#[test]
fn peer_count_evictions_counter_starts_at_zero_1038() {
let cache = FederationNonceCache::new();
assert_eq!(cache.peer_evictions_since_boot(), 0);
for i in 0..32 {
let _ = cache.record_and_check(&format!("peer-{i}"), "n");
}
assert_eq!(cache.peer_count(), 32);
assert_eq!(cache.peer_evictions_since_boot(), 0);
}
#[test]
fn outer_lru_evicts_least_recently_touched_at_ceiling_1038() {
let cache = FederationNonceCache::new();
for i in 0..FEDERATION_NONCE_MAX_PEERS {
let _ = cache.record_and_check(&format!("peer-{i}"), "n");
}
assert_eq!(cache.peer_count(), FEDERATION_NONCE_MAX_PEERS);
assert_eq!(cache.peer_evictions_since_boot(), 0);
let _ = cache.record_and_check("peer-0", "n2");
assert_eq!(
cache.record_and_check("peer-new", "n"),
ReplayDecision::Fresh
);
assert_eq!(
cache.peer_count(),
FEDERATION_NONCE_MAX_PEERS,
"#1038: at ceiling the outer HashMap must stay at FEDERATION_NONCE_MAX_PEERS"
);
assert_eq!(
cache.peer_evictions_since_boot(),
1,
"#1038: exactly one peer-slot eviction must have fired"
);
assert_eq!(cache.len_for_peer("peer-1"), 0);
assert!(cache.len_for_peer("peer-0") > 0);
}
#[test]
fn re_touch_existing_peer_does_not_trigger_eviction_1038() {
let cache = FederationNonceCache::new();
for i in 0..FEDERATION_NONCE_MAX_PEERS {
let _ = cache.record_and_check(&format!("peer-{i}"), "n");
}
let before = cache.peer_evictions_since_boot();
for i in 0..FEDERATION_NONCE_MAX_PEERS {
let _ = cache.record_and_check(&format!("peer-{i}"), &format!("n2-{i}"));
}
assert_eq!(
cache.peer_evictions_since_boot(),
before,
"#1038: re-touching existing peers MUST NOT trigger LRU eviction"
);
assert_eq!(cache.peer_count(), FEDERATION_NONCE_MAX_PEERS);
}
#[test]
fn issue_1255_nonce_persists_across_recreated_cache() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let db_path = tmp.path().to_path_buf();
let cache_a = FederationNonceCache::new_with_db_persistence(&db_path)
.expect("first cache must open the DB and run v51 migration");
assert_eq!(
cache_a.record_and_check("peer-1255", "n-1255"),
ReplayDecision::Fresh,
"#1255: first observation of (peer, nonce) is Fresh"
);
assert_eq!(
cache_a.record_and_check("peer-1255", "n-1255"),
ReplayDecision::Replay,
"#1255: in-process re-observation is Replay (sanity)"
);
drop(cache_a);
let cache_b = FederationNonceCache::new_with_db_persistence(&db_path)
.expect("second cache must hydrate from the same DB");
assert_eq!(
cache_b.record_and_check("peer-1255", "n-1255"),
ReplayDecision::Replay,
"#1255: persistence is load-bearing — a daemon restart must NOT \
reopen the replay window for a previously-seen nonce"
);
assert_eq!(
cache_b.record_and_check("peer-1255", "n-different"),
ReplayDecision::Fresh,
"#1255: hydration must NOT over-block on unrelated nonces"
);
assert!(
cache_b.len_for_peer("peer-1255") >= 1,
"#1255: hydrated cache must retain the persisted fingerprint count"
);
}
#[test]
fn issue_1690_eviction_prunes_disk_mirror() {
fn disk_row_count(path: &std::path::Path) -> i64 {
let conn = crate::db::open(path).expect("open nonce db");
conn.query_row("SELECT COUNT(*) FROM federation_nonce_cache", [], |r| {
r.get(0)
})
.expect("count rows")
}
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let db_path = tmp.path().to_path_buf();
let cache = FederationNonceCache::new_with_db_persistence(&db_path)
.expect("open + migrate nonce db");
let fp_a = FederationNonceCache::fingerprint("peer-x", "nonce-a");
let fp_b = FederationNonceCache::fingerprint("peer-x", "nonce-b");
cache.persist_fingerprint_and_evict("peer-x", &fp_a, 1, None, None);
cache.persist_fingerprint_and_evict("peer-x", &fp_b, 2, None, None);
assert_eq!(disk_row_count(&db_path), 2, "two inserts → two rows");
let fp_c = FederationNonceCache::fingerprint("peer-x", "nonce-c");
cache.persist_fingerprint_and_evict("peer-x", &fp_c, 3, Some(&fp_a), None);
assert_eq!(
disk_row_count(&db_path),
2,
"#1690: a per-peer FIFO eviction must delete the evicted disk row"
);
let fp_y = FederationNonceCache::fingerprint("peer-y", "n");
cache.persist_fingerprint_and_evict("peer-y", &fp_y, 4, None, None);
assert_eq!(disk_row_count(&db_path), 3, "peer-y row added → three rows");
let fp_z = FederationNonceCache::fingerprint("peer-z", "n");
cache.persist_fingerprint_and_evict("peer-z", &fp_z, 5, None, Some("peer-x"));
assert_eq!(
disk_row_count(&db_path),
2,
"#1690: an outer-LRU peer eviction must delete every disk row for that peer \
(peer-x's 2 rows gone, peer-y + peer-z remain)"
);
}
#[test]
fn issue_1690_prune_nonce_cache_keeps_newest_per_peer() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let conn = crate::db::open(tmp.path()).expect("open + migrate");
for (peer, touch) in [
("peer-a", 1),
("peer-a", 2),
("peer-a", 3),
("peer-a", 4),
("peer-b", 5),
("peer-b", 6),
] {
let fp = FederationNonceCache::fingerprint(peer, &format!("n{touch}"));
conn.execute(
"INSERT INTO federation_nonce_cache (peer_id, fingerprint, last_touch, inserted_at)
VALUES (?1, ?2, ?3, '2026-01-01T00:00:00Z')",
rusqlite::params![peer, fp.as_slice(), touch],
)
.unwrap();
}
let deleted = prune_nonce_cache_to_per_peer_cap(&conn, 2).expect("prune");
assert_eq!(deleted, 2, "#1690: peer-a's 2 over-cap rows deleted");
let a_left: i64 = conn
.query_row(
"SELECT COUNT(*) FROM federation_nonce_cache WHERE peer_id='peer-a'",
[],
|r| r.get(0),
)
.unwrap();
let b_left: i64 = conn
.query_row(
"SELECT COUNT(*) FROM federation_nonce_cache WHERE peer_id='peer-b'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(a_left, 2, "peer-a bounded to cap");
assert_eq!(b_left, 2, "peer-b within cap, untouched");
let min_touch_a: i64 = conn
.query_row(
"SELECT MIN(last_touch) FROM federation_nonce_cache WHERE peer_id='peer-a'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(min_touch_a, 3, "the oldest rows were the ones pruned");
assert_eq!(prune_nonce_cache_to_per_peer_cap(&conn, 2).unwrap(), 0);
}
#[test]
fn issue_1255_persistence_constructor_surfaces_open_errors() {
let dir = tempfile::TempDir::new().unwrap();
let res = FederationNonceCache::new_with_db_persistence(dir.path().to_path_buf());
assert!(
res.is_err(),
"#1255: a non-DB path must surface as a constructor Err so operators \
see the persistence failure rather than silently falling back"
);
}
}