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