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        // Reuses the sync suite's own contention checks over the `BlockOn` adapter, rather than
346        // a second hand-rolled race-detection pair, for the same reason `run_async_with` reuses
347        // `run`: sync and async coverage cannot drift apart.
348        crate::settle_contention(&|pacts, lease| BlockOn {
349            registry: make(pacts, lease),
350            driver: SelfProgress,
351        });
352        crate::claim_contention(&|pacts, lease| BlockOn {
353            registry: make(pacts, lease),
354            driver: SelfProgress,
355        });
356    }
357
358    /// A reactor-backed fixture proving the runtime-compatible entry drives futures that a naive
359    /// poll loop cannot: each op first parks on a real timer (`tokio::time::sleep`) and completes
360    /// only when a real runtime advances it. It runs the whole shared scenario set through the
361    /// public [`run_async_with`] entry, on a current-thread Tokio runtime — establishing that a
362    /// backend needing an external reactor reuses the exact same scenarios without re-declaring
363    /// them, and that the entry forces no `Send` future (a current-thread runtime needs none).
364    #[cfg(test)]
365    mod reactor_fixture {
366        use super::*;
367        use core::time::Duration;
368        use pacta_contract::lifecycle::{self, State};
369        use std::sync::Mutex;
370        use uuid::Uuid;
371
372        #[derive(Debug, PartialEq, Eq)]
373        struct NotHeld;
374        impl core::fmt::Display for NotHeld {
375            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376                f.write_str("not held")
377            }
378        }
379        impl std::error::Error for NotHeld {}
380        impl From<lifecycle::NotCurrentHolder> for NotHeld {
381            fn from(_: lifecycle::NotCurrentHolder) -> Self {
382                NotHeld
383            }
384        }
385
386        /// An async backend whose every op parks on a real timer before touching its store, so it
387        /// makes no progress without a runtime driving the timer. Otherwise a faithful in-memory
388        /// backend: atomic claim, and `apply` that locates the record held by the retainer.
389        struct ReactorBacked {
390            records: Mutex<Vec<(Pact, State)>>,
391            lease_millis: u64,
392        }
393
394        impl ReactorBacked {
395            fn seeded(pacts: Vec<Pact>, lease_millis: u64) -> Self {
396                Self {
397                    records: Mutex::new(pacts.into_iter().map(|p| (p, State::Available)).collect()),
398                    lease_millis,
399                }
400            }
401
402            async fn park() {
403                // A real timer: pending until the runtime's time driver advances it. A no-op-waker
404                // poll loop would spin here forever, so this backend needs a real runtime.
405                tokio::time::sleep(Duration::from_millis(1)).await;
406            }
407        }
408
409        impl AsyncRegistry for ReactorBacked {
410            type Error = NotHeld;
411
412            async fn claim(
413                &self,
414                dockets: &[&str],
415                now: Timestamp,
416            ) -> Result<Option<Claim>, NotHeld> {
417                Self::park().await;
418                let mut records = self.records.lock().unwrap();
419                let Some(index) = records.iter().position(|(pact, state)| {
420                    dockets.contains(&pact.docket.as_str()) && lifecycle::is_claimable(state, now)
421                }) else {
422                    return Ok(None);
423                };
424                let retainer = Retainer::new(Uuid::new_v4());
425                records[index].1 = lifecycle::on_claim(&retainer, now, self.lease_millis);
426                let expiry = lifecycle::lease_expiry(now, self.lease_millis);
427                Ok(Some(Claim::new(records[index].0.clone(), retainer, expiry)))
428            }
429
430            fn lease_millis(&self) -> u64 {
431                self.lease_millis
432            }
433
434            async fn apply(
435                &self,
436                retainer: &Retainer,
437                transition: &Transition<'_>,
438            ) -> Result<(), NotHeld> {
439                Self::park().await;
440                let mut records = self.records.lock().unwrap();
441                let (_, state) = records
442                    .iter_mut()
443                    .find(|(_, state)| {
444                        matches!(state, State::Held { retainer: held, .. } if held == retainer)
445                    })
446                    .ok_or(NotHeld)?;
447                *state = transition(state)?;
448                Ok(())
449            }
450        }
451
452        #[derive(Clone, Copy)]
453        struct TokioDriver<'a>(&'a tokio::runtime::Runtime);
454        impl BlockingDriver for TokioDriver<'_> {
455            fn drive<F: Future>(&self, future: F) -> F::Output {
456                self.0.block_on(future)
457            }
458        }
459
460        #[test]
461        fn reactor_backed_backend_runs_the_suite_via_run_async_with() {
462            let runtime = tokio::runtime::Builder::new_current_thread()
463                .enable_time()
464                .build()
465                .expect("current-thread runtime with a timer");
466            // The same shared scenario set a ready-future backend runs — now driven on a real
467            // runtime, over a backend whose futures genuinely park pending a timer.
468            run_async_with(ReactorBacked::seeded, TokioDriver(&runtime));
469        }
470    }
471}
472
473#[cfg(feature = "async")]
474pub use async_runner::{
475    BlockingDriver, SelfProgress, run_async, run_async_contention, run_async_with,
476};
477
478fn no_available_pact_returns_none<R, F>(make: &F)
479where
480    R: Registry,
481    R::Error: Debug,
482    F: Fn(Vec<Pact>, u64) -> R,
483{
484    let registry = make(Vec::new(), LEASE_MILLIS);
485    assert!(
486        registry
487            .claim(&[DOCKET], at(0))
488            .expect("claim should not error")
489            .is_none(),
490        "an empty registry must yield no claim"
491    );
492}
493
494fn unrequested_docket_is_not_claimed<R, F>(make: &F)
495where
496    R: Registry,
497    R::Error: Debug,
498    F: Fn(Vec<Pact>, u64) -> R,
499{
500    let registry = make(vec![a_pact_on("other")], LEASE_MILLIS);
501    assert!(
502        registry
503            .claim(&[DOCKET], at(0))
504            .expect("claim should not error")
505            .is_none(),
506        "a pact on an unrequested docket must not be claimed"
507    );
508    assert!(
509        registry
510            .claim(&["other"], at(0))
511            .expect("claim should not error")
512            .is_some(),
513        "the same pact must be claimable from its own docket"
514    );
515}
516
517fn claim_returns_claim_with_lease<R, F>(make: &F)
518where
519    R: Registry,
520    R::Error: Debug,
521    F: Fn(Vec<Pact>, u64) -> R,
522{
523    let registry = make(vec![a_pact()], LEASE_MILLIS);
524    let claim = registry
525        .claim(&[DOCKET], at(100))
526        .expect("claim should not error")
527        .expect("a pact should be claimable");
528    assert_eq!(
529        claim.lease_expiry,
530        at(100 + LEASE_MILLIS),
531        "lease expiry must be now plus the lease duration"
532    );
533}
534
535fn held_pact_not_reclaimable_before_expiry<R, F>(make: &F)
536where
537    R: Registry,
538    R::Error: Debug,
539    F: Fn(Vec<Pact>, u64) -> R,
540{
541    let registry = make(vec![a_pact()], LEASE_MILLIS);
542    let _first = registry
543        .claim(&[DOCKET], at(0))
544        .expect("claim should not error")
545        .expect("a pact should be claimable");
546    assert!(
547        registry
548            .claim(&[DOCKET], at(500))
549            .expect("claim should not error")
550            .is_none(),
551        "a held pact must not be reclaimable before its lease expires"
552    );
553}
554
555fn expired_lease_lapses_and_reclaims_with_rotated_retainer<R, F>(make: &F)
556where
557    R: Registry,
558    R::Error: Debug,
559    F: Fn(Vec<Pact>, u64) -> R,
560{
561    let registry = make(vec![a_pact()], LEASE_MILLIS);
562    let first = registry
563        .claim(&[DOCKET], at(0))
564        .expect("claim should not error")
565        .expect("a pact should be claimable");
566    let second = registry
567        .claim(&[DOCKET], at(1500))
568        .expect("claim should not error")
569        .expect("an expired pact should be reclaimable through the claim path");
570    assert_ne!(
571        first.retainer.id(),
572        second.retainer.id(),
573        "reclaiming a lapsed pact must rotate the retainer"
574    );
575    assert_eq!(
576        second.lease_expiry,
577        at(1500 + LEASE_MILLIS),
578        "the reclaim must set a fresh lease"
579    );
580}
581
582fn stale_retainer_settle_rejected_after_reclaim<R, F>(make: &F)
583where
584    R: Registry,
585    R::Error: Debug,
586    F: Fn(Vec<Pact>, u64) -> R,
587{
588    let registry = make(vec![a_pact()], LEASE_MILLIS);
589    let first = registry
590        .claim(&[DOCKET], at(0))
591        .expect("claim should not error")
592        .expect("a pact should be claimable");
593    let _second = registry
594        .claim(&[DOCKET], at(1500))
595        .expect("claim should not error")
596        .expect("an expired pact should be reclaimable");
597    assert!(
598        registry.fulfill(&first.retainer).is_err(),
599        "the prior holder must not settle after a reclaim (at-least-once safety)"
600    );
601}
602
603fn late_fulfill_before_reclaim_succeeds<R, F>(make: &F)
604where
605    R: Registry,
606    R::Error: Debug,
607    F: Fn(Vec<Pact>, u64) -> R,
608{
609    let registry = make(vec![a_pact()], LEASE_MILLIS);
610    let claim = registry
611        .claim(&[DOCKET], at(0))
612        .expect("claim should not error")
613        .expect("a pact should be claimable");
614    // The lease has expired but nobody reclaimed; the holder's retainer still
615    // matches, so a late fulfill of genuinely-done work settles. No time involved.
616    assert!(
617        registry.fulfill(&claim.retainer).is_ok(),
618        "a late fulfill before any reclaim must settle"
619    );
620    assert!(
621        registry
622            .claim(&[DOCKET], at(9999))
623            .expect("claim should not error")
624            .is_none(),
625        "a settled pact must not be claimable"
626    );
627}
628
629fn fulfill_settles_and_pact_not_claimable<R, F>(make: &F)
630where
631    R: Registry,
632    R::Error: Debug,
633    F: Fn(Vec<Pact>, u64) -> R,
634{
635    let registry = make(vec![a_pact()], LEASE_MILLIS);
636    let claim = registry
637        .claim(&[DOCKET], at(0))
638        .expect("claim should not error")
639        .expect("a pact should be claimable");
640    registry
641        .fulfill(&claim.retainer)
642        .expect("fulfill should settle");
643    assert!(
644        registry
645            .claim(&[DOCKET], at(0))
646            .expect("claim should not error")
647            .is_none(),
648        "a fulfilled pact must not be claimable"
649    );
650}
651
652fn breach_settles_terminally<R, F>(make: &F)
653where
654    R: Registry,
655    R::Error: Debug,
656    F: Fn(Vec<Pact>, u64) -> R,
657{
658    let registry = make(vec![a_pact()], LEASE_MILLIS);
659    let claim = registry
660        .claim(&[DOCKET], at(0))
661        .expect("claim should not error")
662        .expect("a pact should be claimable");
663    registry
664        .breach(&claim.retainer)
665        .expect("breach should settle");
666    assert!(
667        registry
668            .claim(&[DOCKET], at(5000))
669            .expect("claim should not error")
670            .is_none(),
671        "a breached pact must not be claimable, even after its lease would have expired"
672    );
673}
674
675fn released_pact_withheld_until_reclaimable<R, F>(make: &F)
676where
677    R: Registry,
678    R::Error: Debug,
679    F: Fn(Vec<Pact>, u64) -> R,
680{
681    let registry = make(vec![a_pact()], LEASE_MILLIS);
682    let claim = registry
683        .claim(&[DOCKET], at(0))
684        .expect("claim should not error")
685        .expect("a pact should be claimable");
686    registry
687        .release(&claim.retainer, at(5000))
688        .expect("release should succeed for the current holder");
689    // at(3000) is past the original lease (1000) — a lapse would make it claimable —
690    // but the reclaimable instant (5000) is later, so release must withhold it.
691    assert!(
692        registry
693            .claim(&[DOCKET], at(3000))
694            .expect("claim should not error")
695            .is_none(),
696        "a released pact must not be claimable before its reclaimable instant"
697    );
698}
699
700fn released_pact_reclaimable_at_its_instant<R, F>(make: &F)
701where
702    R: Registry,
703    R::Error: Debug,
704    F: Fn(Vec<Pact>, u64) -> R,
705{
706    let registry = make(vec![a_pact()], LEASE_MILLIS);
707    let first = registry
708        .claim(&[DOCKET], at(0))
709        .expect("claim should not error")
710        .expect("a pact should be claimable");
711    registry
712        .release(&first.retainer, at(5000))
713        .expect("release should succeed");
714    let second = registry
715        .claim(&[DOCKET], at(5000))
716        .expect("claim should not error")
717        .expect("a released pact must be claimable at its reclaimable instant");
718    assert_ne!(
719        first.retainer.id(),
720        second.retainer.id(),
721        "reclaiming a released pact must rotate the retainer"
722    );
723}
724
725fn immediate_reclaim_reclaims_like_lapse<R, F>(make: &F)
726where
727    R: Registry,
728    R::Error: Debug,
729    F: Fn(Vec<Pact>, u64) -> R,
730{
731    let registry = make(vec![a_pact()], LEASE_MILLIS);
732    let claim = registry
733        .claim(&[DOCKET], at(0))
734        .expect("claim should not error")
735        .expect("a pact should be claimable");
736    registry
737        .release(&claim.retainer, at(0))
738        .expect("release with an immediate reclaim should succeed");
739    assert!(
740        registry
741            .claim(&[DOCKET], at(0))
742            .expect("claim should not error")
743            .is_some(),
744        "an immediate reclaim must make the pact claimable at once, as a voluntary lapse"
745    );
746}
747
748fn release_rotates_authority_from_prior_holder<R, F>(make: &F)
749where
750    R: Registry,
751    R::Error: Debug,
752    F: Fn(Vec<Pact>, u64) -> R,
753{
754    let registry = make(vec![a_pact()], LEASE_MILLIS);
755    let claim = registry
756        .claim(&[DOCKET], at(0))
757        .expect("claim should not error")
758        .expect("a pact should be claimable");
759    registry
760        .release(&claim.retainer, at(0))
761        .expect("release should succeed");
762    assert!(
763        registry.fulfill(&claim.retainer).is_err(),
764        "the prior holder must not settle after releasing (release rotates authority)"
765    );
766}
767
768fn heartbeat_extends_lease_preventing_lapse<R, F>(make: &F)
769where
770    R: Registry,
771    R::Error: Debug,
772    F: Fn(Vec<Pact>, u64) -> R,
773{
774    let registry = make(vec![a_pact()], LEASE_MILLIS);
775    let claim = registry
776        .claim(&[DOCKET], at(0))
777        .expect("claim should not error")
778        .expect("a pact should be claimable");
779    registry
780        .heartbeat(&claim.retainer, at(800))
781        .expect("an in-window heartbeat should extend the lease");
782    // The original lease (expiry 1000) would have lapsed by 1500, but the
783    // heartbeat pushed expiry to 1800, so the pact is still held.
784    assert!(
785        registry
786            .claim(&[DOCKET], at(1500))
787            .expect("claim should not error")
788            .is_none(),
789        "a heartbeat within the window must prevent a lapse"
790    );
791}
792
793fn heartbeat_at_expiry_boundary_succeeds<R, F>(make: &F)
794where
795    R: Registry,
796    R::Error: Debug,
797    F: Fn(Vec<Pact>, u64) -> R,
798{
799    let registry = make(vec![a_pact()], LEASE_MILLIS);
800    let claim = registry
801        .claim(&[DOCKET], at(0))
802        .expect("claim should not error")
803        .expect("a pact should be claimable");
804    // The lease expires at now(0) + LEASE_MILLIS. A heartbeat *exactly at* that instant is still
805    // in-window (the lease is valid up to and including its expiry: `expiry >= now`), so it must
806    // extend, not be rejected — only a strictly later heartbeat lapses.
807    registry
808        .heartbeat(&claim.retainer, at(LEASE_MILLIS))
809        .expect("a heartbeat at now == expiry must extend the lease, not be rejected");
810    // The boundary heartbeat pushed expiry to 2 * LEASE_MILLIS, so the pact is still held at 1.5x.
811    assert!(
812        registry
813            .claim(&[DOCKET], at(LEASE_MILLIS + LEASE_MILLIS / 2))
814            .expect("claim should not error")
815            .is_none(),
816        "a heartbeat at the expiry boundary must extend the lease and prevent a lapse"
817    );
818}
819
820fn heartbeat_on_lapsed_lease_rejected<R, F>(make: &F)
821where
822    R: Registry,
823    R::Error: Debug,
824    F: Fn(Vec<Pact>, u64) -> R,
825{
826    let registry = make(vec![a_pact()], LEASE_MILLIS);
827    let claim = registry
828        .claim(&[DOCKET], at(0))
829        .expect("claim should not error")
830        .expect("a pact should be claimable");
831    assert!(
832        registry.heartbeat(&claim.retainer, at(1200)).is_err(),
833        "a heartbeat after the lease expired must be rejected, forcing a re-claim"
834    );
835}
836
837fn heartbeat_unknown_retainer_rejected<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    let unknown = Retainer::new(Uuid::new_v4());
849    assert!(
850        registry.heartbeat(&unknown, at(100)).is_err(),
851        "a heartbeat with an unissued retainer must be rejected"
852    );
853}
854
855#[cfg(test)]
856mod contention_guard {
857    //! Proves the contention harness has teeth: a deterministically non-atomic backend must fail
858    //! [`run_contention`], and a matching atomic one must pass — so the gate is not vacuous.
859
860    use super::{Registry, claim_contention, run_contention, settle_contention};
861    use pacta_contract::lifecycle::{self, State};
862    use pacta_contract::{Claim, Pact, Retainer, Timestamp, Transition};
863    use std::sync::{Barrier, Mutex};
864    use uuid::Uuid;
865
866    #[derive(Debug, PartialEq, Eq)]
867    struct NotHeld;
868    impl std::fmt::Display for NotHeld {
869        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
870            f.write_str("not held")
871        }
872    }
873    impl std::error::Error for NotHeld {}
874    impl From<lifecycle::NotCurrentHolder> for NotHeld {
875        fn from(_: lifecycle::NotCurrentHolder) -> Self {
876            NotHeld
877        }
878    }
879
880    /// Which operation this fixture makes deliberately non-atomic. Each broken mode forces a
881    /// deterministic double-issue on its op via a two-party barrier, so the matching contention check
882    /// must catch it.
883    #[derive(Clone, Copy, PartialEq, Eq)]
884    enum Mode {
885        Atomic,
886        BrokenApply,
887        BrokenClaim,
888    }
889
890    /// A single-pact in-memory backend used three ways. `Atomic` is a correct one-lock-scope backend.
891    /// `BrokenApply` makes `apply` non-atomic: it loads, waits until both contending workers have
892    /// loaded the same pre-state, then stores by index unconditionally — a forced double-apply.
893    /// `BrokenClaim` makes `claim` non-atomic the same way: both workers see the pact available before
894    /// either marks it held, so both mint a claim — a forced double-issue.
895    struct TestBackend {
896        records: Mutex<Vec<(Pact, State)>>,
897        lease_millis: u64,
898        mode: Mode,
899        barrier: Option<Barrier>,
900    }
901
902    impl TestBackend {
903        fn build(pacts: Vec<Pact>, lease_millis: u64, mode: Mode) -> Self {
904            Self {
905                records: Mutex::new(pacts.into_iter().map(|p| (p, State::Available)).collect()),
906                lease_millis,
907                mode,
908                barrier: (mode != Mode::Atomic).then(|| Barrier::new(2)),
909            }
910        }
911
912        fn atomic(pacts: Vec<Pact>, lease_millis: u64) -> Self {
913            Self::build(pacts, lease_millis, Mode::Atomic)
914        }
915
916        fn broken_apply(pacts: Vec<Pact>, lease_millis: u64) -> Self {
917            Self::build(pacts, lease_millis, Mode::BrokenApply)
918        }
919
920        fn broken_claim(pacts: Vec<Pact>, lease_millis: u64) -> Self {
921            Self::build(pacts, lease_millis, Mode::BrokenClaim)
922        }
923
924        fn held_index(records: &[(Pact, State)], retainer: &Retainer) -> Option<usize> {
925            records
926                .iter()
927                .position(|(_, state)| matches!(state, State::Held { retainer: held, .. } if held == retainer))
928        }
929
930        fn available_index(
931            records: &[(Pact, State)],
932            dockets: &[&str],
933            now: Timestamp,
934        ) -> Option<usize> {
935            records.iter().position(|(pact, state)| {
936                dockets.contains(&pact.docket.as_str()) && lifecycle::is_claimable(state, now)
937            })
938        }
939    }
940
941    impl Registry for TestBackend {
942        type Error = NotHeld;
943
944        fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, NotHeld> {
945            let mint = |records: &mut Vec<(Pact, State)>, index: usize| {
946                let retainer = Retainer::new(Uuid::new_v4());
947                records[index].1 = lifecycle::on_claim(&retainer, now, self.lease_millis);
948                let expiry = lifecycle::lease_expiry(now, self.lease_millis);
949                Claim::new(records[index].0.clone(), retainer, expiry)
950            };
951            match &self.barrier {
952                // Non-atomic claim: both workers observe the pact available before either marks it
953                // held, so both mint — a forced double-issue.
954                Some(barrier) if self.mode == Mode::BrokenClaim => {
955                    let index = {
956                        let records = self.records.lock().unwrap();
957                        Self::available_index(&records, dockets, now)
958                    };
959                    let Some(index) = index else { return Ok(None) };
960                    barrier.wait();
961                    let mut records = self.records.lock().unwrap();
962                    Ok(Some(mint(&mut records, index)))
963                }
964                // Atomic claim (the default, and for the BrokenApply fixture whose claim is fine).
965                _ => {
966                    let mut records = self.records.lock().unwrap();
967                    let Some(index) = Self::available_index(&records, dockets, now) else {
968                        return Ok(None);
969                    };
970                    Ok(Some(mint(&mut records, index)))
971                }
972            }
973        }
974
975        fn lease_millis(&self) -> u64 {
976            self.lease_millis
977        }
978
979        fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), NotHeld> {
980            match &self.barrier {
981                // Non-atomic apply: load (release lock), let both workers reach the same pre-state,
982                // then store by the loaded index unconditionally — a forced double-apply.
983                Some(barrier) if self.mode == Mode::BrokenApply => {
984                    let (index, state) = {
985                        let records = self.records.lock().unwrap();
986                        let index = Self::held_index(&records, retainer).ok_or(NotHeld)?;
987                        (index, records[index].1.clone())
988                    };
989                    barrier.wait();
990                    let next = transition(&state)?;
991                    let mut records = self.records.lock().unwrap();
992                    records[index].1 = next;
993                    Ok(())
994                }
995                // Atomic apply: load, decide, and store within one lock scope.
996                _ => {
997                    let mut records = self.records.lock().unwrap();
998                    let index = Self::held_index(&records, retainer).ok_or(NotHeld)?;
999                    records[index].1 = transition(&records[index].1)?;
1000                    Ok(())
1001                }
1002            }
1003        }
1004    }
1005
1006    /// Run `body` with the panic backtrace suppressed (a deliberate failure would print one),
1007    /// returning whether it panicked.
1008    fn panicked(body: impl FnOnce() + std::panic::UnwindSafe) -> bool {
1009        let previous = std::panic::take_hook();
1010        std::panic::set_hook(Box::new(|_| {}));
1011        let result = std::panic::catch_unwind(body);
1012        std::panic::set_hook(previous);
1013        result.is_err()
1014    }
1015
1016    #[test]
1017    fn harness_catches_a_non_atomic_apply() {
1018        assert!(
1019            panicked(|| settle_contention(&TestBackend::broken_apply)),
1020            "the settlement-contention check must fail against a non-atomic apply"
1021        );
1022    }
1023
1024    #[test]
1025    fn harness_catches_a_non_atomic_claim() {
1026        assert!(
1027            panicked(|| claim_contention(&TestBackend::broken_claim)),
1028            "the claim-contention check must fail against a non-atomic claim (double-issue)"
1029        );
1030    }
1031
1032    #[test]
1033    fn harness_passes_a_fully_atomic_backend() {
1034        // The matching atomic backend passes both branches, so each guard above distinguishes teeth
1035        // from always-firing.
1036        run_contention(TestBackend::atomic);
1037    }
1038}