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    use std::marker::PhantomData;
204    use std::rc::Rc;
205
206    struct LocalRegistry {
207        inner: MemoryRegistry,
208        _local: Rc<()>,
209    }
210
211    impl LocalRegistry {
212        fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
213            Self {
214                inner: MemoryRegistry::seeded(pacts, lease_millis),
215                _local: Rc::new(()),
216            }
217        }
218    }
219
220    impl Registry for LocalRegistry {
221        type Error = NotHeld;
222
223        fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error> {
224            self.inner.claim(dockets, now)
225        }
226
227        fn lease_millis(&self) -> u64 {
228            self.inner.lease_millis()
229        }
230
231        fn apply(
232            &self,
233            retainer: &Retainer,
234            transition: &Transition<'_>,
235        ) -> Result<(), Self::Error> {
236            self.inner.apply(retainer, transition)
237        }
238    }
239
240    #[cfg(feature = "async")]
241    struct LocalRegistryAsync {
242        inner: MemoryRegistryAsync,
243        _local: Rc<()>,
244    }
245
246    #[cfg(feature = "async")]
247    impl LocalRegistryAsync {
248        fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
249            Self {
250                inner: MemoryRegistryAsync::seeded(pacts, lease_millis),
251                _local: Rc::new(()),
252            }
253        }
254    }
255
256    #[cfg(feature = "async")]
257    impl pacta_contract::AsyncRegistry for LocalRegistryAsync {
258        type Error = NotHeld;
259
260        async fn claim(
261            &self,
262            dockets: &[&str],
263            now: Timestamp,
264        ) -> Result<Option<Claim>, Self::Error> {
265            pacta_contract::AsyncRegistry::claim(&self.inner, dockets, now).await
266        }
267
268        fn lease_millis(&self) -> u64 {
269            pacta_contract::AsyncRegistry::lease_millis(&self.inner)
270        }
271
272        async fn apply(
273            &self,
274            retainer: &Retainer,
275            transition: &Transition<'_>,
276        ) -> Result<(), Self::Error> {
277            pacta_contract::AsyncRegistry::apply(&self.inner, retainer, transition).await
278        }
279    }
280
281    #[cfg(feature = "async")]
282    #[derive(Clone, Copy)]
283    struct LocalDriver(PhantomData<Rc<()>>);
284
285    #[cfg(feature = "async")]
286    impl pacta_conformance::BlockingDriver for LocalDriver {
287        fn drive<F: core::future::Future>(&self, future: F) -> F::Output {
288            pacta_conformance::BlockingDriver::drive(&pacta_conformance::SelfProgress, future)
289        }
290    }
291
292    #[test]
293    fn passes_registry_conformance() {
294        pacta_conformance::run(MemoryRegistry::seeded);
295    }
296
297    #[test]
298    fn local_sync_backend_passes_sequential_conformance() {
299        pacta_conformance::run(LocalRegistry::seeded);
300    }
301
302    /// The sync reference backend upholds at-most-once authority under real concurrent claim and
303    /// settlement contention, through the shared sync contention check (OS threads).
304    #[test]
305    fn passes_sync_contention() {
306        pacta_conformance::run_contention(MemoryRegistry::seeded);
307    }
308
309    fn a_pact() -> Pact {
310        Pact::new(Uuid::new_v4(), "d".to_string(), "k".to_string(), Vec::new())
311    }
312
313    #[test]
314    fn release_rejects_a_non_holder() {
315        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
316        registry
317            .claim(&["d"], Timestamp::from_millis(0))
318            .expect("claim should not error")
319            .expect("a pact should be claimable");
320        let stranger = Retainer::new(Uuid::new_v4());
321        assert_eq!(
322            registry.release(&stranger, Timestamp::from_millis(0)),
323            Err(NotHeld),
324            "release by a non-holder must be rejected, like fulfill and breach"
325        );
326    }
327
328    #[test]
329    fn a_settled_pact_cannot_be_released() {
330        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
331        let claim = registry
332            .claim(&["d"], Timestamp::from_millis(0))
333            .expect("claim should not error")
334            .expect("a pact should be claimable");
335        registry
336            .fulfill(&claim.retainer)
337            .expect("fulfill should settle");
338        assert_eq!(
339            registry.release(&claim.retainer, Timestamp::from_millis(0)),
340            Err(NotHeld),
341            "a concluded obligation has no claim to relinquish"
342        );
343    }
344
345    /// Adversarial authority: a stranger retainer paired with a transition that would accept *any*
346    /// state must not drive a transition, because `apply` locates the record the retainer holds and
347    /// the stranger holds none. The held pact is left untouched — the true holder still settles it —
348    /// so authority is enforced by the located record, not merely by the transition closure.
349    #[test]
350    fn apply_rejects_a_stranger_even_with_an_any_state_transition() {
351        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
352        let claim = registry
353            .claim(&["d"], Timestamp::from_millis(0))
354            .expect("claim should not error")
355            .expect("a pact should be claimable");
356        let stranger = Retainer::new(Uuid::new_v4());
357        // This transition would accept any state — the safety must come from apply locating the
358        // stranger's (nonexistent) held record, not from the transition policing the holder.
359        let accept_any = |_state: &State| Ok::<State, lifecycle::NotCurrentHolder>(State::Settled);
360        assert_eq!(
361            registry.apply(&stranger, &accept_any),
362            Err(NotHeld),
363            "a retainer that holds no record cannot apply, even an any-state transition"
364        );
365        // The held pact was not mutated: the true holder still settles it.
366        registry
367            .fulfill(&claim.retainer)
368            .expect("the held state was untouched, so the holder still settles");
369    }
370
371    /// The correct holder's lifecycle transitions still succeed after locating by retainer:
372    /// heartbeat extends, and release then rotates authority away.
373    #[test]
374    fn apply_admits_the_true_holder() {
375        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
376        let claim = registry
377            .claim(&["d"], Timestamp::from_millis(0))
378            .expect("claim should not error")
379            .expect("a pact should be claimable");
380        registry
381            .heartbeat(&claim.retainer, Timestamp::from_millis(500))
382            .expect("the holder's heartbeat extends the lease");
383        registry
384            .release(&claim.retainer, Timestamp::from_millis(0))
385            .expect("the holder releases");
386        assert_eq!(
387            registry.fulfill(&claim.retainer),
388            Err(NotHeld),
389            "release rotated authority, so the prior retainer no longer holds a record"
390        );
391    }
392
393    /// The reference async backend is held to the same scenarios as every sync backend, through the
394    /// shared conformance suite — the async binding proving itself, over the same `Store`.
395    #[cfg(feature = "async")]
396    #[test]
397    fn passes_async_conformance() {
398        pacta_conformance::run_async(MemoryRegistryAsync::seeded);
399    }
400
401    #[cfg(feature = "async")]
402    #[test]
403    fn local_async_backend_passes_ready_future_conformance() {
404        pacta_conformance::run_async(LocalRegistryAsync::seeded);
405    }
406
407    #[cfg(feature = "async")]
408    #[test]
409    fn local_async_backend_and_driver_pass_runtime_compatible_conformance() {
410        pacta_conformance::run_async_with(LocalRegistryAsync::seeded, LocalDriver(PhantomData));
411    }
412
413    /// The async binding enforces authority the same way: a stranger retainer with an any-state
414    /// transition is rejected, over the same shared `Store::apply`.
415    #[cfg(feature = "async")]
416    #[tokio::test]
417    async fn async_apply_rejects_a_stranger_even_with_an_any_state_transition() {
418        use pacta_contract::AsyncRegistry;
419
420        let registry = MemoryRegistryAsync::seeded(vec![a_pact()], 1000);
421        let claim = registry
422            .claim(&["d"], Timestamp::from_millis(0))
423            .await
424            .expect("claim should not error")
425            .expect("a pact should be claimable");
426        let stranger = Retainer::new(Uuid::new_v4());
427        let accept_any = |_state: &State| Ok::<State, lifecycle::NotCurrentHolder>(State::Settled);
428        assert_eq!(
429            registry.apply(&stranger, &accept_any).await,
430            Err(NotHeld),
431            "the async binding also locates by retainer"
432        );
433        registry
434            .fulfill(&claim.retainer)
435            .await
436            .expect("the held state was untouched, so the holder still settles");
437    }
438
439    /// The at-most-once invariant under concurrent contention for this **ready-future** backend,
440    /// through the shared `run_async_contention` check (OS threads + no-op-waker `block_on`, no
441    /// runtime). A real-reactor backend cannot use this ready-future runner; it drives its own
442    /// contention verification, and runs the sequential scenarios via `run_async_with`.
443    #[cfg(feature = "async")]
444    #[test]
445    fn passes_async_contention() {
446        pacta_conformance::run_async_contention(MemoryRegistryAsync::seeded);
447    }
448}