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}