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    /// The coordinator backend itself failed — a *distributed* coordinator
126    /// (e.g. [`crate::net_relay::NetworkLeaseCoordinator`]) could not reach the
127    /// lease register or got an unusable reply. Distinct from `Held`/`Lost`,
128    /// which are legitimate CAS verdicts, not failures. The in-process
129    /// `InMemoryLeaseCoordinator` never returns this.
130    Backend(String),
131}
132
133impl fmt::Display for LeaseError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            LeaseError::Held {
137                agent_id,
138                holder,
139                epoch,
140                expires_at_ms,
141            } => write!(
142                f,
143                "lease for {agent_id} is held by {holder} at epoch {epoch} (expires at \
144                 {expires_at_ms}ms) — acquire refused (unexpired)"
145            ),
146            LeaseError::Lost {
147                agent_id,
148                claimed_epoch,
149                current_epoch,
150            } => write!(
151                f,
152                "lease for {agent_id} claimed at epoch {claimed_epoch} is lost (coordinator is at \
153                 epoch {current_epoch}) — you are no longer the holder"
154            ),
155            LeaseError::Backend(m) => write!(f, "lease coordinator backend error: {m}"),
156        }
157    }
158}
159
160impl std::error::Error for LeaseError {}
161
162/// The lease-coordination contract — the **linearizable single-key register**
163/// per agent the proposal requires (Cosmos `if_match` / a single-writer
164/// daemon in B6). Distinct from [`crate::relay::Relay`] on purpose: `Relay` is
165/// eventually consistent and cannot host a lease.
166///
167/// All methods take `&mut self` to mirror `Relay` and leave room for a backend
168/// that mutates on read; the reference impl is `Clone` + `Send` + `Sync` so
169/// several device handles can share one linearizable register.
170///
171/// # Contract every implementation MUST hold
172///
173/// 1. **Linearizable single-key CAS.** [`Self::acquire`] must be a genuine
174///    compare-and-swap: given concurrent acquires on an unheld/expired agent,
175///    exactly one succeeds. An eventually-consistent store (a synced folder, an
176///    `FsRelay`-shaped backend) **cannot** provide this and must not back a
177///    coordinator — two sites would each "win" the same acquire, the exact
178///    split-brain the fence exists to bound.
179/// 2. **Epoch monotonicity that OUTLIVES the process.** The fencing epoch must
180///    never regress or repeat across restarts/failovers. This is load-bearing:
181///    a coordinator that restarts and re-issues a *lower* epoch would let a
182///    stale high-epoch write out-fence a fresh one. A durable backend persists
183///    the counter; a stateless one MUST seed each agent's epoch from
184///    **`max(max(epoch) over that agent's intent ops still in the oplog,
185///    checkpoint.state.intents[agent].fencing_epoch)`** before granting.
186///    **Both terms are required:** compaction (B4) truncates below-frontier
187///    intent ops out of the journal, so `max(oplog ops)` alone UNDER-seeds
188///    after a compaction — the checkpoint's persisted `fencing_epoch` is the
189///    high-water mark the truncated ops left behind ([`crate::fold::IntentAgent`]
190///    is carried keep-all in the checkpoint precisely so this seed survives).
191///    **[`InMemoryLeaseCoordinator`] does NOT persist** — it is the honest
192///    single-process reference (like `InMemoryRelay`), correct only while the
193///    process lives.
194/// 3. **Authority is the epoch, not the expiry.** `Lease::expires_at_ms` is a
195///    **liveness hint only** — it merely enables *stealing*. No consumer may
196///    read "expired" as license to act without holding the current epoch; the
197///    only authority check is "am I still the holder at `epoch`?" via
198///    [`Self::current`]/[`Self::renew`]. (The B6 dispatch fence reads the
199///    current epoch here, not wall-clock expiry.)
200pub trait LeaseCoordinator {
201    /// Compare-and-swap acquire. Grants **iff** the agent is unheld or its
202    /// lease has expired; on grant the epoch is bumped (`last_epoch + 1`,
203    /// monotone, never reused) and `expires_at_ms = now + ttl_ms`. A
204    /// still-valid lease (held by anyone, including the caller) returns
205    /// [`LeaseError::Held`] — the holder keeps it alive with [`Self::renew`].
206    fn acquire(
207        &mut self,
208        agent_id: &str,
209        device_id: &str,
210        ttl_ms: u64,
211    ) -> Result<Lease, LeaseError>;
212
213    /// Heartbeat: extend `expires_at_ms` **without** bumping the epoch. Ok iff
214    /// the caller is still the current holder at `epoch` (even slightly past
215    /// wall-clock expiry, as long as no one has stolen it — expiry only
216    /// enables stealing). Otherwise [`LeaseError::Lost`].
217    fn renew(
218        &mut self,
219        agent_id: &str,
220        device_id: &str,
221        epoch: u64,
222        ttl_ms: u64,
223    ) -> Result<Lease, LeaseError>;
224
225    /// Clean release (hook this to the supervisor's graceful stop): iff the
226    /// caller holds `epoch`, drop the holder so the next acquire grants
227    /// immediately with the next epoch — no TTL wait. Otherwise
228    /// [`LeaseError::Lost`]. The epoch counter is retained across a release so
229    /// epochs never repeat.
230    fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError>;
231
232    /// Linearizable read of the current lease (the tier-3 fence's "am I still
233    /// the holder at `epoch`?" check reads this). `None` when the agent is
234    /// unheld (never acquired, or cleanly released). Note an *expired* but
235    /// un-stolen lease still reports its holder — expiry alone doesn't clear
236    /// the register, only a steal or release does.
237    fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError>;
238}
239
240/// One agent's lease register cell.
241#[derive(Debug, Clone, Default)]
242struct Slot {
243    /// The last epoch minted for this agent — monotone, retained across
244    /// release so a re-acquire never reuses an epoch.
245    epoch: u64,
246    /// `Some` while held (until a steal or a release), `None` when unheld.
247    holder: Option<String>,
248    expires_at_ms: u64,
249}
250
251/// The honest in-process reference coordinator: a linearizable CAS register
252/// behind `Arc<Mutex>` (genuinely linearizable within one process — the
253/// mutex serializes every CAS), over an injected [`WallClock`] for
254/// server-authoritative expiry. Clone shares the same register, so two device
255/// handles contend over one linearizable cell — exactly the concurrency the
256/// exclusivity/fencing tests exercise.
257///
258/// **This cannot be backed by an eventually-consistent store.** A synced-folder
259/// / `FsRelay`-style backend gives no consensus, so two sites could each "win"
260/// the same acquire — the split-brain the whole design exists to prevent. The
261/// distributed coordinator (Cosmos `if_match`, single-writer daemon, etc.) is
262/// B6; it must supply *real* single-key linearizability, and this type is the
263/// contract it implements.
264#[derive(Clone)]
265pub struct InMemoryLeaseCoordinator {
266    slots: Arc<Mutex<BTreeMap<String, Slot>>>,
267    wall: WallClock,
268}
269
270impl fmt::Debug for InMemoryLeaseCoordinator {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        f.debug_struct("InMemoryLeaseCoordinator")
273            .finish_non_exhaustive()
274    }
275}
276
277impl InMemoryLeaseCoordinator {
278    pub fn new(wall: WallClock) -> Self {
279        Self {
280            slots: Arc::new(Mutex::new(BTreeMap::new())),
281            wall,
282        }
283    }
284
285    fn lease_of(agent_id: &str, slot: &Slot) -> Option<Lease> {
286        slot.holder.as_ref().map(|holder| Lease {
287            agent_id: agent_id.to_string(),
288            holder: holder.clone(),
289            epoch: slot.epoch,
290            expires_at_ms: slot.expires_at_ms,
291        })
292    }
293}
294
295impl LeaseCoordinator for InMemoryLeaseCoordinator {
296    fn acquire(
297        &mut self,
298        agent_id: &str,
299        device_id: &str,
300        ttl_ms: u64,
301    ) -> Result<Lease, LeaseError> {
302        let now = (self.wall)();
303        // The whole CAS runs under the lock — this is what makes acquire
304        // linearizable (the honest reference).
305        let mut slots = self.slots.lock().expect("lease mutex poisoned");
306        let slot = slots.entry(agent_id.to_string()).or_default();
307        let held = slot.holder.is_some() && now < slot.expires_at_ms;
308        if held {
309            return Err(LeaseError::Held {
310                agent_id: agent_id.to_string(),
311                holder: slot.holder.clone().expect("held ⇒ some holder"),
312                epoch: slot.epoch,
313                expires_at_ms: slot.expires_at_ms,
314            });
315        }
316        slot.epoch += 1; // monotone fencing token, never reused
317        slot.holder = Some(device_id.to_string());
318        slot.expires_at_ms = now.saturating_add(ttl_ms);
319        Ok(Self::lease_of(agent_id, slot).expect("just set holder"))
320    }
321
322    fn renew(
323        &mut self,
324        agent_id: &str,
325        device_id: &str,
326        epoch: u64,
327        ttl_ms: u64,
328    ) -> Result<Lease, LeaseError> {
329        let now = (self.wall)();
330        let mut slots = self.slots.lock().expect("lease mutex poisoned");
331        let slot = slots.entry(agent_id.to_string()).or_default();
332        let ours = slot.holder.as_deref() == Some(device_id) && slot.epoch == epoch;
333        if !ours {
334            return Err(LeaseError::Lost {
335                agent_id: agent_id.to_string(),
336                claimed_epoch: epoch,
337                current_epoch: slot.epoch,
338            });
339        }
340        // Renew regardless of wall-clock expiry: until someone steals (which
341        // bumps the epoch) the holder keeps it. No epoch bump.
342        slot.expires_at_ms = now.saturating_add(ttl_ms);
343        Ok(Self::lease_of(agent_id, slot).expect("ours ⇒ some holder"))
344    }
345
346    fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError> {
347        let mut slots = self.slots.lock().expect("lease mutex poisoned");
348        let slot = slots.entry(agent_id.to_string()).or_default();
349        let ours = slot.holder.as_deref() == Some(device_id) && slot.epoch == epoch;
350        if !ours {
351            return Err(LeaseError::Lost {
352                agent_id: agent_id.to_string(),
353                claimed_epoch: epoch,
354                current_epoch: slot.epoch,
355            });
356        }
357        slot.holder = None; // epoch retained → next acquire mints epoch+1
358        Ok(())
359    }
360
361    fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError> {
362        let slots = self.slots.lock().expect("lease mutex poisoned");
363        Ok(slots
364            .get(agent_id)
365            .and_then(|slot| Self::lease_of(agent_id, slot)))
366    }
367}
368
369// ---------------------------------------------------------------------------
370// Intent — the leased execution-intent surface's typed payload + the fold-side
371// field readers the deterministic fold uses to apply fencing.
372// ---------------------------------------------------------------------------
373
374/// Payload field names for [`crate::oplog::Surface::Intent`] ops. These are part of the
375/// serialized payload the fold reads; keep them stable.
376pub const INTENT_FIELD_ID: &str = "id";
377pub const INTENT_FIELD_AGENT: &str = "agent_id";
378pub const INTENT_FIELD_EPOCH: &str = "epoch";
379pub const INTENT_FIELD_STATUS: &str = "status";
380
381/// The monotone lifecycle of one execution intent: `pending` → `committed`
382/// | `failed`. Monotone means a terminal state (either) supersedes `pending`
383/// in the fold, and never regresses.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum IntentStatus {
387    Pending,
388    Committed,
389    Failed,
390}
391
392impl IntentStatus {
393    /// Monotone rank: `pending` (0) < terminal (`committed`/`failed`, 1). The
394    /// fold prefers the higher rank at a given epoch, so a `committed`/`failed`
395    /// always supersedes an earlier `pending` for the same run.
396    pub fn rank(self) -> u8 {
397        match self {
398            IntentStatus::Pending => 0,
399            IntentStatus::Committed | IntentStatus::Failed => 1,
400        }
401    }
402
403    fn as_str(self) -> &'static str {
404        match self {
405            IntentStatus::Pending => "pending",
406            IntentStatus::Committed => "committed",
407            IntentStatus::Failed => "failed",
408        }
409    }
410}
411
412/// A leased execution intent — the proposal's write-ahead ledger entry, folded
413/// under [`crate::oplog::FoldTier::Leased`]. `run_id` is the B7 deterministic run id (the
414/// idempotency key), `epoch` the lease's fencing token.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct Intent {
417    pub agent_id: String,
418    /// The B7 content-addressed run id — the fold's per-run stable key.
419    pub run_id: String,
420    /// The lease epoch this intent was written under — the fencing token.
421    pub epoch: u64,
422    pub status: IntentStatus,
423}
424
425impl Intent {
426    pub fn new(
427        agent_id: impl Into<String>,
428        run_id: impl Into<String>,
429        epoch: u64,
430        status: IntentStatus,
431    ) -> Self {
432        Self {
433            agent_id: agent_id.into(),
434            run_id: run_id.into(),
435            epoch,
436            status,
437        }
438    }
439
440    /// The op payload — `run_id` under `"id"` so the fold's stable key is
441    /// `id:<run_id>` (reusing the standard [`crate::oplog::OpRecord::stable_key`]
442    /// machinery), plus the fencing fields.
443    pub fn payload(&self) -> Value {
444        json!({
445            INTENT_FIELD_ID: self.run_id,
446            INTENT_FIELD_AGENT: self.agent_id,
447            INTENT_FIELD_EPOCH: self.epoch,
448            INTENT_FIELD_STATUS: self.status.as_str(),
449        })
450    }
451
452    /// Parse an [`crate::oplog::Surface::Intent`] payload back into an [`Intent`]; `None`
453    /// when a required field is missing or malformed.
454    pub fn from_payload(payload: &Value) -> Option<Self> {
455        let status = match payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str)? {
456            "pending" => IntentStatus::Pending,
457            "committed" => IntentStatus::Committed,
458            "failed" => IntentStatus::Failed,
459            _ => return None,
460        };
461        Some(Self {
462            agent_id: payload
463                .get(INTENT_FIELD_AGENT)
464                .and_then(Value::as_str)?
465                .to_string(),
466            run_id: payload
467                .get(INTENT_FIELD_ID)
468                .and_then(Value::as_str)?
469                .to_string(),
470            epoch: payload.get(INTENT_FIELD_EPOCH).and_then(Value::as_u64)?,
471            status,
472        })
473    }
474}
475
476/// The agent an intent op belongs to — the fencing group. Missing/malformed
477/// reads as `""` so the fold stays total (a well-formed [`Intent`] never
478/// omits it).
479pub fn intent_agent(payload: &Value) -> &str {
480    payload
481        .get(INTENT_FIELD_AGENT)
482        .and_then(Value::as_str)
483        .unwrap_or("")
484}
485
486/// The fencing epoch an intent op carries. Missing/malformed reads as `0`
487/// (below any real lease epoch, which start at 1), so a malformed op is fenced
488/// rather than promoted.
489pub fn intent_epoch(payload: &Value) -> u64 {
490    payload
491        .get(INTENT_FIELD_EPOCH)
492        .and_then(Value::as_u64)
493        .unwrap_or(0)
494}
495
496/// The monotone status rank of an intent op ([`IntentStatus::rank`]); an
497/// unknown/absent status reads as `pending` (0).
498pub fn intent_status_rank(payload: &Value) -> u8 {
499    match payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str) {
500        Some("committed") | Some("failed") => 1,
501        _ => 0,
502    }
503}
504
505/// Is this intent op **committed** specifically (not merely terminal)? Feeds
506/// the fence-independent idempotency oracle ([`crate::fold::IntentAgent::committed_runs`])
507/// — a `failed` run is terminal but NOT committed, so it may be retried.
508pub fn intent_is_committed(payload: &Value) -> bool {
509    payload.get(INTENT_FIELD_STATUS).and_then(Value::as_str) == Some("committed")
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
516    use std::thread;
517
518    fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
519        let t = Arc::new(AtomicU64::new(0));
520        let reader = t.clone();
521        (t, Arc::new(move || reader.load(Ordering::SeqCst)))
522    }
523
524    #[test]
525    fn acquire_is_exclusive_and_bumps_epoch_never_reusing() {
526        let (_t, wall) = manual_clock(); // clock frozen at 0 → nothing expires
527        let mut coord = InMemoryLeaseCoordinator::new(wall);
528
529        // First acquire wins epoch 1.
530        let a = coord.acquire("agent-x", "dev-a", 100).unwrap();
531        assert_eq!((a.epoch, a.holder.as_str()), (1, "dev-a"));
532
533        // A second, concurrent acquire (clock frozen ⇒ not expired) loses —
534        // exactly one holder.
535        let err = coord.acquire("agent-x", "dev-b", 100).unwrap_err();
536        assert!(
537            matches!(err, LeaseError::Held { epoch: 1, .. }),
538            "got {err:?}"
539        );
540
541        // Clean release, then the next acquire mints epoch 2 — no wait, no
542        // reuse of epoch 1.
543        coord.release("agent-x", "dev-a", 1).unwrap();
544        assert_eq!(coord.current("agent-x").unwrap(), None, "released ⇒ unheld");
545        let b = coord.acquire("agent-x", "dev-b", 100).unwrap();
546        assert_eq!(
547            (b.epoch, b.holder.as_str()),
548            (2, "dev-b"),
549            "epoch is monotone, never reused"
550        );
551    }
552
553    #[test]
554    fn renew_and_release_require_the_current_holder_and_epoch() {
555        let (t, wall) = manual_clock();
556        let mut coord = InMemoryLeaseCoordinator::new(wall);
557        let lease = coord.acquire("a", "dev-a", 100).unwrap();
558        assert_eq!(lease.expires_at_ms, 100);
559
560        // Renew extends expiry, no epoch bump.
561        t.store(50, Ordering::SeqCst);
562        let renewed = coord.renew("a", "dev-a", 1, 100).unwrap();
563        assert_eq!((renewed.epoch, renewed.expires_at_ms), (1, 150));
564
565        // Wrong epoch, wrong holder → Lost, both ways.
566        assert!(matches!(
567            coord.renew("a", "dev-a", 99, 100),
568            Err(LeaseError::Lost {
569                current_epoch: 1,
570                ..
571            })
572        ));
573        assert!(matches!(
574            coord.renew("a", "dev-b", 1, 100),
575            Err(LeaseError::Lost { .. })
576        ));
577        assert!(matches!(
578            coord.release("a", "dev-b", 1),
579            Err(LeaseError::Lost { .. })
580        ));
581
582        // Release by the real holder succeeds; a later renew is Lost.
583        coord.release("a", "dev-a", 1).unwrap();
584        assert!(matches!(
585            coord.renew("a", "dev-a", 1, 100),
586            Err(LeaseError::Lost { .. })
587        ));
588    }
589
590    #[test]
591    fn renew_works_past_expiry_until_actually_stolen() {
592        let (t, wall) = manual_clock();
593        let mut coord = InMemoryLeaseCoordinator::new(wall);
594        coord.acquire("a", "dev-a", 100).unwrap();
595
596        // Past wall-clock expiry but nobody stole it → renew still works
597        // (expiry only *enables* stealing; the proposal's Azure-blob-lease
598        // semantics).
599        t.store(500, Ordering::SeqCst);
600        assert!(
601            coord.renew("a", "dev-a", 1, 100).is_ok(),
602            "renew works until stolen"
603        );
604    }
605
606    #[test]
607    fn expired_lease_is_stealable_and_fences_the_old_epoch() {
608        let (t, wall) = manual_clock();
609        let mut coord = InMemoryLeaseCoordinator::new(wall);
610        let old = coord.acquire("a", "dev-a", 100).unwrap();
611        assert_eq!(old.epoch, 1);
612
613        // dev-a pauses past its TTL; dev-b steals — epoch bumps to 2.
614        t.store(200, Ordering::SeqCst);
615        let stolen = coord.acquire("a", "dev-b", 100).unwrap();
616        assert_eq!((stolen.epoch, stolen.holder.as_str()), (2, "dev-b"));
617
618        // The zombie dev-a, if it can reach the coordinator, learns it lost.
619        assert!(matches!(
620            coord.renew("a", "dev-a", 1, 100),
621            Err(LeaseError::Lost {
622                claimed_epoch: 1,
623                current_epoch: 2,
624                ..
625            })
626        ));
627        let cur = coord.current("a").unwrap().unwrap();
628        assert_eq!((cur.epoch, cur.holder.as_str()), (2, "dev-b"));
629    }
630
631    #[test]
632    fn reference_impl_is_linearizable_under_concurrent_contention() {
633        // Many threads sharing ONE register hammer acquire→work→release. The
634        // linearizable CAS must guarantee: at most one holder at any instant,
635        // and every granted epoch is unique and monotone (never reused).
636        let (_t, wall) = manual_clock(); // frozen ⇒ no expiry ⇒ every hold ends only by release
637        let coord = InMemoryLeaseCoordinator::new(wall);
638        let concurrent = Arc::new(AtomicUsize::new(0));
639        let max_concurrent = Arc::new(AtomicUsize::new(0));
640        let granted = Arc::new(Mutex::new(Vec::<u64>::new()));
641
642        let threads: Vec<_> = (0..8)
643            .map(|i| {
644                let mut coord = coord.clone();
645                let concurrent = concurrent.clone();
646                let max_concurrent = max_concurrent.clone();
647                let granted = granted.clone();
648                let device = format!("dev-{i}");
649                thread::spawn(move || {
650                    for _ in 0..50 {
651                        // Spin until we win the lease.
652                        let lease = loop {
653                            match coord.acquire("agent", &device, 1_000_000) {
654                                Ok(l) => break l,
655                                Err(LeaseError::Held { .. }) => thread::yield_now(),
656                                Err(e) => panic!("unexpected {e:?}"),
657                            }
658                        };
659                        // Critical section: assert we are the sole holder.
660                        let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
661                        max_concurrent.fetch_max(now, Ordering::SeqCst);
662                        granted.lock().unwrap().push(lease.epoch);
663                        // A little real overlap pressure.
664                        for _ in 0..20 {
665                            std::hint::spin_loop();
666                        }
667                        concurrent.fetch_sub(1, Ordering::SeqCst);
668                        coord.release("agent", &device, lease.epoch).unwrap();
669                    }
670                })
671            })
672            .collect();
673        for th in threads {
674            th.join().unwrap();
675        }
676
677        assert_eq!(
678            max_concurrent.load(Ordering::SeqCst),
679            1,
680            "the CAS is linearizable: never two holders at once"
681        );
682        // Every successful acquire bumped by exactly 1, so grants are exactly
683        // 1..=K with no repeats.
684        let mut epochs = granted.lock().unwrap().clone();
685        let count = epochs.len();
686        epochs.sort_unstable();
687        epochs.dedup();
688        assert_eq!(epochs.len(), count, "no epoch was ever reused");
689        assert_eq!(
690            epochs,
691            (1..=count as u64).collect::<Vec<_>>(),
692            "epochs are dense and monotone"
693        );
694    }
695
696    #[test]
697    fn intent_payload_round_trips_and_ranks() {
698        let intent = Intent::new("milo", "run-abc", 3, IntentStatus::Committed);
699        let payload = intent.payload();
700        assert_eq!(
701            payload[INTENT_FIELD_ID],
702            json!("run-abc"),
703            "run_id lives under id"
704        );
705        assert_eq!(Intent::from_payload(&payload), Some(intent));
706
707        // Fold-side readers.
708        assert_eq!(intent_agent(&payload), "milo");
709        assert_eq!(intent_epoch(&payload), 3);
710        assert_eq!(intent_status_rank(&payload), 1);
711        assert_eq!(
712            intent_status_rank(&Intent::new("m", "r", 1, IntentStatus::Pending).payload()),
713            0
714        );
715
716        // Monotone status ranking.
717        assert!(IntentStatus::Pending.rank() < IntentStatus::Committed.rank());
718        assert_eq!(IntentStatus::Committed.rank(), IntentStatus::Failed.rank());
719
720        // Missing fields → total, fenceable defaults (never a panic).
721        assert_eq!(intent_epoch(&json!({})), 0);
722        assert_eq!(intent_agent(&json!({})), "");
723        assert_eq!(Intent::from_payload(&json!({"id": "r"})), None);
724    }
725}