Skip to main content

pacta_memory/
lib.rs

1//! An in-memory [`Registry`] backend with real lease and lapse semantics.
2//!
3//! This is a **reference** backend, not a durable or production one: it holds pacts
4//! in memory, so nothing survives the process. It exists to demonstrate correct
5//! lifecycle semantics and to calibrate against — durable backends live outside this
6//! workspace and prove themselves against `pacta-conformance` just as this one does.
7//!
8//! It is a pure lifecycle state machine that holds
9//! pacts, leases claims for a user-supplied duration, reclaims lapsed pacts through
10//! the normal claim path, and rotates the retainer on every claim so a stale holder
11//! cannot settle. It reads no clock — time is injected into `claim` and `heartbeat`.
12
13#![forbid(unsafe_code)]
14#![warn(missing_docs)]
15
16use std::sync::Mutex;
17
18use pacta_contract::{Claim, Pact, Registry, Retainer, Timestamp};
19use uuid::Uuid;
20
21/// The error a memory registry returns when a retainer is not the current holder,
22/// or when a heartbeat arrives after its lease has already lapsed.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct NotHeld;
25
26impl std::fmt::Display for NotHeld {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "retainer is not the current holder of any claim")
29    }
30}
31
32impl std::error::Error for NotHeld {}
33
34enum State {
35    Available,
36    Held {
37        retainer: Uuid,
38        expiry: Timestamp,
39    },
40    /// Released, non-terminal: claimable again only at or after `reclaimable_at`.
41    Deferred {
42        reclaimable_at: Timestamp,
43    },
44    Settled,
45}
46
47struct Record {
48    pact: Pact,
49    state: State,
50}
51
52/// An in-memory registry seeded with a fixed set of pacts.
53pub struct MemoryRegistry {
54    records: Mutex<Vec<Record>>,
55    lease_millis: u64,
56}
57
58impl MemoryRegistry {
59    /// Create an empty registry that leases claims for `lease_millis`.
60    #[must_use]
61    pub fn new(lease_millis: u64) -> Self {
62        Self::seeded(Vec::new(), lease_millis)
63    }
64
65    /// Create a registry holding `pacts`, each available to claim, leasing claims
66    /// for `lease_millis`.
67    #[must_use]
68    pub fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
69        Self {
70            records: Mutex::new(
71                pacts
72                    .into_iter()
73                    .map(|pact| Record {
74                        pact,
75                        state: State::Available,
76                    })
77                    .collect(),
78            ),
79            lease_millis,
80        }
81    }
82
83    fn find_holder(records: &mut [Record], retainer: &Retainer) -> Option<usize> {
84        records.iter().position(|record| {
85            matches!(record.state, State::Held { retainer: held, .. } if held == retainer.id())
86        })
87    }
88}
89
90impl Registry for MemoryRegistry {
91    type Error = NotHeld;
92
93    fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error> {
94        let mut records = self
95            .records
96            .lock()
97            .expect("registry mutex should not be poisoned");
98        let claimable = records.iter().position(|record| {
99            if !dockets.contains(&record.pact.docket.as_str()) {
100                return false;
101            }
102            match record.state {
103                State::Available => true,
104                // A lapse: an expired hold is reclaimable through this claim path.
105                State::Held { expiry, .. } => expiry < now,
106                // A reclaim: a released pact is claimable once its instant has passed.
107                State::Deferred { reclaimable_at } => reclaimable_at <= now,
108                State::Settled => false,
109            }
110        });
111
112        // Mint a retainer only on a successful claim.
113        let Some(index) = claimable else {
114            return Ok(None);
115        };
116        let retainer = Retainer::new(Uuid::new_v4());
117        let expiry = now.plus_millis(self.lease_millis);
118        records[index].state = State::Held {
119            retainer: retainer.id(),
120            expiry,
121        };
122        Ok(Some(Claim::new(
123            records[index].pact.clone(),
124            retainer,
125            expiry,
126        )))
127    }
128
129    fn heartbeat(&self, retainer: &Retainer, now: Timestamp) -> Result<(), Self::Error> {
130        let mut records = self
131            .records
132            .lock()
133            .expect("registry mutex should not be poisoned");
134        let index = Self::find_holder(&mut records, retainer).ok_or(NotHeld)?;
135        let State::Held { expiry, .. } = records[index].state else {
136            return Err(NotHeld);
137        };
138        // Refuse to revive a lapsed lease: the holder must re-claim.
139        if expiry < now {
140            return Err(NotHeld);
141        }
142        records[index].state = State::Held {
143            retainer: retainer.id(),
144            expiry: now.plus_millis(self.lease_millis),
145        };
146        Ok(())
147    }
148
149    fn fulfill(&self, retainer: &Retainer) -> Result<(), Self::Error> {
150        self.settle(retainer)
151    }
152
153    fn breach(&self, retainer: &Retainer) -> Result<(), Self::Error> {
154        self.settle(retainer)
155    }
156
157    fn release(&self, retainer: &Retainer, reclaimable_at: Timestamp) -> Result<(), Self::Error> {
158        let mut records = self
159            .records
160            .lock()
161            .expect("registry mutex should not be poisoned");
162        // Only the current holder may release; a settled or non-held pact has no
163        // current holder, so this rejects settled-release and stale retainers alike —
164        // the same authority check as fulfill and breach.
165        let index = Self::find_holder(&mut records, retainer).ok_or(NotHeld)?;
166        // Non-terminal: drop the hold (rotating authority away from this retainer) and
167        // set the reclaimable instant. The core honors the injected instant; it computes
168        // no delay.
169        records[index].state = State::Deferred { reclaimable_at };
170        Ok(())
171    }
172}
173
174impl MemoryRegistry {
175    // fulfill and breach share the same authority check: a stale retainer no longer
176    // matches the rotated current holder, so no time is needed to reject it.
177    fn settle(&self, retainer: &Retainer) -> Result<(), NotHeld> {
178        let mut records = self
179            .records
180            .lock()
181            .expect("registry mutex should not be poisoned");
182        let index = Self::find_holder(&mut records, retainer).ok_or(NotHeld)?;
183        records[index].state = State::Settled;
184        Ok(())
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn passes_registry_conformance() {
194        pacta_conformance::run(MemoryRegistry::seeded);
195    }
196
197    fn a_pact() -> Pact {
198        Pact::new(Uuid::new_v4(), "d".to_string(), "k".to_string(), Vec::new())
199    }
200
201    #[test]
202    fn release_rejects_a_non_holder() {
203        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
204        registry
205            .claim(&["d"], Timestamp::from_millis(0))
206            .expect("claim should not error")
207            .expect("a pact should be claimable");
208        let stranger = Retainer::new(Uuid::new_v4());
209        assert_eq!(
210            registry.release(&stranger, Timestamp::from_millis(0)),
211            Err(NotHeld),
212            "release by a non-holder must be rejected, like fulfill and breach"
213        );
214    }
215
216    #[test]
217    fn a_settled_pact_cannot_be_released() {
218        let registry = MemoryRegistry::seeded(vec![a_pact()], 1000);
219        let claim = registry
220            .claim(&["d"], Timestamp::from_millis(0))
221            .expect("claim should not error")
222            .expect("a pact should be claimable");
223        registry
224            .fulfill(&claim.retainer)
225            .expect("fulfill should settle");
226        assert_eq!(
227            registry.release(&claim.retainer, Timestamp::from_millis(0)),
228            Err(NotHeld),
229            "a concluded obligation has no claim to relinquish"
230        );
231    }
232}