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 within one `Mutex` scope (load, decide, and store without
94    /// releasing the lock, so there is no load-then-store race). The `transition` carries the
95    /// authority check — it fails on any state the retainer does not hold — so scanning for the
96    /// first record it accepts locates the held pact; a durable backend would instead load by
97    /// `retainer`, and this in-memory scan is equivalent.
98    fn apply(&self, transition: &Transition<'_>) -> Result<(), NotHeld> {
99        let mut records = self
100            .records
101            .lock()
102            .expect("registry mutex should not be poisoned");
103        for record in records.iter_mut() {
104            if let Ok(next) = transition(&record.state) {
105                record.state = next;
106                return Ok(());
107            }
108        }
109        Err(NotHeld)
110    }
111}
112
113/// An in-memory synchronous registry seeded with a fixed set of pacts.
114pub struct MemoryRegistry {
115    store: Store,
116}
117
118impl MemoryRegistry {
119    /// Create an empty registry that leases claims for `lease_millis`.
120    #[must_use]
121    pub fn new(lease_millis: u64) -> Self {
122        Self::seeded(Vec::new(), lease_millis)
123    }
124
125    /// Create a registry holding `pacts`, each available to claim, leasing claims
126    /// for `lease_millis`.
127    #[must_use]
128    pub fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
129        Self {
130            store: Store::seeded(pacts, lease_millis),
131        }
132    }
133}
134
135impl Registry for MemoryRegistry {
136    type Error = NotHeld;
137
138    fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error> {
139        Ok(self.store.claim(dockets, now))
140    }
141
142    fn lease_millis(&self) -> u64 {
143        self.store.lease_millis()
144    }
145
146    fn apply(&self, _retainer: &Retainer, transition: &Transition<'_>) -> Result<(), Self::Error> {
147        self.store.apply(transition)
148    }
149}
150
151/// An in-memory asynchronous registry seeded with a fixed set of pacts — the reference
152/// [`AsyncRegistry`](pacta_contract::AsyncRegistry) backend, over the same private store as
153/// [`MemoryRegistry`]. Its I/O is trivial (a `Mutex`), so its `async fn`s are ready futures, but it
154/// exercises the exact same async surface a durable backend implements.
155#[cfg(feature = "async")]
156pub struct MemoryRegistryAsync {
157    store: Store,
158}
159
160#[cfg(feature = "async")]
161impl MemoryRegistryAsync {
162    /// Create an empty registry that leases claims for `lease_millis`.
163    #[must_use]
164    pub fn new(lease_millis: u64) -> Self {
165        Self::seeded(Vec::new(), lease_millis)
166    }
167
168    /// Create a registry holding `pacts`, each available to claim, leasing claims for `lease_millis`.
169    #[must_use]
170    pub fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
171        Self {
172            store: Store::seeded(pacts, lease_millis),
173        }
174    }
175}
176
177#[cfg(feature = "async")]
178impl pacta_contract::AsyncRegistry for MemoryRegistryAsync {
179    type Error = NotHeld;
180
181    async fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, NotHeld> {
182        Ok(self.store.claim(dockets, now))
183    }
184
185    fn lease_millis(&self) -> u64 {
186        self.store.lease_millis()
187    }
188
189    async fn apply(
190        &self,
191        _retainer: &Retainer,
192        transition: &Transition<'_>,
193    ) -> Result<(), NotHeld> {
194        // The store's `apply` is one atomic `Mutex` scope; awaiting nothing, this backend's futures
195        // are ready, but it exercises the same async surface a durable backend implements.
196        self.store.apply(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    fn a_pact() -> Pact {
210        Pact::new(Uuid::new_v4(), "d".to_string(), "k".to_string(), Vec::new())
211    }
212
213    #[test]
214    fn release_rejects_a_non_holder() {
215        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
216        registry
217            .claim(&["d"], Timestamp::from_millis(0))
218            .expect("claim should not error")
219            .expect("a pact should be claimable");
220        let stranger = Retainer::new(Uuid::new_v4());
221        assert_eq!(
222            registry.release(&stranger, Timestamp::from_millis(0)),
223            Err(NotHeld),
224            "release by a non-holder must be rejected, like fulfill and breach"
225        );
226    }
227
228    #[test]
229    fn a_settled_pact_cannot_be_released() {
230        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
231        let claim = registry
232            .claim(&["d"], Timestamp::from_millis(0))
233            .expect("claim should not error")
234            .expect("a pact should be claimable");
235        registry
236            .fulfill(&claim.retainer)
237            .expect("fulfill should settle");
238        assert_eq!(
239            registry.release(&claim.retainer, Timestamp::from_millis(0)),
240            Err(NotHeld),
241            "a concluded obligation has no claim to relinquish"
242        );
243    }
244
245    /// The reference async backend is held to the same scenarios as every sync backend, through the
246    /// shared conformance suite — the async binding proving itself, over the same `Store`.
247    #[cfg(feature = "async")]
248    #[test]
249    fn passes_async_conformance() {
250        pacta_conformance::run_async(MemoryRegistryAsync::seeded);
251    }
252
253    /// The at-most-once invariant under concurrent contention, through the shared *portable* runner
254    /// — the exact check any async backend runs, driven by OS threads and `block_on` (no runtime).
255    #[cfg(feature = "async")]
256    #[test]
257    fn passes_async_contention() {
258        pacta_conformance::run_async_contention(MemoryRegistryAsync::seeded);
259    }
260}