Skip to main content

pacta_memory/
lib.rs

1//! In-memory reference [`Registry`] backends with real lease and lapse semantics.
2//!
3//! These are **reference** backends, not durable or production ones: they hold pacts in memory, so
4//! nothing survives the process. They exist to demonstrate correct lifecycle semantics and to
5//! calibrate against — durable backends live outside this workspace and prove themselves against
6//! `pacta-conformance` just as these do.
7//!
8//! [`MemoryRegistry`] implements the synchronous [`Registry`]. Behind the `async` feature,
9//! [`MemoryRegistryAsync`] implements [`pacta_contract::AsyncRegistry`] over the **same** private
10//! store, so the two bindings share one storage and cannot drift. Every eligibility decision and
11//! state transition is delegated to the shared, pure [`pacta_contract::lifecycle`] kernel; the store
12//! reads no clock — time is injected into `claim` and `heartbeat`.
13
14#![forbid(unsafe_code)]
15#![warn(missing_docs)]
16
17use std::sync::Mutex;
18
19use pacta_contract::lifecycle::{self, State};
20use pacta_contract::{Claim, Pact, Registry, Retainer, Timestamp, Transition};
21use uuid::Uuid;
22
23/// The error a memory backend returns when a retainer is not the current holder,
24/// or when a heartbeat arrives after its lease has already lapsed.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct NotHeld;
27
28impl std::fmt::Display for NotHeld {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "retainer is not the current holder of any claim")
31    }
32}
33
34impl std::error::Error for NotHeld {}
35
36impl From<lifecycle::NotCurrentHolder> for NotHeld {
37    fn from(_: lifecycle::NotCurrentHolder) -> Self {
38        NotHeld
39    }
40}
41
42struct Record {
43    pact: Pact,
44    state: State,
45}
46
47/// The shared in-memory store: storage, retainer minting, and the claim-select / transition-apply
48/// logic. Both the sync and async backends wrap one of these, so their behavior is single-sourced.
49/// It owns no I/O beyond a `Mutex`; every decision is the shared `lifecycle` kernel's.
50struct Store {
51    records: Mutex<Vec<Record>>,
52    lease_millis: u64,
53}
54
55impl Store {
56    fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
57        Self {
58            records: Mutex::new(
59                pacts
60                    .into_iter()
61                    .map(|pact| Record {
62                        pact,
63                        state: State::Available,
64                    })
65                    .collect(),
66            ),
67            lease_millis,
68        }
69    }
70
71    fn lease_millis(&self) -> u64 {
72        self.lease_millis
73    }
74
75    fn claim(&self, dockets: &[&str], now: Timestamp) -> Option<Claim> {
76        let mut records = self
77            .records
78            .lock()
79            .expect("registry mutex should not be poisoned");
80        // Storage picks a candidate on the requested dockets; the kernel decides
81        // eligibility (available / lapsed hold / reclaimable defer / settled).
82        let index = records.iter().position(|record| {
83            dockets.contains(&record.pact.docket.as_str())
84                && lifecycle::is_claimable(&record.state, now)
85        })?;
86        // Mint a retainer only on a successful claim; the kernel produces the held state.
87        let retainer = Retainer::new(Uuid::new_v4());
88        records[index].state = lifecycle::on_claim(&retainer, now, self.lease_millis);
89        let expiry = lifecycle::lease_expiry(now, self.lease_millis);
90        Some(Claim::new(records[index].pact.clone(), retainer, expiry))
91    }
92
93    /// Apply a lifecycle transition to the pact held by `retainer`, within one `Mutex` scope
94    /// (load, decide, and store without releasing the lock, so there is no load-then-store race).
95    /// This locates the record the retainer holds — the one whose state is `Held { retainer, .. }` —
96    /// exactly as a durable backend loads its row by the holder key, then runs the transition on
97    /// that record and persists the result. The transition's own `Result` is propagated, so a
98    /// transition that rejects the located state (a heartbeat on a lapsed-but-unreclaimed lease)
99    /// still fails. A retainer that holds no record resolves to `NotHeld` without mutating anything —
100    /// so an authority the caller does not hold cannot drive a transition, even one that would
101    /// accept any state.
102    fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), NotHeld> {
103        let mut records = self
104            .records
105            .lock()
106            .expect("registry mutex should not be poisoned");
107        let record = records
108            .iter_mut()
109            .find(|record| matches!(&record.state, State::Held { retainer: held, .. } if held == retainer))
110            .ok_or(NotHeld)?;
111        record.state = transition(&record.state)?;
112        Ok(())
113    }
114}
115
116/// An in-memory synchronous registry seeded with a fixed set of pacts.
117pub struct MemoryRegistry {
118    store: Store,
119}
120
121impl MemoryRegistry {
122    /// Create an empty registry that leases claims for `lease_millis`.
123    #[must_use]
124    pub fn new(lease_millis: u64) -> Self {
125        Self::seeded(Vec::new(), lease_millis)
126    }
127
128    /// Create a registry holding `pacts`, each available to claim, leasing claims
129    /// for `lease_millis`.
130    #[must_use]
131    pub fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
132        Self {
133            store: Store::seeded(pacts, lease_millis),
134        }
135    }
136}
137
138impl Registry for MemoryRegistry {
139    type Error = NotHeld;
140
141    fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error> {
142        Ok(self.store.claim(dockets, now))
143    }
144
145    fn lease_millis(&self) -> u64 {
146        self.store.lease_millis()
147    }
148
149    fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), Self::Error> {
150        self.store.apply(retainer, transition)
151    }
152}
153
154/// An in-memory asynchronous registry seeded with a fixed set of pacts — the reference
155/// [`AsyncRegistry`](pacta_contract::AsyncRegistry) backend, over the same private store as
156/// [`MemoryRegistry`]. Its I/O is trivial (a `Mutex`), so its `async fn`s are ready futures, but it
157/// exercises the exact same async surface a durable backend implements.
158#[cfg(feature = "async")]
159pub struct MemoryRegistryAsync {
160    store: Store,
161}
162
163#[cfg(feature = "async")]
164impl MemoryRegistryAsync {
165    /// Create an empty registry that leases claims for `lease_millis`.
166    #[must_use]
167    pub fn new(lease_millis: u64) -> Self {
168        Self::seeded(Vec::new(), lease_millis)
169    }
170
171    /// Create a registry holding `pacts`, each available to claim, leasing claims for `lease_millis`.
172    #[must_use]
173    pub fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
174        Self {
175            store: Store::seeded(pacts, lease_millis),
176        }
177    }
178}
179
180#[cfg(feature = "async")]
181impl pacta_contract::AsyncRegistry for MemoryRegistryAsync {
182    type Error = NotHeld;
183
184    async fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, NotHeld> {
185        Ok(self.store.claim(dockets, now))
186    }
187
188    fn lease_millis(&self) -> u64 {
189        self.store.lease_millis()
190    }
191
192    async fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), NotHeld> {
193        // The store's `apply` is one atomic `Mutex` scope; awaiting nothing, this backend's futures
194        // are ready, but it exercises the same async surface a durable backend implements. It
195        // locates the record held by `retainer`, as a durable backend loads its row by holder.
196        self.store.apply(retainer, transition)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn passes_registry_conformance() {
206        pacta_conformance::run(MemoryRegistry::seeded);
207    }
208
209    /// The sync reference backend upholds at-most-once authority under real concurrent claim and
210    /// settlement contention, through the shared sync contention check (OS threads).
211    #[test]
212    fn passes_sync_contention() {
213        pacta_conformance::run_contention(MemoryRegistry::seeded);
214    }
215
216    fn a_pact() -> Pact {
217        Pact::new(Uuid::new_v4(), "d".to_string(), "k".to_string(), Vec::new())
218    }
219
220    #[test]
221    fn release_rejects_a_non_holder() {
222        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
223        registry
224            .claim(&["d"], Timestamp::from_millis(0))
225            .expect("claim should not error")
226            .expect("a pact should be claimable");
227        let stranger = Retainer::new(Uuid::new_v4());
228        assert_eq!(
229            registry.release(&stranger, Timestamp::from_millis(0)),
230            Err(NotHeld),
231            "release by a non-holder must be rejected, like fulfill and breach"
232        );
233    }
234
235    #[test]
236    fn a_settled_pact_cannot_be_released() {
237        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
238        let claim = registry
239            .claim(&["d"], Timestamp::from_millis(0))
240            .expect("claim should not error")
241            .expect("a pact should be claimable");
242        registry
243            .fulfill(&claim.retainer)
244            .expect("fulfill should settle");
245        assert_eq!(
246            registry.release(&claim.retainer, Timestamp::from_millis(0)),
247            Err(NotHeld),
248            "a concluded obligation has no claim to relinquish"
249        );
250    }
251
252    /// Adversarial authority: a stranger retainer paired with a transition that would accept *any*
253    /// state must not drive a transition, because `apply` locates the record the retainer holds and
254    /// the stranger holds none. The held pact is left untouched — the true holder still settles it —
255    /// so authority is enforced by the located record, not merely by the transition closure.
256    #[test]
257    fn apply_rejects_a_stranger_even_with_an_any_state_transition() {
258        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
259        let claim = registry
260            .claim(&["d"], Timestamp::from_millis(0))
261            .expect("claim should not error")
262            .expect("a pact should be claimable");
263        let stranger = Retainer::new(Uuid::new_v4());
264        // This transition would accept any state — the safety must come from apply locating the
265        // stranger's (nonexistent) held record, not from the transition policing the holder.
266        let accept_any = |_state: &State| Ok::<State, lifecycle::NotCurrentHolder>(State::Settled);
267        assert_eq!(
268            registry.apply(&stranger, &accept_any),
269            Err(NotHeld),
270            "a retainer that holds no record cannot apply, even an any-state transition"
271        );
272        // The held pact was not mutated: the true holder still settles it.
273        registry
274            .fulfill(&claim.retainer)
275            .expect("the held state was untouched, so the holder still settles");
276    }
277
278    /// The correct holder's lifecycle transitions still succeed after locating by retainer:
279    /// heartbeat extends, and release then rotates authority away.
280    #[test]
281    fn apply_admits_the_true_holder() {
282        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
283        let claim = registry
284            .claim(&["d"], Timestamp::from_millis(0))
285            .expect("claim should not error")
286            .expect("a pact should be claimable");
287        registry
288            .heartbeat(&claim.retainer, Timestamp::from_millis(500))
289            .expect("the holder's heartbeat extends the lease");
290        registry
291            .release(&claim.retainer, Timestamp::from_millis(0))
292            .expect("the holder releases");
293        assert_eq!(
294            registry.fulfill(&claim.retainer),
295            Err(NotHeld),
296            "release rotated authority, so the prior retainer no longer holds a record"
297        );
298    }
299
300    /// The reference async backend is held to the same scenarios as every sync backend, through the
301    /// shared conformance suite — the async binding proving itself, over the same `Store`.
302    #[cfg(feature = "async")]
303    #[test]
304    fn passes_async_conformance() {
305        pacta_conformance::run_async(MemoryRegistryAsync::seeded);
306    }
307
308    /// The async binding enforces authority the same way: a stranger retainer with an any-state
309    /// transition is rejected, over the same shared `Store::apply`.
310    #[cfg(feature = "async")]
311    #[tokio::test]
312    async fn async_apply_rejects_a_stranger_even_with_an_any_state_transition() {
313        use pacta_contract::AsyncRegistry;
314
315        let registry = MemoryRegistryAsync::seeded(vec![a_pact()], 1000);
316        let claim = registry
317            .claim(&["d"], Timestamp::from_millis(0))
318            .await
319            .expect("claim should not error")
320            .expect("a pact should be claimable");
321        let stranger = Retainer::new(Uuid::new_v4());
322        let accept_any = |_state: &State| Ok::<State, lifecycle::NotCurrentHolder>(State::Settled);
323        assert_eq!(
324            registry.apply(&stranger, &accept_any).await,
325            Err(NotHeld),
326            "the async binding also locates by retainer"
327        );
328        registry
329            .fulfill(&claim.retainer)
330            .await
331            .expect("the held state was untouched, so the holder still settles");
332    }
333
334    /// The at-most-once invariant under concurrent contention for this **ready-future** backend,
335    /// through the shared `run_async_contention` check (OS threads + no-op-waker `block_on`, no
336    /// runtime). A real-reactor backend cannot use this ready-future runner; it drives its own
337    /// contention verification, and runs the sequential scenarios via `run_async_with`.
338    #[cfg(feature = "async")]
339    #[test]
340    fn passes_async_contention() {
341        pacta_conformance::run_async_contention(MemoryRegistryAsync::seeded);
342    }
343}