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}