fix(core): ack only observations the store kept

Exchange::accept enqueued a pending ack whenever
!(duplicate && addressed && released) held, even when the backend
store.insert returned Err (store full / backend error). On Err the
released flag computed to false, so an ack was still enqueued: a
receiver that did not keep the message acked it. For an addressed
offer that releases the sender's custody for a message nobody holds;
for a broadcast it fires a false confidence event at the sender.

Gate the ack enqueue on res.is_ok(): enqueue only when the store
actually retained the observation. The existing released-holder
exception still applies among kept copies. No bounce is recorded on
Err (already the case).

Adds a unit test exercising a capped store (StoreError::Full) for
both a broadcast and an addressed offer, and a tines-sim property
that drives custody with CappedStore-backed nodes and asserts the
"at least one holder" invariant still holds when stores fill.
This commit is contained in:
2026-10-19 12:35:17 -07:00
parent 836ab74e81
commit 2738de4959
5 changed files with 299 additions and 14 deletions
+83
View File
@@ -0,0 +1,83 @@
//! A [`Store`] that delegates to a [`MemStore`] but rejects `insert` with
//! [`StoreError::Full`] once it has held `capacity` observations.
//!
//! `MemStore` is unbounded, so it never fills; this wrapper lets the simulator
//! exercise the full-store custody path (F1): a receiver whose store is full
//! must not ack a received observation, or custody is released for a message
//! nobody holds.
use tines_core::{MemStore, Observation, ObservationId, Store, StoreError, Tick, TrimPolicy};
/// A capacity-bounded [`Store`]: delegates to a [`MemStore`] but returns
/// `StoreError::Full` on `insert` once `capacity` observations are held.
pub struct CappedStore {
capacity: usize,
inner: MemStore,
}
impl CappedStore {
/// A capped store holding at most `capacity` observations.
pub fn new(capacity: usize) -> Self {
Self {
capacity,
inner: MemStore::new(),
}
}
}
impl Default for CappedStore {
fn default() -> Self {
// Small enough that a node filling up on received observations rejects
// later inserts, which is what drives the full-store ack path.
Self::new(8)
}
}
impl Store for CappedStore {
fn insert(&mut self, obs: Observation, now: Tick) -> Result<bool, StoreError> {
if self.inner.len() >= self.capacity {
return Err(StoreError::Full);
}
self.inner.insert(obs, now)
}
fn contains(&self, id: &ObservationId) -> bool {
self.inner.contains(id)
}
fn get(&self, id: &ObservationId, now: Tick) -> Option<Observation> {
self.inner.get(id, now)
}
fn len(&self) -> usize {
self.inner.len()
}
fn for_each_id(&self, f: &mut dyn FnMut(&ObservationId)) {
self.inner.for_each_id(f)
}
fn for_each_broadcast_id(&self, f: &mut dyn FnMut(&ObservationId)) {
self.inner.for_each_broadcast_id(f)
}
fn for_each_addressed_id(&self, f: &mut dyn FnMut(&ObservationId)) {
self.inner.for_each_addressed_id(f)
}
fn release(&mut self, id: &ObservationId) {
self.inner.release(id)
}
fn is_released(&self, id: &ObservationId) -> bool {
self.inner.is_released(id)
}
fn for_each_topic(&self, prefix: &[u8], now: Tick, f: &mut dyn FnMut(&Observation)) {
self.inner.for_each_topic(prefix, now, f)
}
fn trim(&mut self, policy: &TrimPolicy, now: Tick) -> usize {
self.inner.trim(policy, now)
}
}
+2
View File
@@ -9,11 +9,13 @@
#![deny(warnings)]
pub mod capped;
pub mod sim;
pub mod trace;
pub mod transport;
pub mod world;
pub use capped::CappedStore;
pub use sim::{Sim, SimNode};
pub use trace::{Contact, ContactTrace};
pub use transport::SimTransport;
+18 -3
View File
@@ -19,15 +19,30 @@ use tines_core::{MemStore, Node, NodeId, ObservationId, Store};
pub type SimNode = Node<MemStore, SimTransport>;
/// A run of the simulation, owned by the test.
pub struct Sim {
///
/// Generic over the store backend `S` so tests can drive custody with a store
/// that fills (see [`CappedStore`](crate::CappedStore)); the default is the
/// unbounded [`MemStore`].
pub struct Sim<S = MemStore>
where
S: Store,
{
pub world: Rc<RefCell<SimWorld>>,
pub nodes: Vec<SimNode>,
pub nodes: Vec<Node<S, SimTransport>>,
pub t: u64,
pub step_ms: u64,
}
impl Sim {
/// A run over `nodes` nodes, each with its own unbounded [`MemStore`].
pub fn new(cfg: WorldConfig, trace: ContactTrace) -> Sim {
Sim::with_store(cfg, trace)
}
}
impl<S: Store + Default> Sim<S> {
/// A run over `nodes` nodes, each given a fresh `S::default()` store.
pub fn with_store(cfg: WorldConfig, trace: ContactTrace) -> Sim<S> {
let n = cfg.nodes;
let world = SimWorld::new(cfg, trace);
let mut nodes = Vec::with_capacity(n);
@@ -35,7 +50,7 @@ impl Sim {
nodes.push(Node::new(
[i as u8; 16],
0,
MemStore::new(),
S::default(),
SimTransport::new(world.clone(), i),
));
}