Skip to main content

pacta_conformance/
lib.rs

1//! A backend-agnostic conformance suite for [`Registry`] implementations.
2//!
3//! The suite is generic over `Registry` and takes a constructor closure that
4//! returns a seeded backend, so it defines no seeding trait: a backend runs the
5//! suite from its own `#[cfg(test)]` module and keeps `pacta-conformance` a pure
6//! dev-dependency. Time is driven entirely through the trait by passing controlled
7//! [`Timestamp`] values, never a wall clock.
8
9#![forbid(unsafe_code)]
10#![warn(missing_docs)]
11
12use std::fmt::Debug;
13
14use pacta_contract::{Pact, Registry, Retainer, Timestamp};
15use uuid::Uuid;
16
17/// The lease duration, in milliseconds, the suite constructs every backend with.
18pub const LEASE_MILLIS: u64 = 1000;
19
20const DOCKET: &str = "conformance";
21
22fn at(millis: u64) -> Timestamp {
23    Timestamp::from_millis(millis)
24}
25
26fn a_pact_on(docket: &str) -> Pact {
27    Pact::new(
28        Uuid::new_v4(),
29        docket.to_string(),
30        "conformance".to_string(),
31        Vec::new(),
32    )
33}
34
35fn a_pact() -> Pact {
36    a_pact_on(DOCKET)
37}
38
39/// Run the full conformance suite against a backend built by `make`.
40///
41/// `make(pacts, lease_millis)` must return a fresh registry seeded with `pacts`
42/// and configured to lease claims for `lease_millis`. The suite calls it once per
43/// scenario. A failing assertion panics, failing the calling test. This sequential
44/// suite requires no `Send` or `Sync` bound on the backend; thread shareability is
45/// required separately by [`run_contention`].
46pub fn run<R, F>(make: F)
47where
48    R: Registry,
49    R::Error: Debug,
50    F: Fn(Vec<Pact>, u64) -> R,
51{
52    no_available_pact_returns_none(&make);
53    unrequested_docket_is_not_claimed(&make);
54    claim_returns_claim_with_lease(&make);
55    held_pact_not_reclaimable_before_expiry(&make);
56    expired_lease_lapses_and_reclaims_with_rotated_retainer(&make);
57    stale_retainer_settle_rejected_after_reclaim(&make);
58    late_fulfill_before_reclaim_succeeds(&make);
59    fulfill_settles_and_pact_not_claimable(&make);
60    breach_settles_terminally(&make);
61    released_pact_withheld_until_reclaimable(&make);
62    released_pact_reclaimable_at_its_instant(&make);
63    immediate_reclaim_reclaims_like_lapse(&make);
64    release_rotates_authority_from_prior_holder(&make);
65    heartbeat_extends_lease_preventing_lapse(&make);
66    heartbeat_at_expiry_boundary_succeeds(&make);
67    heartbeat_on_lapsed_lease_rejected(&make);
68    heartbeat_unknown_retainer_rejected(&make);
69}
70
71/// The number of rounds the contention checks repeat to surface a racing interleaving. This is a
72/// **probabilistic stress**, not a deterministic proof: an atomic backend passes every round, and a
73/// non-atomic one is overwhelmingly likely — but not guaranteed on any single round — to be caught
74/// here. The harness's *teeth* are proven deterministically by the barrier-synchronized broken
75/// fixture in this crate's tests, not by this repetition count.
76pub const CONTENTION_ROUNDS: usize = 2000;
77
78/// Verify a sync [`Registry`] backend upholds at-most-once authority under real concurrency: two
79/// workers contending a settlement on one claimed pact settle it at most once, and two workers
80/// contending a claim on one available pact issue at most one claim. Both are asserted through the
81/// public trait only — never by inspecting the backend's lock, transaction, or compare-and-set
82/// mechanism — so the check holds for any concurrency-control strategy. Concurrency is real OS
83/// threads; a failing assertion panics, failing the calling test.
84///
85/// This is the sync sibling of [`run_async_contention`]; the async binding has its own because a
86/// backend implements only one of the two bindings.
87///
88/// Unlike [`run`], this entry shares `Arc<R>` across OS threads and therefore requires
89/// `R: Send + Sync + 'static` explicitly.
90pub fn run_contention<R, F>(make: F)
91where
92    R: Registry + Send + Sync + 'static,
93    R::Error: Debug + Send,
94    F: Fn(Vec<Pact>, u64) -> R,
95{
96    settle_contention(&make);
97    claim_contention(&make);
98}
99
100/// Two workers race a settlement on one claimed pact: exactly one succeeds, the other resolves to a
101/// not-current-holder — the at-most-once `apply` invariant. Split out so the non-vacuity guard can
102/// prove this branch has teeth independently of the claim branch.
103fn settle_contention<R, F>(make: &F)
104where
105    R: Registry + Send + Sync + 'static,
106    R::Error: Debug + Send,
107    F: Fn(Vec<Pact>, u64) -> R,
108{
109    use std::sync::Arc;
110
111    for _ in 0..CONTENTION_ROUNDS {
112        let registry = Arc::new(make(vec![a_pact()], LEASE_MILLIS));
113        let retainer = registry
114            .claim(&[DOCKET], at(0))
115            .expect("claim should not error")
116            .expect("a pact should be claimable")
117            .retainer;
118
119        let a = {
120            let registry = Arc::clone(&registry);
121            let retainer = retainer.clone();
122            std::thread::spawn(move || registry.fulfill(&retainer))
123        };
124        let b = {
125            let registry = Arc::clone(&registry);
126            let retainer = retainer.clone();
127            std::thread::spawn(move || registry.fulfill(&retainer))
128        };
129        let (ra, rb) = (a.join().unwrap(), b.join().unwrap());
130
131        let winners = [ra.is_ok(), rb.is_ok()]
132            .into_iter()
133            .filter(|&ok| ok)
134            .count();
135        assert_eq!(
136            winners, 1,
137            "a settlement must apply exactly once under contention: a={ra:?} b={rb:?}"
138        );
139        assert!(
140            registry
141                .claim(&[DOCKET], at(0))
142                .expect("claim should not error")
143                .is_none(),
144            "a settled pact must not be claimable again"
145        );
146    }
147}
148
149/// Two workers race a claim on one available pact: exactly one gets a claim, never two — the
150/// at-most-one-issue `claim` invariant. Split out so the non-vacuity guard can prove this branch has
151/// teeth independently of the settlement branch.
152fn claim_contention<R, F>(make: &F)
153where
154    R: Registry + Send + Sync + 'static,
155    R::Error: Debug + Send,
156    F: Fn(Vec<Pact>, u64) -> R,
157{
158    use std::sync::Arc;
159
160    for _ in 0..CONTENTION_ROUNDS {
161        let registry = Arc::new(make(vec![a_pact()], LEASE_MILLIS));
162        let a = {
163            let registry = Arc::clone(&registry);
164            std::thread::spawn(move || {
165                registry
166                    .claim(&[DOCKET], at(0))
167                    .expect("claim should not error")
168                    .map(|claim| claim.retainer.id())
169            })
170        };
171        let b = {
172            let registry = Arc::clone(&registry);
173            std::thread::spawn(move || {
174                registry
175                    .claim(&[DOCKET], at(0))
176                    .expect("claim should not error")
177                    .map(|claim| claim.retainer.id())
178            })
179        };
180        let (ra, rb) = (a.join().unwrap(), b.join().unwrap());
181
182        let claims = [ra, rb].into_iter().flatten().collect::<Vec<_>>();
183        assert_eq!(
184            claims.len(),
185            1,
186            "exactly one worker must claim the single available pact: {claims:?}"
187        );
188    }
189}
190
191/// Async conformance: hold an [`AsyncRegistry`](pacta_contract::AsyncRegistry) backend to the
192/// exact same scenarios as the sync suite.
193///
194/// The async runner reuses [`run`] rather than a duplicated scenario set: it adapts the async
195/// backend into the sync [`Registry`] by driving each operation to completion with a
196/// [`BlockingDriver`], so sync and async coverage cannot drift. This proves state-machine parity —
197/// the same bar the sync suite meets, which itself exercises no concurrency. The at-most-once
198/// invariant under concurrent contention is a separate check (`run_async_contention`).
199///
200/// Two entries drive the one shared scenario set, differing only in the driver:
201/// [`run_async_with`] takes a caller-supplied [`BlockingDriver`], so a **real-reactor** backend runs
202/// the scenarios on its own runtime; [`run_async`] uses the built-in [`SelfProgress`] driver and is
203/// correct only for backends whose futures make progress without an external reactor. Neither
204/// requires the backend type or blocking driver to be `Send` or `Sync`, imposes a `Send` bound on
205/// the backend's futures, or pulls an async runtime into the crate. Thread shareability belongs to
206/// the separate [`run_async_contention`] entry.
207#[cfg(feature = "async")]
208mod async_runner {
209    use core::future::Future;
210
211    use pacta_contract::AsyncRegistry;
212    use pacta_contract::{Claim, Pact, Registry, Retainer, Timestamp, Transition};
213
214    /// Drives an async operation to completion — the seam by which a backend supplies its own
215    /// runtime without the conformance suite committing to one. The method is generic over the
216    /// future and imposes **no `Send` bound**, so future coloring stays the backend's; it is a
217    /// static bound (no `dyn`, no boxing), so the crate takes on no async-runtime dependency.
218    /// The driver type itself need not be `Send` or `Sync`; this sequential adapter never moves it
219    /// across threads.
220    ///
221    /// A real-reactor backend implements this over its runtime (for example a wrapper whose `drive`
222    /// calls `tokio::runtime::Runtime::block_on`) and passes it to [`run_async_with`]. A backend
223    /// whose futures are ready without a reactor uses the built-in [`SelfProgress`].
224    pub trait BlockingDriver {
225        /// Drive `future` to completion and return its output.
226        fn drive<F: Future>(&self, future: F) -> F::Output;
227    }
228
229    /// The built-in [`BlockingDriver`] for backends whose futures make progress **without** an
230    /// external reactor (a ready-future backend, such as the in-memory reference). It drives with a
231    /// no-op-waker poll loop and pulls no async runtime. It is **not** correct for a backend whose
232    /// futures park pending an external event (real I/O, a timer): such a backend must pass its own
233    /// runtime to [`run_async_with`] instead, or this driver will spin without ever completing.
234    #[derive(Clone, Copy, Debug, Default)]
235    pub struct SelfProgress;
236
237    impl BlockingDriver for SelfProgress {
238        fn drive<F: Future>(&self, future: F) -> F::Output {
239            block_on(future)
240        }
241    }
242
243    /// Drive a future to completion on the current thread with a no-op waker. Correct for futures
244    /// that make progress without an external reactor; keeps the crate dependency- and unsafe-free.
245    fn block_on<F: Future>(future: F) -> F::Output {
246        use core::task::{Context, Poll};
247
248        let mut future = core::pin::pin!(future);
249        let mut cx = Context::from_waker(core::task::Waker::noop());
250        loop {
251            match future.as_mut().poll(&mut cx) {
252                Poll::Ready(output) => return output,
253                Poll::Pending => core::hint::spin_loop(),
254            }
255        }
256    }
257
258    /// Adapts an [`AsyncRegistry`] into the sync [`Registry`] by driving each primitive to
259    /// completion through a [`BlockingDriver`], so the async binding runs the sync suite verbatim.
260    /// Because both bindings share one transition port, the adapter forwards only the primitives
261    /// (`claim`, `lease_millis`, `apply`); the four transition ops come from the sync trait's
262    /// default methods over `apply`.
263    struct BlockOn<R, D> {
264        registry: R,
265        driver: D,
266    }
267
268    impl<R: AsyncRegistry, D: BlockingDriver> Registry for BlockOn<R, D> {
269        type Error = R::Error;
270
271        fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error> {
272            self.driver.drive(self.registry.claim(dockets, now))
273        }
274
275        fn lease_millis(&self) -> u64 {
276            self.registry.lease_millis()
277        }
278
279        fn apply(
280            &self,
281            retainer: &Retainer,
282            transition: &Transition<'_>,
283        ) -> Result<(), Self::Error> {
284            self.driver.drive(self.registry.apply(retainer, transition))
285        }
286    }
287
288    /// Run the full conformance suite against an async backend built by `make`, driving its futures
289    /// with a caller-supplied `driver`. A real-reactor backend passes a driver wrapping its own
290    /// runtime, so the shared scenarios run on that runtime — no scenario is re-declared, and the
291    /// entry imposes no `Send` or `Sync` bound on the backend type or driver, no `Send` bound on
292    /// the backend's futures, and adds no async-runtime dependency.
293    ///
294    /// `make(pacts, lease_millis)` returns a fresh async registry seeded with `pacts`, exactly as
295    /// [`run`](crate::run) expects for the sync binding.
296    pub fn run_async_with<R, F, D>(make: F, driver: D)
297    where
298        R: AsyncRegistry,
299        R::Error: core::fmt::Debug,
300        F: Fn(Vec<Pact>, u64) -> R,
301        D: BlockingDriver + Copy,
302    {
303        crate::run(move |pacts, lease_millis| BlockOn {
304            registry: make(pacts, lease_millis),
305            driver,
306        });
307    }
308
309    /// Run the full conformance suite against a **ready-future** async backend built by `make`,
310    /// driving its futures with the built-in [`SelfProgress`] driver.
311    ///
312    /// This is a convenience over [`run_async_with`] for backends whose futures make progress
313    /// without an external reactor (the in-memory reference backend). A backend whose futures park
314    /// pending real I/O or a timer must use [`run_async_with`] with a driver over its own runtime;
315    /// `SelfProgress` would spin without completing such a future.
316    pub fn run_async<R, F>(make: F)
317    where
318        R: AsyncRegistry,
319        R::Error: core::fmt::Debug,
320        F: Fn(Vec<Pact>, u64) -> R,
321    {
322        run_async_with(make, SelfProgress);
323    }
324
325    /// Verify at-most-once authority under concurrent contention, for a ready-future async backend.
326    ///
327    /// Two checks, each through the public ops only (never inspecting the backend's concurrency
328    /// mechanism), so both hold for a lock, a transaction, or a compare-and-set backend alike:
329    /// two workers race a settlement on one claimed pact — exactly one succeeds; and two workers
330    /// race a claim on one available pact — exactly one gets a claim.
331    ///
332    /// Parallelism is real (OS threads); each thread drives its future to completion with
333    /// `block_on`, so a future never migrates across threads and **no `Send` bound on the future is
334    /// required** — the suite pulls no async runtime. Like [`run_async`], this convenience is for
335    /// ready-future backends; the repetition count is a **probabilistic stress**, not a
336    /// deterministic proof (the harness's teeth are proven by the barrier-synchronized broken
337    /// fixture in this crate's tests). Because this entry shares `Arc<R>` across those OS threads,
338    /// it requires `R: Send + Sync + 'static` explicitly; the sequential async entries do not.
339    pub fn run_async_contention<R, F>(make: F)
340    where
341        R: AsyncRegistry + Send + Sync + 'static,
342        R::Error: core::fmt::Debug + Send,
343        F: Fn(Vec<Pact>, u64) -> R,
344    {
345        use std::sync::Arc;
346
347        // Two workers race a settlement on one claimed pact: exactly one succeeds.
348        for _ in 0..crate::CONTENTION_ROUNDS {
349            let reg = Arc::new(make(vec![crate::a_pact()], crate::LEASE_MILLIS));
350            let retainer = block_on(reg.claim(&[crate::DOCKET], crate::at(0)))
351                .expect("claim should not error")
352                .expect("a pact should be claimable")
353                .retainer;
354
355            let a = {
356                let reg = Arc::clone(&reg);
357                let retainer = retainer.clone();
358                std::thread::spawn(move || block_on(reg.fulfill(&retainer)))
359            };
360            let b = {
361                let reg = Arc::clone(&reg);
362                let retainer = retainer.clone();
363                std::thread::spawn(move || block_on(reg.fulfill(&retainer)))
364            };
365            let (ra, rb) = (a.join().unwrap(), b.join().unwrap());
366
367            let winners = [ra.is_ok(), rb.is_ok()]
368                .into_iter()
369                .filter(|&ok| ok)
370                .count();
371            assert_eq!(
372                winners, 1,
373                "settlement must apply exactly once: a={ra:?} b={rb:?}"
374            );
375            assert!(
376                block_on(reg.claim(&[crate::DOCKET], crate::at(0)))
377                    .expect("claim should not error")
378                    .is_none(),
379                "a settled pact must not be claimable again"
380            );
381        }
382
383        // Two workers race a claim on one available pact: exactly one gets a claim, never two.
384        for _ in 0..crate::CONTENTION_ROUNDS {
385            let reg = Arc::new(make(vec![crate::a_pact()], crate::LEASE_MILLIS));
386            let a = {
387                let reg = Arc::clone(&reg);
388                std::thread::spawn(move || {
389                    block_on(reg.claim(&[crate::DOCKET], crate::at(0)))
390                        .expect("claim should not error")
391                        .map(|claim| claim.retainer.id())
392                })
393            };
394            let b = {
395                let reg = Arc::clone(&reg);
396                std::thread::spawn(move || {
397                    block_on(reg.claim(&[crate::DOCKET], crate::at(0)))
398                        .expect("claim should not error")
399                        .map(|claim| claim.retainer.id())
400                })
401            };
402            let (ra, rb) = (a.join().unwrap(), b.join().unwrap());
403
404            let claims = [ra, rb].into_iter().flatten().collect::<Vec<_>>();
405            assert_eq!(
406                claims.len(),
407                1,
408                "exactly one worker must claim the single available pact: {claims:?}"
409            );
410        }
411    }
412
413    /// A reactor-backed fixture proving the runtime-compatible entry drives futures that a naive
414    /// poll loop cannot: each op first parks on a real timer (`tokio::time::sleep`) and completes
415    /// only when a real runtime advances it. It runs the whole shared scenario set through the
416    /// public [`run_async_with`] entry, on a current-thread Tokio runtime — establishing that a
417    /// backend needing an external reactor reuses the exact same scenarios without re-declaring
418    /// them, and that the entry forces no `Send` future (a current-thread runtime needs none).
419    #[cfg(test)]
420    mod reactor_fixture {
421        use super::*;
422        use core::time::Duration;
423        use pacta_contract::lifecycle::{self, State};
424        use std::sync::Mutex;
425        use uuid::Uuid;
426
427        #[derive(Debug, PartialEq, Eq)]
428        struct NotHeld;
429        impl core::fmt::Display for NotHeld {
430            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
431                f.write_str("not held")
432            }
433        }
434        impl std::error::Error for NotHeld {}
435        impl From<lifecycle::NotCurrentHolder> for NotHeld {
436            fn from(_: lifecycle::NotCurrentHolder) -> Self {
437                NotHeld
438            }
439        }
440
441        /// An async backend whose every op parks on a real timer before touching its store, so it
442        /// makes no progress without a runtime driving the timer. Otherwise a faithful in-memory
443        /// backend: atomic claim, and `apply` that locates the record held by the retainer.
444        struct ReactorBacked {
445            records: Mutex<Vec<(Pact, State)>>,
446            lease_millis: u64,
447        }
448
449        impl ReactorBacked {
450            fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
451                Self {
452                    records: Mutex::new(pacts.into_iter().map(|p| (p, State::Available)).collect()),
453                    lease_millis,
454                }
455            }
456
457            async fn park() {
458                // A real timer: pending until the runtime's time driver advances it. A no-op-waker
459                // poll loop would spin here forever, so this backend needs a real runtime.
460                tokio::time::sleep(Duration::from_millis(1)).await;
461            }
462        }
463
464        impl AsyncRegistry for ReactorBacked {
465            type Error = NotHeld;
466
467            async fn claim(
468                &self,
469                dockets: &[&str],
470                now: Timestamp,
471            ) -> Result<Option<Claim>, NotHeld> {
472                Self::park().await;
473                let mut records = self.records.lock().unwrap();
474                let Some(index) = records.iter().position(|(pact, state)| {
475                    dockets.contains(&pact.docket.as_str()) && lifecycle::is_claimable(state, now)
476                }) else {
477                    return Ok(None);
478                };
479                let retainer = Retainer::new(Uuid::new_v4());
480                records[index].1 = lifecycle::on_claim(&retainer, now, self.lease_millis);
481                let expiry = lifecycle::lease_expiry(now, self.lease_millis);
482                Ok(Some(Claim::new(records[index].0.clone(), retainer, expiry)))
483            }
484
485            fn lease_millis(&self) -> u64 {
486                self.lease_millis
487            }
488
489            async fn apply(
490                &self,
491                retainer: &Retainer,
492                transition: &Transition<'_>,
493            ) -> Result<(), NotHeld> {
494                Self::park().await;
495                let mut records = self.records.lock().unwrap();
496                let (_, state) = records
497                    .iter_mut()
498                    .find(|(_, state)| {
499                        matches!(state, State::Held { retainer: held, .. } if held == retainer)
500                    })
501                    .ok_or(NotHeld)?;
502                *state = transition(state)?;
503                Ok(())
504            }
505        }
506
507        #[derive(Clone, Copy)]
508        struct TokioDriver<'a>(&'a tokio::runtime::Runtime);
509        impl BlockingDriver for TokioDriver<'_> {
510            fn drive<F: Future>(&self, future: F) -> F::Output {
511                self.0.block_on(future)
512            }
513        }
514
515        #[test]
516        fn reactor_backed_backend_runs_the_suite_via_run_async_with() {
517            let runtime = tokio::runtime::Builder::new_current_thread()
518                .enable_time()
519                .build()
520                .expect("current-thread runtime with a timer");
521            // The same shared scenario set a ready-future backend runs — now driven on a real
522            // runtime, over a backend whose futures genuinely park pending a timer.
523            run_async_with(ReactorBacked::seeded, TokioDriver(&runtime));
524        }
525    }
526}
527
528#[cfg(feature = "async")]
529pub use async_runner::{
530    BlockingDriver, SelfProgress, run_async, run_async_contention, run_async_with,
531};
532
533fn no_available_pact_returns_none<R, F>(make: &F)
534where
535    R: Registry,
536    R::Error: Debug,
537    F: Fn(Vec<Pact>, u64) -> R,
538{
539    let registry = make(Vec::new(), LEASE_MILLIS);
540    assert!(
541        registry
542            .claim(&[DOCKET], at(0))
543            .expect("claim should not error")
544            .is_none(),
545        "an empty registry must yield no claim"
546    );
547}
548
549fn unrequested_docket_is_not_claimed<R, F>(make: &F)
550where
551    R: Registry,
552    R::Error: Debug,
553    F: Fn(Vec<Pact>, u64) -> R,
554{
555    let registry = make(vec![a_pact_on("other")], LEASE_MILLIS);
556    assert!(
557        registry
558            .claim(&[DOCKET], at(0))
559            .expect("claim should not error")
560            .is_none(),
561        "a pact on an unrequested docket must not be claimed"
562    );
563    assert!(
564        registry
565            .claim(&["other"], at(0))
566            .expect("claim should not error")
567            .is_some(),
568        "the same pact must be claimable from its own docket"
569    );
570}
571
572fn claim_returns_claim_with_lease<R, F>(make: &F)
573where
574    R: Registry,
575    R::Error: Debug,
576    F: Fn(Vec<Pact>, u64) -> R,
577{
578    let registry = make(vec![a_pact()], LEASE_MILLIS);
579    let claim = registry
580        .claim(&[DOCKET], at(100))
581        .expect("claim should not error")
582        .expect("a pact should be claimable");
583    assert_eq!(
584        claim.lease_expiry,
585        at(100 + LEASE_MILLIS),
586        "lease expiry must be now plus the lease duration"
587    );
588}
589
590fn held_pact_not_reclaimable_before_expiry<R, F>(make: &F)
591where
592    R: Registry,
593    R::Error: Debug,
594    F: Fn(Vec<Pact>, u64) -> R,
595{
596    let registry = make(vec![a_pact()], LEASE_MILLIS);
597    let _first = registry
598        .claim(&[DOCKET], at(0))
599        .expect("claim should not error")
600        .expect("a pact should be claimable");
601    assert!(
602        registry
603            .claim(&[DOCKET], at(500))
604            .expect("claim should not error")
605            .is_none(),
606        "a held pact must not be reclaimable before its lease expires"
607    );
608}
609
610fn expired_lease_lapses_and_reclaims_with_rotated_retainer<R, F>(make: &F)
611where
612    R: Registry,
613    R::Error: Debug,
614    F: Fn(Vec<Pact>, u64) -> R,
615{
616    let registry = make(vec![a_pact()], LEASE_MILLIS);
617    let first = registry
618        .claim(&[DOCKET], at(0))
619        .expect("claim should not error")
620        .expect("a pact should be claimable");
621    let second = registry
622        .claim(&[DOCKET], at(1500))
623        .expect("claim should not error")
624        .expect("an expired pact should be reclaimable through the claim path");
625    assert_ne!(
626        first.retainer.id(),
627        second.retainer.id(),
628        "reclaiming a lapsed pact must rotate the retainer"
629    );
630    assert_eq!(
631        second.lease_expiry,
632        at(1500 + LEASE_MILLIS),
633        "the reclaim must set a fresh lease"
634    );
635}
636
637fn stale_retainer_settle_rejected_after_reclaim<R, F>(make: &F)
638where
639    R: Registry,
640    R::Error: Debug,
641    F: Fn(Vec<Pact>, u64) -> R,
642{
643    let registry = make(vec![a_pact()], LEASE_MILLIS);
644    let first = registry
645        .claim(&[DOCKET], at(0))
646        .expect("claim should not error")
647        .expect("a pact should be claimable");
648    let _second = registry
649        .claim(&[DOCKET], at(1500))
650        .expect("claim should not error")
651        .expect("an expired pact should be reclaimable");
652    assert!(
653        registry.fulfill(&first.retainer).is_err(),
654        "the prior holder must not settle after a reclaim (at-least-once safety)"
655    );
656}
657
658fn late_fulfill_before_reclaim_succeeds<R, F>(make: &F)
659where
660    R: Registry,
661    R::Error: Debug,
662    F: Fn(Vec<Pact>, u64) -> R,
663{
664    let registry = make(vec![a_pact()], LEASE_MILLIS);
665    let claim = registry
666        .claim(&[DOCKET], at(0))
667        .expect("claim should not error")
668        .expect("a pact should be claimable");
669    // The lease has expired but nobody reclaimed; the holder's retainer still
670    // matches, so a late fulfill of genuinely-done work settles. No time involved.
671    assert!(
672        registry.fulfill(&claim.retainer).is_ok(),
673        "a late fulfill before any reclaim must settle"
674    );
675    assert!(
676        registry
677            .claim(&[DOCKET], at(9999))
678            .expect("claim should not error")
679            .is_none(),
680        "a settled pact must not be claimable"
681    );
682}
683
684fn fulfill_settles_and_pact_not_claimable<R, F>(make: &F)
685where
686    R: Registry,
687    R::Error: Debug,
688    F: Fn(Vec<Pact>, u64) -> R,
689{
690    let registry = make(vec![a_pact()], LEASE_MILLIS);
691    let claim = registry
692        .claim(&[DOCKET], at(0))
693        .expect("claim should not error")
694        .expect("a pact should be claimable");
695    registry
696        .fulfill(&claim.retainer)
697        .expect("fulfill should settle");
698    assert!(
699        registry
700            .claim(&[DOCKET], at(0))
701            .expect("claim should not error")
702            .is_none(),
703        "a fulfilled pact must not be claimable"
704    );
705}
706
707fn breach_settles_terminally<R, F>(make: &F)
708where
709    R: Registry,
710    R::Error: Debug,
711    F: Fn(Vec<Pact>, u64) -> R,
712{
713    let registry = make(vec![a_pact()], LEASE_MILLIS);
714    let claim = registry
715        .claim(&[DOCKET], at(0))
716        .expect("claim should not error")
717        .expect("a pact should be claimable");
718    registry
719        .breach(&claim.retainer)
720        .expect("breach should settle");
721    assert!(
722        registry
723            .claim(&[DOCKET], at(5000))
724            .expect("claim should not error")
725            .is_none(),
726        "a breached pact must not be claimable, even after its lease would have expired"
727    );
728}
729
730fn released_pact_withheld_until_reclaimable<R, F>(make: &F)
731where
732    R: Registry,
733    R::Error: Debug,
734    F: Fn(Vec<Pact>, u64) -> R,
735{
736    let registry = make(vec![a_pact()], LEASE_MILLIS);
737    let claim = registry
738        .claim(&[DOCKET], at(0))
739        .expect("claim should not error")
740        .expect("a pact should be claimable");
741    registry
742        .release(&claim.retainer, at(5000))
743        .expect("release should succeed for the current holder");
744    // at(3000) is past the original lease (1000) — a lapse would make it claimable —
745    // but the reclaimable instant (5000) is later, so release must withhold it.
746    assert!(
747        registry
748            .claim(&[DOCKET], at(3000))
749            .expect("claim should not error")
750            .is_none(),
751        "a released pact must not be claimable before its reclaimable instant"
752    );
753}
754
755fn released_pact_reclaimable_at_its_instant<R, F>(make: &F)
756where
757    R: Registry,
758    R::Error: Debug,
759    F: Fn(Vec<Pact>, u64) -> R,
760{
761    let registry = make(vec![a_pact()], LEASE_MILLIS);
762    let first = registry
763        .claim(&[DOCKET], at(0))
764        .expect("claim should not error")
765        .expect("a pact should be claimable");
766    registry
767        .release(&first.retainer, at(5000))
768        .expect("release should succeed");
769    let second = registry
770        .claim(&[DOCKET], at(5000))
771        .expect("claim should not error")
772        .expect("a released pact must be claimable at its reclaimable instant");
773    assert_ne!(
774        first.retainer.id(),
775        second.retainer.id(),
776        "reclaiming a released pact must rotate the retainer"
777    );
778}
779
780fn immediate_reclaim_reclaims_like_lapse<R, F>(make: &F)
781where
782    R: Registry,
783    R::Error: Debug,
784    F: Fn(Vec<Pact>, u64) -> R,
785{
786    let registry = make(vec![a_pact()], LEASE_MILLIS);
787    let claim = registry
788        .claim(&[DOCKET], at(0))
789        .expect("claim should not error")
790        .expect("a pact should be claimable");
791    registry
792        .release(&claim.retainer, at(0))
793        .expect("release with an immediate reclaim should succeed");
794    assert!(
795        registry
796            .claim(&[DOCKET], at(0))
797            .expect("claim should not error")
798            .is_some(),
799        "an immediate reclaim must make the pact claimable at once, as a voluntary lapse"
800    );
801}
802
803fn release_rotates_authority_from_prior_holder<R, F>(make: &F)
804where
805    R: Registry,
806    R::Error: Debug,
807    F: Fn(Vec<Pact>, u64) -> R,
808{
809    let registry = make(vec![a_pact()], LEASE_MILLIS);
810    let claim = registry
811        .claim(&[DOCKET], at(0))
812        .expect("claim should not error")
813        .expect("a pact should be claimable");
814    registry
815        .release(&claim.retainer, at(0))
816        .expect("release should succeed");
817    assert!(
818        registry.fulfill(&claim.retainer).is_err(),
819        "the prior holder must not settle after releasing (release rotates authority)"
820    );
821}
822
823fn heartbeat_extends_lease_preventing_lapse<R, F>(make: &F)
824where
825    R: Registry,
826    R::Error: Debug,
827    F: Fn(Vec<Pact>, u64) -> R,
828{
829    let registry = make(vec![a_pact()], LEASE_MILLIS);
830    let claim = registry
831        .claim(&[DOCKET], at(0))
832        .expect("claim should not error")
833        .expect("a pact should be claimable");
834    registry
835        .heartbeat(&claim.retainer, at(800))
836        .expect("an in-window heartbeat should extend the lease");
837    // The original lease (expiry 1000) would have lapsed by 1500, but the
838    // heartbeat pushed expiry to 1800, so the pact is still held.
839    assert!(
840        registry
841            .claim(&[DOCKET], at(1500))
842            .expect("claim should not error")
843            .is_none(),
844        "a heartbeat within the window must prevent a lapse"
845    );
846}
847
848fn heartbeat_at_expiry_boundary_succeeds<R, F>(make: &F)
849where
850    R: Registry,
851    R::Error: Debug,
852    F: Fn(Vec<Pact>, u64) -> R,
853{
854    let registry = make(vec![a_pact()], LEASE_MILLIS);
855    let claim = registry
856        .claim(&[DOCKET], at(0))
857        .expect("claim should not error")
858        .expect("a pact should be claimable");
859    // The lease expires at now(0) + LEASE_MILLIS. A heartbeat *exactly at* that instant is still
860    // in-window (the lease is valid up to and including its expiry: `expiry >= now`), so it must
861    // extend, not be rejected — only a strictly later heartbeat lapses.
862    registry
863        .heartbeat(&claim.retainer, at(LEASE_MILLIS))
864        .expect("a heartbeat at now == expiry must extend the lease, not be rejected");
865    // The boundary heartbeat pushed expiry to 2 * LEASE_MILLIS, so the pact is still held at 1.5x.
866    assert!(
867        registry
868            .claim(&[DOCKET], at(LEASE_MILLIS + LEASE_MILLIS / 2))
869            .expect("claim should not error")
870            .is_none(),
871        "a heartbeat at the expiry boundary must extend the lease and prevent a lapse"
872    );
873}
874
875fn heartbeat_on_lapsed_lease_rejected<R, F>(make: &F)
876where
877    R: Registry,
878    R::Error: Debug,
879    F: Fn(Vec<Pact>, u64) -> R,
880{
881    let registry = make(vec![a_pact()], LEASE_MILLIS);
882    let claim = registry
883        .claim(&[DOCKET], at(0))
884        .expect("claim should not error")
885        .expect("a pact should be claimable");
886    assert!(
887        registry.heartbeat(&claim.retainer, at(1200)).is_err(),
888        "a heartbeat after the lease expired must be rejected, forcing a re-claim"
889    );
890}
891
892fn heartbeat_unknown_retainer_rejected<R, F>(make: &F)
893where
894    R: Registry,
895    R::Error: Debug,
896    F: Fn(Vec<Pact>, u64) -> R,
897{
898    let registry = make(vec![a_pact()], LEASE_MILLIS);
899    let _claim = registry
900        .claim(&[DOCKET], at(0))
901        .expect("claim should not error")
902        .expect("a pact should be claimable");
903    let unknown = Retainer::new(Uuid::new_v4());
904    assert!(
905        registry.heartbeat(&unknown, at(100)).is_err(),
906        "a heartbeat with an unissued retainer must be rejected"
907    );
908}
909
910#[cfg(test)]
911mod contention_guard {
912    //! Proves the contention harness has teeth: a deterministically non-atomic backend must fail
913    //! [`run_contention`], and a matching atomic one must pass — so the gate is not vacuous.
914
915    use super::{Registry, claim_contention, run_contention, settle_contention};
916    use pacta_contract::lifecycle::{self, State};
917    use pacta_contract::{Claim, Pact, Retainer, Timestamp, Transition};
918    use std::sync::{Barrier, Mutex};
919    use uuid::Uuid;
920
921    #[derive(Debug, PartialEq, Eq)]
922    struct NotHeld;
923    impl std::fmt::Display for NotHeld {
924        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
925            f.write_str("not held")
926        }
927    }
928    impl std::error::Error for NotHeld {}
929    impl From<lifecycle::NotCurrentHolder> for NotHeld {
930        fn from(_: lifecycle::NotCurrentHolder) -> Self {
931            NotHeld
932        }
933    }
934
935    /// Which operation this fixture makes deliberately non-atomic. Each broken mode forces a
936    /// deterministic double-issue on its op via a two-party barrier, so the matching contention check
937    /// must catch it.
938    #[derive(Clone, Copy, PartialEq, Eq)]
939    enum Mode {
940        Atomic,
941        BrokenApply,
942        BrokenClaim,
943    }
944
945    /// A single-pact in-memory backend used three ways. `Atomic` is a correct one-lock-scope backend.
946    /// `BrokenApply` makes `apply` non-atomic: it loads, waits until both contending workers have
947    /// loaded the same pre-state, then stores by index unconditionally — a forced double-apply.
948    /// `BrokenClaim` makes `claim` non-atomic the same way: both workers see the pact available before
949    /// either marks it held, so both mint a claim — a forced double-issue.
950    struct TestBackend {
951        records: Mutex<Vec<(Pact, State)>>,
952        lease_millis: u64,
953        mode: Mode,
954        barrier: Option<Barrier>,
955    }
956
957    impl TestBackend {
958        fn build(pacts: Vec<Pact>, lease_millis: u64, mode: Mode) -> Self {
959            Self {
960                records: Mutex::new(pacts.into_iter().map(|p| (p, State::Available)).collect()),
961                lease_millis,
962                mode,
963                barrier: (mode != Mode::Atomic).then(|| Barrier::new(2)),
964            }
965        }
966
967        fn atomic(pacts: Vec<Pact>, lease_millis: u64) -> Self {
968            Self::build(pacts, lease_millis, Mode::Atomic)
969        }
970
971        fn broken_apply(pacts: Vec<Pact>, lease_millis: u64) -> Self {
972            Self::build(pacts, lease_millis, Mode::BrokenApply)
973        }
974
975        fn broken_claim(pacts: Vec<Pact>, lease_millis: u64) -> Self {
976            Self::build(pacts, lease_millis, Mode::BrokenClaim)
977        }
978
979        fn held_index(records: &[(Pact, State)], retainer: &Retainer) -> Option<usize> {
980            records
981                .iter()
982                .position(|(_, state)| matches!(state, State::Held { retainer: held, .. } if held == retainer))
983        }
984
985        fn available_index(
986            records: &[(Pact, State)],
987            dockets: &[&str],
988            now: Timestamp,
989        ) -> Option<usize> {
990            records.iter().position(|(pact, state)| {
991                dockets.contains(&pact.docket.as_str()) && lifecycle::is_claimable(state, now)
992            })
993        }
994    }
995
996    impl Registry for TestBackend {
997        type Error = NotHeld;
998
999        fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, NotHeld> {
1000            let mint = |records: &mut Vec<(Pact, State)>, index: usize| {
1001                let retainer = Retainer::new(Uuid::new_v4());
1002                records[index].1 = lifecycle::on_claim(&retainer, now, self.lease_millis);
1003                let expiry = lifecycle::lease_expiry(now, self.lease_millis);
1004                Claim::new(records[index].0.clone(), retainer, expiry)
1005            };
1006            match &self.barrier {
1007                // Non-atomic claim: both workers observe the pact available before either marks it
1008                // held, so both mint — a forced double-issue.
1009                Some(barrier) if self.mode == Mode::BrokenClaim => {
1010                    let index = {
1011                        let records = self.records.lock().unwrap();
1012                        Self::available_index(&records, dockets, now)
1013                    };
1014                    let Some(index) = index else { return Ok(None) };
1015                    barrier.wait();
1016                    let mut records = self.records.lock().unwrap();
1017                    Ok(Some(mint(&mut records, index)))
1018                }
1019                // Atomic claim (the default, and for the BrokenApply fixture whose claim is fine).
1020                _ => {
1021                    let mut records = self.records.lock().unwrap();
1022                    let Some(index) = Self::available_index(&records, dockets, now) else {
1023                        return Ok(None);
1024                    };
1025                    Ok(Some(mint(&mut records, index)))
1026                }
1027            }
1028        }
1029
1030        fn lease_millis(&self) -> u64 {
1031            self.lease_millis
1032        }
1033
1034        fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), NotHeld> {
1035            match &self.barrier {
1036                // Non-atomic apply: load (release lock), let both workers reach the same pre-state,
1037                // then store by the loaded index unconditionally — a forced double-apply.
1038                Some(barrier) if self.mode == Mode::BrokenApply => {
1039                    let (index, state) = {
1040                        let records = self.records.lock().unwrap();
1041                        let index = Self::held_index(&records, retainer).ok_or(NotHeld)?;
1042                        (index, records[index].1.clone())
1043                    };
1044                    barrier.wait();
1045                    let next = transition(&state)?;
1046                    let mut records = self.records.lock().unwrap();
1047                    records[index].1 = next;
1048                    Ok(())
1049                }
1050                // Atomic apply: load, decide, and store within one lock scope.
1051                _ => {
1052                    let mut records = self.records.lock().unwrap();
1053                    let index = Self::held_index(&records, retainer).ok_or(NotHeld)?;
1054                    records[index].1 = transition(&records[index].1)?;
1055                    Ok(())
1056                }
1057            }
1058        }
1059    }
1060
1061    /// Run `body` with the panic backtrace suppressed (a deliberate failure would print one),
1062    /// returning whether it panicked.
1063    fn panicked(body: impl FnOnce() + std::panic::UnwindSafe) -> bool {
1064        let previous = std::panic::take_hook();
1065        std::panic::set_hook(Box::new(|_| {}));
1066        let result = std::panic::catch_unwind(body);
1067        std::panic::set_hook(previous);
1068        result.is_err()
1069    }
1070
1071    #[test]
1072    fn harness_catches_a_non_atomic_apply() {
1073        assert!(
1074            panicked(|| settle_contention(&TestBackend::broken_apply)),
1075            "the settlement-contention check must fail against a non-atomic apply"
1076        );
1077    }
1078
1079    #[test]
1080    fn harness_catches_a_non_atomic_claim() {
1081        assert!(
1082            panicked(|| claim_contention(&TestBackend::broken_claim)),
1083            "the claim-contention check must fail against a non-atomic claim (double-issue)"
1084        );
1085    }
1086
1087    #[test]
1088    fn harness_passes_a_fully_atomic_backend() {
1089        // The matching atomic backend passes both branches, so each guard above distinguishes teeth
1090        // from always-firing.
1091        run_contention(TestBackend::atomic);
1092    }
1093}