Skip to main content

car_sync/
lease.rs

1//! Execution lease + fencing (slice B5 of
2//! `docs/proposals/multi-device-sync.md`, §"Deep dive: the execution-lease /
3//! fencing protocol").
4//!
5//! **What this slice delivers: DETERMINISTIC LEDGER CONVERGENCE + a durable
6//! idempotency oracle — NOT exactly-once execution.** The exactly-once
7//! *execution* gate is B6's **dispatch fence**: a dispatch-time linearizable
8//! "am I still epoch N?" read plus a durable non-fenced idempotency read
9//! ([`crate::fold::SyncState::committed_run`]) **before** the external side
10//! effect. That is necessary, not an optimization; the fold below gates the
11//! *ledger*, not the side effect. Do not read "the fold fences the write" as
12//! "the fold makes execution safe" — it does not.
13//!
14//! Replicating *state* is a leaderless CRDT fold — safe with any number of
15//! concurrent writers (that is B1–B4). Performing *actions* is not: a tool
16//! call has an external side effect and is not idempotent, so if two sites
17//! both hold the agent and the same scheduled trigger fires, the email goes
18//! out twice. B5 adds the two mechanisms the proposal specs for that:
19//!
20//! 1. **A separate [`LeaseCoordinator`] (liveness).** The crisp principle,
21//!    from the proposal: *the lease is for liveness; only the fence is for
22//!    safety.* A lease demands **linearizable compare-and-swap** — exactly
23//!    one holder per agent at a time — which the eventually-consistent
24//!    [`crate::relay::Relay`] structurally **cannot** provide (an `FsRelay`
25//!    over a synced folder gives no consensus; two sites could each append "I
26//!    take it" and both believe they won). So the lease lives behind its
27//!    **own trait**, never bolted onto `Relay`. The proposal's data/control
28//!    split: the relay stays a dumb, E2E-ciphertext ordered log for *content*;
29//!    the lease register holds only non-sensitive metadata (`agent_id`,
30//!    `holder`, `epoch`, `expiry`), so a coordinator can serialize it in
31//!    cleartext without breaching E2E. [`InMemoryLeaseCoordinator`] is the
32//!    honest in-process reference — `Arc<Mutex>` CAS *is* genuinely
33//!    linearizable within one process (exactly as `InMemoryRelay` is the
34//!    honest single-process relay). A distributed backend (Cosmos `if_match`,
35//!    a single-writer daemon, Postgres advisory locks) is B6; the trait is its
36//!    contract.
37//!
38//! 2. **Fencing as a fold property, over two views.** The `epoch` a successful
39//!    acquire mints is the **monotone fencing token** (never reused). Since
40//!    `car-sync` is the state layer, B5 realizes fencing as a **deterministic
41//!    fold rule** over the leased [`crate::oplog::Surface::Intent`] surface,
42//!    yielding two per-agent views ([`crate::fold::IntentAgent`]):
43//!    - **`committed_runs` — the durable idempotency ORACLE.** Keep-all,
44//!      **fence-independent**: a commit is a permanent fact, recorded whatever
45//!      its epoch and never cleared by a later epoch or by compaction.
46//!      [`crate::fold::SyncState::committed_run`] is the correct "did this run
47//!      already execute?" lookup — the read a B6 dispatch fence performs before
48//!      any external write.
49//!    - **`runs` — the "who holds now" view.** Per-agent epoch fencing applies
50//!      to *pending* intents (a stale zombie holder's pending is fenced,
51//!      order-independently, without a wall-clock race — fencing beats HLC),
52//!      while committed/failed records are **terminal-immune** (never reverted,
53//!      never cleared). Read via [`crate::fold::SyncState::intent`]. This is
54//!      NOT the idempotency oracle — a fenced pending is absent here even when
55//!      the run committed under a prior epoch.
56//!
57//!    Two partitioned sites that both believe they hold the lease both write
58//!    intents; after convergence the ledger converges deterministically (the
59//!    higher-epoch pending wins `runs`; committed facts survive in
60//!    `committed_runs`).
61//!
62//! **Idempotency ties B7.** A scheduled/triggered run's id is content-derived
63//! (`car_proto::deterministic_run_id`), so two sites computing the same
64//! occurrence produce the **same `run_id`**. An [`Intent`] keys on that
65//! `run_id`, so a lease holder and a just-failed-over holder proposing the
66//! *same* logical run collapse to one committed-oracle record.
67//!
68//! **Honesty (the proposal's tier-3 residual).** For an external resource that
69//! doesn't understand CAR's tokens, exactly-once is impossible without that
70//! resource's cooperation. B5 does not close that: it converges the ledger and
71//! provides the durable committed-oracle read; the dispatch-time fence + the
72//! oracle-read-before-effect (B6) are what make execution single-shot in the
73//! common cases, with a documented bounded residual — not a false exactly-once
74//! claim, and NOT something the fold alone provides.
75
76use crate::oplog::WallClock;
77use serde::{Deserialize, Serialize};
78use serde_json::{json, Value};
79use std::collections::BTreeMap;
80use std::fmt;
81use std::sync::{Arc, Mutex};
82
83/// A granted lease — the proposal's `Lease { agent_id, holder, epoch,
84/// expires_at }`. `epoch` is the **fencing token**: strictly monotone per
85/// agent, never reused, minted on every successful [`LeaseCoordinator::acquire`].
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Lease {
88    pub agent_id: String,
89    /// The device that holds it.
90    pub holder: String,
91    /// Monotone fencing token (never reused). Threaded into every leased
92    /// [`Intent`] this holder writes; the fold fences anything below the
93    /// agent's max.
94    pub epoch: u64,
95    /// **Server-authoritative** expiry (coordinator wall-clock ms). Dodges
96    /// client skew — the proposal's requirement. Expiry only enables
97    /// *stealing*; the holder can [`LeaseCoordinator::renew`] until stolen.
98    pub expires_at_ms: u64,
99}
100
101/// A lease-coordination failure.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum LeaseError {
104    /// [`LeaseCoordinator::acquire`] found a still-valid lease held by
105    /// another (or the same) device — CAS failed. The proposal: "acquire
106    /// succeeds only if unheld or expired."
107    Held {
108        agent_id: String,
109        holder: String,
110        epoch: u64,
111        expires_at_ms: u64,
112    },
113    /// [`LeaseCoordinator::renew`]/[`LeaseCoordinator::release`] found the
114    /// caller is no longer the holder at the claimed epoch — someone stole
115    /// it (a higher epoch exists), or it was already released. The
116    /// fencing-relevant signal: a zombie that can reach the coordinator
117    /// learns here that it lost.
118    Lost {
119        agent_id: String,
120        /// The epoch the caller claimed to hold.
121        claimed_epoch: u64,
122        /// The coordinator's current epoch for the agent (0 if never held).
123        current_epoch: u64,
124    },
125}
126
127impl fmt::Display for LeaseError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            LeaseError::Held { agent_id, holder, epoch, expires_at_ms } => write!(
131                f,
132                "lease for {agent_id} is held by {holder} at epoch {epoch} (expires at \
133                 {expires_at_ms}ms) — acquire refused (unexpired)"
134            ),
135            LeaseError::Lost { agent_id, claimed_epoch, current_epoch } => write!(
136                f,
137                "lease for {agent_id} claimed at epoch {claimed_epoch} is lost (coordinator is at \
138                 epoch {current_epoch}) — you are no longer the holder"
139            ),
140        }
141    }
142}
143
144impl std::error::Error for LeaseError {}
145
146/// The lease-coordination contract — the **linearizable single-key register**
147/// per agent the proposal requires (Cosmos `if_match` / a single-writer
148/// daemon in B6). Distinct from [`crate::relay::Relay`] on purpose: `Relay` is
149/// eventually consistent and cannot host a lease.
150///
151/// All methods take `&mut self` to mirror `Relay` and leave room for a backend
152/// that mutates on read; the reference impl is `Clone` + `Send` + `Sync` so
153/// several device handles can share one linearizable register.
154///
155/// # Contract every implementation MUST hold
156///
157/// 1. **Linearizable single-key CAS.** [`Self::acquire`] must be a genuine
158///    compare-and-swap: given concurrent acquires on an unheld/expired agent,
159///    exactly one succeeds. An eventually-consistent store (a synced folder, an
160///    `FsRelay`-shaped backend) **cannot** provide this and must not back a
161///    coordinator — two sites would each "win" the same acquire, the exact
162///    split-brain the fence exists to bound.
163/// 2. **Epoch monotonicity that OUTLIVES the process.** The fencing epoch must
164///    never regress or repeat across restarts/failovers. This is load-bearing:
165///    a coordinator that restarts and re-issues a *lower* epoch would let a
166///    stale high-epoch write out-fence a fresh one. A durable backend persists
167///    the counter; a stateless one MUST seed each agent's epoch from
168///    **`max(max(epoch) over that agent's intent ops still in the oplog,
169///    checkpoint.state.intents[agent].fencing_epoch)`** before granting.
170///    **Both terms are required:** compaction (B4) truncates below-frontier
171///    intent ops out of the journal, so `max(oplog ops)` alone UNDER-seeds
172///    after a compaction — the checkpoint's persisted `fencing_epoch` is the
173///    high-water mark the truncated ops left behind ([`crate::fold::IntentAgent`]
174///    is carried keep-all in the checkpoint precisely so this seed survives).
175///    **[`InMemoryLeaseCoordinator`] does NOT persist** — it is the honest
176///    single-process reference (like `InMemoryRelay`), correct only while the
177///    process lives.
178/// 3. **Authority is the epoch, not the expiry.** `Lease::expires_at_ms` is a
179///    **liveness hint only** — it merely enables *stealing*. No consumer may
180///    read "expired" as license to act without holding the current epoch; the
181///    only authority check is "am I still the holder at `epoch`?" via
182///    [`Self::current`]/[`Self::renew`]. (The B6 dispatch fence reads the
183///    current epoch here, not wall-clock expiry.)
184pub trait LeaseCoordinator {
185    /// Compare-and-swap acquire. Grants **iff** the agent is unheld or its
186    /// lease has expired; on grant the epoch is bumped (`last_epoch + 1`,
187    /// monotone, never reused) and `expires_at_ms = now + ttl_ms`. A
188    /// still-valid lease (held by anyone, including the caller) returns
189    /// [`LeaseError::Held`] — the holder keeps it alive with [`Self::renew`].
190    fn acquire(
191        &mut self,
192        agent_id: &str,
193        device_id: &str,
194        ttl_ms: u64,
195    ) -> Result<Lease, LeaseError>;
196
197    /// Heartbeat: extend `expires_at_ms` **without** bumping the epoch. Ok iff
198    /// the caller is still the current holder at `epoch` (even slightly past
199    /// wall-clock expiry, as long as no one has stolen it — expiry only
200    /// enables stealing). Otherwise [`LeaseError::Lost`].
201    fn renew(
202        &mut self,
203        agent_id: &str,
204        device_id: &str,
205        epoch: u64,
206        ttl_ms: u64,
207    ) -> Result<Lease, LeaseError>;
208
209    /// Clean release (hook this to the supervisor's graceful stop): iff the
210    /// caller holds `epoch`, drop the holder so the next acquire grants
211    /// immediately with the next epoch — no TTL wait. Otherwise
212    /// [`LeaseError::Lost`]. The epoch counter is retained across a release so
213    /// epochs never repeat.
214    fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError>;
215
216    /// Linearizable read of the current lease (the tier-3 fence's "am I still
217    /// the holder at `epoch`?" check reads this). `None` when the agent is
218    /// unheld (never acquired, or cleanly released). Note an *expired* but
219    /// un-stolen lease still reports its holder — expiry alone doesn't clear
220    /// the register, only a steal or release does.
221    fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError>;
222}
223
224/// One agent's lease register cell.
225#[derive(Debug, Clone, Default)]
226struct Slot {
227    /// The last epoch minted for this agent — monotone, retained across
228    /// release so a re-acquire never reuses an epoch.
229    epoch: u64,
230    /// `Some` while held (until a steal or a release), `None` when unheld.
231    holder: Option<String>,
232    expires_at_ms: u64,
233}
234
235/// The honest in-process reference coordinator: a linearizable CAS register
236/// behind `Arc<Mutex>` (genuinely linearizable within one process — the
237/// mutex serializes every CAS), over an injected [`WallClock`] for
238/// server-authoritative expiry. Clone shares the same register, so two device
239/// handles contend over one linearizable cell — exactly the concurrency the
240/// exclusivity/fencing tests exercise.
241///
242/// **This cannot be backed by an eventually-consistent store.** A synced-folder
243/// / `FsRelay`-style backend gives no consensus, so two sites could each "win"
244/// the same acquire — the split-brain the whole design exists to prevent. The
245/// distributed coordinator (Cosmos `if_match`, single-writer daemon, etc.) is
246/// B6; it must supply *real* single-key linearizability, and this type is the
247/// contract it implements.
248#[derive(Clone)]
249pub struct InMemoryLeaseCoordinator {
250    slots: Arc<Mutex<BTreeMap<String, Slot>>>,
251    wall: WallClock,
252}
253
254impl fmt::Debug for InMemoryLeaseCoordinator {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        f.debug_struct("InMemoryLeaseCoordinator").finish_non_exhaustive()
257    }
258}
259
260impl InMemoryLeaseCoordinator {
261    pub fn new(wall: WallClock) -> Self {
262        Self { slots: Arc::new(Mutex::new(BTreeMap::new())), wall }
263    }
264
265    fn lease_of(agent_id: &str, slot: &Slot) -> Option<Lease> {
266        slot.holder.as_ref().map(|holder| Lease {
267            agent_id: agent_id.to_string(),
268            holder: holder.clone(),
269            epoch: slot.epoch,
270            expires_at_ms: slot.expires_at_ms,
271        })
272    }
273}
274
275impl LeaseCoordinator for InMemoryLeaseCoordinator {
276    fn acquire(
277        &mut self,
278        agent_id: &str,
279        device_id: &str,
280        ttl_ms: u64,
281    ) -> Result<Lease, LeaseError> {
282        let now = (self.wall)();
283        // The whole CAS runs under the lock — this is what makes acquire
284        // linearizable (the honest reference).
285        let mut slots = self.slots.lock().expect("lease mutex poisoned");
286        let slot = slots.entry(agent_id.to_string()).or_default();
287        let held = slot.holder.is_some() && now < slot.expires_at_ms;
288        if held {
289            return Err(LeaseError::Held {
290                agent_id: agent_id.to_string(),
291                holder: slot.holder.clone().expect("held ⇒ some holder"),
292                epoch: slot.epoch,
293                expires_at_ms: slot.expires_at_ms,
294            });
295        }
296        slot.epoch += 1; // monotone fencing token, never reused
297        slot.holder = Some(device_id.to_string());
298        slot.expires_at_ms = now.saturating_add(ttl_ms);
299        Ok(Self::lease_of(agent_id, slot).expect("just set holder"))
300    }
301
302    fn renew(
303        &mut self,
304        agent_id: &str,
305        device_id: &str,
306        epoch: u64,
307        ttl_ms: u64,
308    ) -> Result<Lease, LeaseError> {
309        let now = (self.wall)();
310        let mut slots = self.slots.lock().expect("lease mutex poisoned");
311        let slot = slots.entry(agent_id.to_string()).or_default();
312        let ours = slot.holder.as_deref() == Some(device_id) && slot.epoch == epoch;
313        if !ours {
314            return Err(LeaseError::Lost {
315                agent_id: agent_id.to_string(),
316                claimed_epoch: epoch,
317                current_epoch: slot.epoch,
318            });
319        }
320        // Renew regardless of wall-clock expiry: until someone steals (which
321        // bumps the epoch) the holder keeps it. No epoch bump.
322        slot.expires_at_ms = now.saturating_add(ttl_ms);
323        Ok(Self::lease_of(agent_id, slot).expect("ours ⇒ some holder"))
324    }
325
326    fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError> {
327        let mut slots = self.slots.lock().expect("lease mutex poisoned");
328        let slot = slots.entry(agent_id.to_string()).or_default();
329        let ours = slot.holder.as_deref() == Some(device_id) && slot.epoch == epoch;
330        if !ours {
331            return Err(LeaseError::Lost {
332                agent_id: agent_id.to_string(),
333                claimed_epoch: epoch,
334                current_epoch: slot.epoch,
335            });
336        }
337        slot.holder = None; // epoch retained → next acquire mints epoch+1
338        Ok(())
339    }
340
341    fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError> {
342        let slots = self.slots.lock().expect("lease mutex poisoned");
343        Ok(slots.get(agent_id).and_then(|slot| Self::lease_of(agent_id, slot)))
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Intent — the leased execution-intent surface's typed payload + the fold-side
349// field readers the deterministic fold uses to apply fencing.
350// ---------------------------------------------------------------------------
351
352/// Payload field names for [`crate::oplog::Surface::Intent`] ops. These are part of the
353/// serialized payload the fold reads; keep them stable.
354pub const INTENT_FIELD_ID: &str = "id";
355pub const INTENT_FIELD_AGENT: &str = "agent_id";
356pub const INTENT_FIELD_EPOCH: &str = "epoch";
357pub const INTENT_FIELD_STATUS: &str = "status";
358
359/// The monotone lifecycle of one execution intent: `pending` → `committed`
360/// | `failed`. Monotone means a terminal state (either) supersedes `pending`
361/// in the fold, and never regresses.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum IntentStatus {
365    Pending,
366    Committed,
367    Failed,
368}
369
370impl IntentStatus {
371    /// Monotone rank: `pending` (0) < terminal (`committed`/`failed`, 1). The
372    /// fold prefers the higher rank at a given epoch, so a `committed`/`failed`
373    /// always supersedes an earlier `pending` for the same run.
374    pub fn rank(self) -> u8 {
375        match self {
376            IntentStatus::Pending => 0,
377            IntentStatus::Committed | IntentStatus::Failed => 1,
378        }
379    }
380
381    fn as_str(self) -> &'static str {
382        match self {
383            IntentStatus::Pending => "pending",
384            IntentStatus::Committed => "committed",
385            IntentStatus::Failed => "failed",
386        }
387    }
388}
389
390/// A leased execution intent — the proposal's write-ahead ledger entry, folded
391/// under [`crate::oplog::FoldTier::Leased`]. `run_id` is the B7 deterministic run id (the
392/// idempotency key), `epoch` the lease's fencing token.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct Intent {
395    pub agent_id: String,
396    /// The B7 content-addressed run id — the fold's per-run stable key.
397    pub run_id: String,
398    /// The lease epoch this intent was written under — the fencing token.
399    pub epoch: u64,
400    pub status: IntentStatus,
401}
402
403impl Intent {
404    pub fn new(
405        agent_id: impl Into<String>,
406        run_id: impl Into<String>,
407        epoch: u64,
408        status: IntentStatus,
409    ) -> Self {
410        Self { agent_id: agent_id.into(), run_id: run_id.into(), epoch, status }
411    }
412
413    /// The op payload — `run_id` under `"id"` so the fold's stable key is
414    /// `id:<run_id>` (reusing the standard [`crate::oplog::OpRecord::stable_key`]
415    /// machinery), plus the fencing fields.
416    pub fn payload(&self) -> Value {
417        json!({
418            INTENT_FIELD_ID: self.run_id,
419            INTENT_FIELD_AGENT: self.agent_id,
420            INTENT_FIELD_EPOCH: self.epoch,
421            INTENT_FIELD_STATUS: self.status.as_str(),
422        })
423    }
424
425    /// Parse an [`crate::oplog::Surface::Intent`] payload back into an [`Intent`]; `None`
426    /// when a required field is missing or malformed.
427    pub fn from_payload(payload: &Value) -> Option<Self> {
428        let status = match payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str)? {
429            "pending" => IntentStatus::Pending,
430            "committed" => IntentStatus::Committed,
431            "failed" => IntentStatus::Failed,
432            _ => return None,
433        };
434        Some(Self {
435            agent_id: payload.get(INTENT_FIELD_AGENT).and_then(Value::as_str)?.to_string(),
436            run_id: payload.get(INTENT_FIELD_ID).and_then(Value::as_str)?.to_string(),
437            epoch: payload.get(INTENT_FIELD_EPOCH).and_then(Value::as_u64)?,
438            status,
439        })
440    }
441}
442
443/// The agent an intent op belongs to — the fencing group. Missing/malformed
444/// reads as `""` so the fold stays total (a well-formed [`Intent`] never
445/// omits it).
446pub fn intent_agent(payload: &Value) -> &str {
447    payload.get(INTENT_FIELD_AGENT).and_then(Value::as_str).unwrap_or("")
448}
449
450/// The fencing epoch an intent op carries. Missing/malformed reads as `0`
451/// (below any real lease epoch, which start at 1), so a malformed op is fenced
452/// rather than promoted.
453pub fn intent_epoch(payload: &Value) -> u64 {
454    payload.get(INTENT_FIELD_EPOCH).and_then(Value::as_u64).unwrap_or(0)
455}
456
457/// The monotone status rank of an intent op ([`IntentStatus::rank`]); an
458/// unknown/absent status reads as `pending` (0).
459pub fn intent_status_rank(payload: &Value) -> u8 {
460    match payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str) {
461        Some("committed") | Some("failed") => 1,
462        _ => 0,
463    }
464}
465
466/// Is this intent op **committed** specifically (not merely terminal)? Feeds
467/// the fence-independent idempotency oracle ([`crate::fold::IntentAgent::committed_runs`])
468/// — a `failed` run is terminal but NOT committed, so it may be retried.
469pub fn intent_is_committed(payload: &Value) -> bool {
470    payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str) == Some("committed")
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
477    use std::thread;
478
479    fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
480        let t = Arc::new(AtomicU64::new(0));
481        let reader = t.clone();
482        (t, Arc::new(move || reader.load(Ordering::SeqCst)))
483    }
484
485    #[test]
486    fn acquire_is_exclusive_and_bumps_epoch_never_reusing() {
487        let (_t, wall) = manual_clock(); // clock frozen at 0 → nothing expires
488        let mut coord = InMemoryLeaseCoordinator::new(wall);
489
490        // First acquire wins epoch 1.
491        let a = coord.acquire("agent-x", "dev-a", 100).unwrap();
492        assert_eq!((a.epoch, a.holder.as_str()), (1, "dev-a"));
493
494        // A second, concurrent acquire (clock frozen ⇒ not expired) loses —
495        // exactly one holder.
496        let err = coord.acquire("agent-x", "dev-b", 100).unwrap_err();
497        assert!(matches!(err, LeaseError::Held { epoch: 1, .. }), "got {err:?}");
498
499        // Clean release, then the next acquire mints epoch 2 — no wait, no
500        // reuse of epoch 1.
501        coord.release("agent-x", "dev-a", 1).unwrap();
502        assert_eq!(coord.current("agent-x").unwrap(), None, "released ⇒ unheld");
503        let b = coord.acquire("agent-x", "dev-b", 100).unwrap();
504        assert_eq!((b.epoch, b.holder.as_str()), (2, "dev-b"), "epoch is monotone, never reused");
505    }
506
507    #[test]
508    fn renew_and_release_require_the_current_holder_and_epoch() {
509        let (t, wall) = manual_clock();
510        let mut coord = InMemoryLeaseCoordinator::new(wall);
511        let lease = coord.acquire("a", "dev-a", 100).unwrap();
512        assert_eq!(lease.expires_at_ms, 100);
513
514        // Renew extends expiry, no epoch bump.
515        t.store(50, Ordering::SeqCst);
516        let renewed = coord.renew("a", "dev-a", 1, 100).unwrap();
517        assert_eq!((renewed.epoch, renewed.expires_at_ms), (1, 150));
518
519        // Wrong epoch, wrong holder → Lost, both ways.
520        assert!(matches!(
521            coord.renew("a", "dev-a", 99, 100),
522            Err(LeaseError::Lost { current_epoch: 1, .. })
523        ));
524        assert!(matches!(coord.renew("a", "dev-b", 1, 100), Err(LeaseError::Lost { .. })));
525        assert!(matches!(coord.release("a", "dev-b", 1), Err(LeaseError::Lost { .. })));
526
527        // Release by the real holder succeeds; a later renew is Lost.
528        coord.release("a", "dev-a", 1).unwrap();
529        assert!(matches!(coord.renew("a", "dev-a", 1, 100), Err(LeaseError::Lost { .. })));
530    }
531
532    #[test]
533    fn renew_works_past_expiry_until_actually_stolen() {
534        let (t, wall) = manual_clock();
535        let mut coord = InMemoryLeaseCoordinator::new(wall);
536        coord.acquire("a", "dev-a", 100).unwrap();
537
538        // Past wall-clock expiry but nobody stole it → renew still works
539        // (expiry only *enables* stealing; the proposal's Azure-blob-lease
540        // semantics).
541        t.store(500, Ordering::SeqCst);
542        assert!(coord.renew("a", "dev-a", 1, 100).is_ok(), "renew works until stolen");
543    }
544
545    #[test]
546    fn expired_lease_is_stealable_and_fences_the_old_epoch() {
547        let (t, wall) = manual_clock();
548        let mut coord = InMemoryLeaseCoordinator::new(wall);
549        let old = coord.acquire("a", "dev-a", 100).unwrap();
550        assert_eq!(old.epoch, 1);
551
552        // dev-a pauses past its TTL; dev-b steals — epoch bumps to 2.
553        t.store(200, Ordering::SeqCst);
554        let stolen = coord.acquire("a", "dev-b", 100).unwrap();
555        assert_eq!((stolen.epoch, stolen.holder.as_str()), (2, "dev-b"));
556
557        // The zombie dev-a, if it can reach the coordinator, learns it lost.
558        assert!(matches!(
559            coord.renew("a", "dev-a", 1, 100),
560            Err(LeaseError::Lost { claimed_epoch: 1, current_epoch: 2, .. })
561        ));
562        let cur = coord.current("a").unwrap().unwrap();
563        assert_eq!((cur.epoch, cur.holder.as_str()), (2, "dev-b"));
564    }
565
566    #[test]
567    fn reference_impl_is_linearizable_under_concurrent_contention() {
568        // Many threads sharing ONE register hammer acquire→work→release. The
569        // linearizable CAS must guarantee: at most one holder at any instant,
570        // and every granted epoch is unique and monotone (never reused).
571        let (_t, wall) = manual_clock(); // frozen ⇒ no expiry ⇒ every hold ends only by release
572        let coord = InMemoryLeaseCoordinator::new(wall);
573        let concurrent = Arc::new(AtomicUsize::new(0));
574        let max_concurrent = Arc::new(AtomicUsize::new(0));
575        let granted = Arc::new(Mutex::new(Vec::<u64>::new()));
576
577        let threads: Vec<_> = (0..8)
578            .map(|i| {
579                let mut coord = coord.clone();
580                let concurrent = concurrent.clone();
581                let max_concurrent = max_concurrent.clone();
582                let granted = granted.clone();
583                let device = format!("dev-{i}");
584                thread::spawn(move || {
585                    for _ in 0..50 {
586                        // Spin until we win the lease.
587                        let lease = loop {
588                            match coord.acquire("agent", &device, 1_000_000) {
589                                Ok(l) => break l,
590                                Err(LeaseError::Held { .. }) => thread::yield_now(),
591                                Err(e) => panic!("unexpected {e:?}"),
592                            }
593                        };
594                        // Critical section: assert we are the sole holder.
595                        let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
596                        max_concurrent.fetch_max(now, Ordering::SeqCst);
597                        granted.lock().unwrap().push(lease.epoch);
598                        // A little real overlap pressure.
599                        for _ in 0..20 {
600                            std::hint::spin_loop();
601                        }
602                        concurrent.fetch_sub(1, Ordering::SeqCst);
603                        coord.release("agent", &device, lease.epoch).unwrap();
604                    }
605                })
606            })
607            .collect();
608        for th in threads {
609            th.join().unwrap();
610        }
611
612        assert_eq!(
613            max_concurrent.load(Ordering::SeqCst),
614            1,
615            "the CAS is linearizable: never two holders at once"
616        );
617        // Every successful acquire bumped by exactly 1, so grants are exactly
618        // 1..=K with no repeats.
619        let mut epochs = granted.lock().unwrap().clone();
620        let count = epochs.len();
621        epochs.sort_unstable();
622        epochs.dedup();
623        assert_eq!(epochs.len(), count, "no epoch was ever reused");
624        assert_eq!(epochs, (1..=count as u64).collect::<Vec<_>>(), "epochs are dense and monotone");
625    }
626
627    #[test]
628    fn intent_payload_round_trips_and_ranks() {
629        let intent = Intent::new("milo", "run-abc", 3, IntentStatus::Committed);
630        let payload = intent.payload();
631        assert_eq!(payload[INTENT_FIELD_ID], json!("run-abc"), "run_id lives under id");
632        assert_eq!(Intent::from_payload(&payload), Some(intent));
633
634        // Fold-side readers.
635        assert_eq!(intent_agent(&payload), "milo");
636        assert_eq!(intent_epoch(&payload), 3);
637        assert_eq!(intent_status_rank(&payload), 1);
638        assert_eq!(
639            intent_status_rank(&Intent::new("m", "r", 1, IntentStatus::Pending).payload()),
640            0
641        );
642
643        // Monotone status ranking.
644        assert!(IntentStatus::Pending.rank() < IntentStatus::Committed.rank());
645        assert_eq!(IntentStatus::Committed.rank(), IntentStatus::Failed.rank());
646
647        // Missing fields → total, fenceable defaults (never a panic).
648        assert_eq!(intent_epoch(&json!({})), 0);
649        assert_eq!(intent_agent(&json!({})), "");
650        assert_eq!(Intent::from_payload(&json!({"id": "r"})), None);
651    }
652}