feat(core): settle the Store trait (flush, is_empty, conformance suite)

This commit is contained in:
2026-10-22 08:44:09 -07:00
parent 158689e265
commit 07907efec5
13 changed files with 416 additions and 40 deletions
+5
View File
@@ -14,6 +14,11 @@ categories = ["no-std", "embedded", "network-programming", "science::robotics"]
[features]
default = ["alloc"]
alloc = []
# A `no_std`-clean conformance suite ([`store_conformance`]) that concrete
# backends run against themselves to prove they honor the `Store` contract.
# Std-free so it builds with `--no-default-features`; enabled only by the
# backend crates that exercise the suite (MemStore, LogStore, CappedStore).
conformance = []
[dependencies]
heapless = { version = "0.8", features = ["serde"] }
+2
View File
@@ -14,6 +14,8 @@ pub mod mem_store;
pub mod node;
pub mod observation;
pub mod store;
#[cfg(feature = "conformance")]
pub mod store_conformance;
pub mod transport;
mod wire;
+21
View File
@@ -479,3 +479,24 @@ mod tests {
assert!(!s.is_released(&[0u8; 16]));
}
}
#[cfg(all(test, feature = "conformance"))]
mod conformance_tests {
use crate::store_conformance;
use super::MemStore;
// The whole suite runs against `MemStore` from its own crate.
#[test]
fn store_conforms() {
store_conformance::insert_get(MemStore::new);
store_conformance::dedupe(MemStore::new);
store_conformance::age_lineage(MemStore::new);
store_conformance::saturating_ticks(MemStore::new);
store_conformance::release_surviving_get(MemStore::new);
store_conformance::sig_round_trip(MemStore::new);
store_conformance::trim_order(MemStore::new);
store_conformance::no_tombstones(MemStore::new);
store_conformance::idempotent_flush(MemStore::new);
}
}
+8 -7
View File
@@ -219,14 +219,15 @@ impl<S: Store, T: Transport> Node<S, T> {
&self.store
}
/// Mutable access to the backing store.
/// Best-effort durability flush, advancing each stored observation's age
/// up to `now`.
///
/// Lets a host call backend-specific methods that aren't on the [`Store`]
/// trait — e.g. `tines_store::LogStore::flush`, the durability boundary a
/// host reaches on its flush schedule. [`Self::trim`] takes `&mut self`;
/// this exposes the concrete backend the same way.
pub fn store_mut(&mut self) -> &mut S {
&mut self.store
/// This is the durability boundary a host reaches on its flush schedule —
/// delegates to [`Store::flush`], so it is infallible by contract: a failed
/// flush is, at worst, a duplicate custodian on the next exchange, never a
/// lost message.
pub fn flush(&mut self, now: Tick) -> Result<(), StoreError> {
self.store.flush(now)
}
/// Access the transport (e.g. to `connect`/`disconnect` a peer in tests).
+22 -1
View File
@@ -43,7 +43,6 @@ pub enum StoreError {
/// differ only in age or `observed_at` share an id and dedupe on insert. Ages
/// are reported as *current* ages, advanced by the elapsed ticks since each
/// observation was stored.
#[allow(clippy::len_without_is_empty)]
pub trait Store {
/// Insert an observation.
///
@@ -62,6 +61,11 @@ pub trait Store {
/// Number of observations currently stored.
fn len(&self) -> usize;
/// `true` if the store holds no observations.
fn is_empty(&self) -> bool {
self.len() == 0
}
/// Visit every stored observation id.
fn for_each_id(&self, f: &mut dyn FnMut(&ObservationId));
@@ -85,6 +89,10 @@ pub trait Store {
/// Releasing an id that is not currently stored is a no-op. The mark
/// persists for the lifetime of the entry (until it is trimmed); a
/// re-inserted observation starts un-released.
///
/// Release is best-effort and infallible: it never returns an error, and a
/// failed release is, at worst, a duplicate custodian on the next
/// exchange — never a lost message.
fn release(&mut self, id: &ObservationId);
/// Returns `true` if the observation with `id` has been released.
@@ -98,6 +106,19 @@ pub trait Store {
/// the number of observations removed. Trimmed ids are not tombstoned:
/// re-inserting them later is accepted.
fn trim(&mut self, policy: &TrimPolicy, now: Tick) -> usize;
/// Best-effort durability flush, advancing each stored observation's age
/// up to the caller's `now` and recording `now` as the flush clock.
///
/// Flush must not lose messages, must never resurrect a released message,
/// and is infallible by contract: a failed flush is, at worst, a
/// duplicate custodian on the next exchange — never a lost message.
/// Backends that cannot persist more cheaply default to a no-op that
/// simply returns `Ok(())`.
fn flush(&mut self, now: Tick) -> Result<(), StoreError> {
let _ = now;
Ok(())
}
}
/// Current (age-adjusted) age of a stored observation at tick `now`.
+223
View File
@@ -0,0 +1,223 @@
//! Backend conformance suite.
//!
//! A `no_std`-clean set of property tests that any [`Store`] backend can run to
//! prove it honors the contract in [`Store`](crate::Store). Each public function
//! exercises exactly one property against a backend produced by a
//! caller-supplied factory, so the same body runs for [`MemStore`], a
//! [`LogStore`](crate::store::LogStore) opened on a fresh file per case and
//! [`CappedStore`] — see the `#[test]` shims in each backend crate.
//!
//! The functions build their own [`Observation`]s, including one carrying a
//! `sig`, which only the crate that owns the `#[non_exhaustive]` type may
//! construct by struct literal — this is why the suite lives *inside*
//! `tines-core` rather than as a `tines-core/tests/` file.
//!
//! The suite is deliberately free of `std`/`alloc` so it can build under
//! `--no-default-features` (with this feature on): every call is `heapless`
//! plus `assert!`.
use crate::observation::Observation;
use crate::{Payload, SigAlgo, Signature, Store, Topic, TrimPolicy};
/// Build a signed broadcast observation for the suite.
///
/// `signature` is a byte string the backend need not understand; the test only
/// checks it round-trips verbatim, so it is any length `<= MAX_SIG`.
fn signed_obs(seq: u64, topic: &[u8], signature: &[u8]) -> Observation {
let payload = Payload::from_slice(b"pay").expect("payload fits capacity");
Observation {
observer: [1u8; 16],
seq,
age_ms: 0,
observed_at: None,
topic: Topic::from_slice(topic).expect("topic fits capacity"),
payload,
dest: None,
sig: Some(Signature {
version: 1,
algo: SigAlgo::Ed25519,
bytes: heapless::Vec::from_slice(signature).expect("signature fits capacity"),
}),
}
}
/// Two observations that carry the *same* id: same content, different age.
/// The signature is ignored by identity, so an aged copy reuses the id.
fn same_id(seq: u64, topic: &[u8]) -> (Observation, Observation) {
let fresh = signed_obs(seq, topic, &[0u8; 8]);
let mut aged = signed_obs(seq, topic, &[0u8; 8]);
aged.age_ms = 500;
(fresh, aged)
}
/// A freshly inserted observation must be retrievable by id at `now`.
pub fn insert_get<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let (obs, _) = same_id(1, b"t");
let id = obs.id();
assert!(store.insert(obs, 1000).unwrap(), "insert succeeds");
let got = store.get(&id, 1000).expect("get after insert");
assert_eq!(got.seq, 1);
assert_eq!(got.topic.as_slice(), b"t");
assert_eq!(got.payload.as_slice(), b"pay");
}
/// A second insert of an already-held id is a no-op: the entry, its stored age
/// lineage and its release state are left untouched.
pub fn dedupe<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
assert!(store.is_empty());
let (fresh, aged) = same_id(1, b"t");
let id = fresh.id();
assert_eq!(id, aged.id(), "an aged copy shares the id");
assert!(store.insert(fresh, 1000).unwrap(), "first insert");
assert_eq!(store.len(), 1);
assert!(
!store.insert(aged, 1500).unwrap(),
"duplicate is not reinserted"
);
assert_eq!(store.len(), 1);
assert!(store.contains(&id));
}
/// `get` advances age by elapsed ticks; a deduped insert must not reset the
/// stored age lineage.
pub fn age_lineage<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let (fresh, _) = same_id(1, b"t");
let id = fresh.id();
store.insert(fresh, 1000).unwrap();
let first = store.get(&id, 1500).unwrap();
assert_eq!(first.age_ms, 500, "age advances to elapsed ticks");
// A later insert of the same id must not reset stored_at.
store.insert(same_id(1, b"t").0, 3000).unwrap();
let second = store.get(&id, 3500).unwrap();
assert_eq!(
second.age_ms, 2500,
"age keeps climbing from the first insert, not the duplicate"
);
}
/// `get` with `now` before the stored tick saturates to zero age — it must not
/// underflow.
pub fn saturating_ticks<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let (fresh, _) = same_id(1, b"t");
let id = fresh.id();
store.insert(fresh, 2000).unwrap();
let got = store.get(&id, 1000).unwrap();
assert_eq!(got.age_ms, 0, "elapsed underflow saturates at zero");
}
/// Releasing marks an id but leaves it stored, contained and gettable; it must
/// not evict the entry.
pub fn release_surviving_get<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let (fresh, _) = same_id(1, b"t");
let id = fresh.id();
store.insert(fresh, 1000).unwrap();
store.release(&id);
assert!(store.is_released(&id));
assert!(store.contains(&id), "release does not evict");
assert!(
store.get(&id, 1000).is_some(),
"released id is still gettable"
);
assert_eq!(store.len(), 1);
}
/// A signature carried by an observation round-trips through the store intact,
/// and a different signature over the same content shares the id.
pub fn sig_round_trip<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let sig_bytes = [7u8; 32];
let fresh = signed_obs(1, b"sig", &sig_bytes);
let id = fresh.id();
store.insert(fresh, 1000).unwrap();
let got = store.get(&id, 1000).unwrap();
let sig = got
.sig
.expect("the signature round-trips through the store");
assert_eq!(sig.version, 1);
assert_eq!(sig.algo, SigAlgo::Ed25519);
assert_eq!(&sig.bytes[..], &sig_bytes[..]);
// A different signature over identical content must not change identity.
let other = signed_obs(1, b"sig", &[9u8; 40]);
assert_eq!(other.id(), id, "the signature must not affect identity");
}
/// With a count cap, `trim` drops the oldest (highest current age) first and
/// reports how many it removed.
pub fn trim_order<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
for i in 0..5u64 {
let fresh = signed_obs(i, b"t", &[i as u8]);
assert!(store.insert(fresh, i * 10).unwrap(), "insert {}", i);
}
let policy = TrimPolicy {
keep_per_topic: None,
max_age_ms: None,
max_count: Some(2),
};
assert_eq!(
store.trim(&policy, 50),
3,
"trim returns the number removed"
);
// The two youngest (seq 3 and 4, lowest current age) survive.
for dropped in 0..3u64 {
let fresh = signed_obs(dropped, b"t", &[dropped as u8]);
assert!(!store.contains(&fresh.id()), "seq {} is trimmed", dropped);
}
for kept in 3..5u64 {
let fresh = signed_obs(kept, b"t", &[kept as u8]);
assert!(store.contains(&fresh.id()), "seq {} survives", kept);
}
}
/// A trimmed id is not tombstoned: it can be reinserted and counted again.
pub fn no_tombstones<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let fresh = signed_obs(1, b"t", &[1u8; 8]);
let id = fresh.id();
store.insert(fresh, 0).unwrap();
let policy = TrimPolicy {
keep_per_topic: None,
max_age_ms: Some(1),
max_count: None,
};
assert_eq!(store.trim(&policy, 100), 1);
assert!(store.is_empty());
// The trimmed id is not tombstoned: re-insert it (aged, still same id).
let reinserted = signed_obs(1, b"t", &[1u8; 8]);
let mut reinserted = reinserted;
reinserted.age_ms = 5;
assert_eq!(reinserted.id(), id, "an aged re-insert keeps the id");
assert!(
store.insert(reinserted, 0).unwrap(),
"a trimmed id can be reinserted"
);
assert!(store.contains(&id));
assert_eq!(store.len(), 1);
}
/// Flushing twice is safe: it must not error and must not evict, and age must
/// keep advancing afterwards.
pub fn idempotent_flush<S: Store>(mut make: impl FnMut() -> S) {
let mut store = make();
let fresh = signed_obs(1, b"t", &[1u8; 8]);
let id = fresh.id();
store.insert(fresh, 1000).unwrap();
assert!(
store.flush(2000).is_ok(),
"the first flush is a best-effort success"
);
assert!(
store.flush(2000).is_ok(),
"the second flush is a no-op that still succeeds"
);
assert!(store.contains(&id), "flush does not evict");
assert_eq!(store.len(), 1);
let got = store.get(&id, 2500).unwrap();
assert_eq!(got.age_ms, 1500, "age keeps advancing past a flush");
}