Skip to main content

laminar_core/cluster/control/
process_lease.rs

1//! Durable ownership of one stable cluster node identity.
2//!
3//! Each renewal appends a create-only sequence object. This gives local filesystems and object
4//! stores the same compare-and-set boundary without relying on backend-specific entity tags.
5
6use std::collections::BinaryHeap;
7use std::future::Future;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use bytes::Bytes;
13use futures::StreamExt;
14use object_store::path::Path as OsPath;
15use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload};
16use serde::{Deserialize, Serialize};
17use tokio::sync::watch;
18use uuid::Uuid;
19
20use crate::cluster::discovery::NodeId;
21
22use super::lease_deadline::LeaseDeadline;
23
24const MAX_PROCESS_LEASE_RECORD_BYTES: u64 = 1024;
25const MAX_PROCESS_LEASE_FENCE_BYTES: u64 = 2048;
26const PROCESS_LEASE_HEAD_READ_ATTEMPTS: usize = 4;
27const PROCESS_LEASE_HISTORY_TO_RETAIN: usize = 2;
28const PROCESS_LEASE_MAX_LIST_RECORDS: usize = 4096;
29const PROCESS_LEASE_PRUNE_BATCH_RECORDS: usize = 256;
30const PROCESS_LEASE_PRUNE_READ_CONCURRENCY: usize = 32;
31const PROCESS_LEASE_WRITES_PER_PRUNE: u64 = 64;
32const PROCESS_LEASE_MAX_PRUNE_BATCHES: usize = 4;
33const PROCESS_LEASE_PRUNE_IO_TIMEOUT: Duration = Duration::from_secs(5);
34
35fn lease_prefix(node: NodeId) -> String {
36    format!("control/process-lease/node={}/", node.0)
37}
38
39fn lease_path(node: NodeId, seq: u64) -> OsPath {
40    OsPath::from(format!("{}v{seq:016}.json", lease_prefix(node)))
41}
42
43fn fence_path(node: NodeId, predecessor: Uuid) -> OsPath {
44    OsPath::from(format!(
45        "control/process-lease-fences/v1/node={}/predecessor={predecessor}.json",
46        node.0
47    ))
48}
49
50fn successor_fence_path(node: NodeId, successor: Uuid, term: u64) -> OsPath {
51    OsPath::from(format!(
52        "control/process-lease-fences/v1/node={}/successor={successor}/term={term:016}.json",
53        node.0
54    ))
55}
56
57fn sequence_from_path(node: NodeId, path: &OsPath) -> Result<u64, ProcessLeaseError> {
58    let prefix = lease_prefix(node);
59    let raw = path
60        .as_ref()
61        .strip_prefix(&prefix)
62        .and_then(|file| file.strip_prefix('v'))
63        .and_then(|file| file.strip_suffix(".json"))
64        .ok_or_else(|| {
65            ProcessLeaseError::Invalid(format!("invalid process lease record path {path}"))
66        })?;
67    if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
68        return Err(ProcessLeaseError::Invalid(format!(
69            "invalid process lease sequence in {path}"
70        )));
71    }
72    let sequence = raw.parse::<u64>().map_err(|error| {
73        ProcessLeaseError::Invalid(format!("invalid process lease sequence in {path}: {error}"))
74    })?;
75    if sequence == 0 || lease_path(node, sequence) != *path {
76        return Err(ProcessLeaseError::Invalid(format!(
77            "noncanonical process lease record path {path}"
78        )));
79    }
80    Ok(sequence)
81}
82
83fn now_millis() -> i64 {
84    std::time::SystemTime::now()
85        .duration_since(std::time::UNIX_EPOCH)
86        .map_or(0, |duration| {
87            i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
88        })
89}
90
91/// Durable owner of one stable node identity.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ProcessLease {
94    /// Stable node identity protected by this lease.
95    pub node: NodeId,
96    /// Boot-unique owner identity.
97    pub owner: Uuid,
98    /// Monotonic process term. It advances after every lease lapse.
99    pub term: u64,
100    /// Append-only compare-and-set sequence.
101    pub seq: u64,
102    /// Owner-written advisory expiry for diagnostics; takeover never compares client clocks.
103    pub expires_at_ms: i64,
104}
105
106impl ProcessLease {
107    pub(crate) fn validate(&self, expected_node: NodeId) -> Result<(), ProcessLeaseError> {
108        if self.node != expected_node || self.node.is_unassigned() {
109            return Err(ProcessLeaseError::Invalid(
110                "lease node does not match its durable namespace".into(),
111            ));
112        }
113        if self.owner.is_nil() || self.term == 0 || self.seq == 0 {
114            return Err(ProcessLeaseError::Invalid(
115                "lease owner, term, and sequence must be nonzero".into(),
116            ));
117        }
118        Ok(())
119    }
120}
121
122/// Durable proof that one exact stable-node process term was superseded after a full lease
123/// observation. The successor is the immediate create-only takeover record, not a wall-clock
124/// expiry estimate.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ProcessLeaseFence {
128    /// Last lease record owned by the process being fenced.
129    pub predecessor: ProcessLease,
130    /// Immediate different-owner term that revoked the predecessor.
131    pub successor: ProcessLease,
132}
133
134impl ProcessLeaseFence {
135    /// Build an exact process-term transition.
136    ///
137    /// # Errors
138    /// Rejects different node namespaces, same-owner renewals, or a non-adjacent sequence/term.
139    pub fn new(
140        predecessor: ProcessLease,
141        successor: ProcessLease,
142    ) -> Result<Self, ProcessLeaseError> {
143        let fence = Self {
144            predecessor,
145            successor,
146        };
147        if !fence.is_canonical() {
148            return Err(ProcessLeaseError::Invalid(
149                "process lease fence must bind an immediate different-owner takeover".into(),
150            ));
151        }
152        Ok(fence)
153    }
154
155    /// Whether this is one exact create-only owner transition.
156    #[must_use]
157    pub fn is_canonical(&self) -> bool {
158        self.predecessor.validate(self.predecessor.node).is_ok()
159            && self.successor.validate(self.predecessor.node).is_ok()
160            && self.predecessor.owner != self.successor.owner
161            && self.predecessor.seq.checked_add(1) == Some(self.successor.seq)
162            && self.predecessor.term.checked_add(1) == Some(self.successor.term)
163    }
164}
165
166/// Result of one process-lease acquisition attempt.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum ProcessLeaseOutcome {
169    /// This boot incarnation owns the stable node identity.
170    Acquired(ProcessLease),
171    /// A different live boot incarnation owns it.
172    Held(ProcessLease),
173}
174
175/// Candidate-local proof that one exact durable lease record stayed current for a full TTL.
176#[derive(Debug)]
177pub struct ProcessLeaseObservation {
178    lease: ProcessLease,
179    started: std::time::Instant,
180}
181
182/// Process lease storage failure.
183#[derive(Debug, thiserror::Error)]
184pub enum ProcessLeaseError {
185    /// Underlying object-store failure.
186    #[error("object store I/O: {0}")]
187    Io(String),
188    /// Invalid durable record.
189    #[error("invalid process lease: {0}")]
190    Invalid(String),
191    /// JSON encoding or decoding failure.
192    #[error("JSON: {0}")]
193    Json(#[from] serde_json::Error),
194    /// Caller-provided monotonic deadline expired before fencing could be proven.
195    #[error("process lease fencing deadline: {0}")]
196    Deadline(String),
197}
198
199/// Append-only object-store authority for one stable node identity.
200pub struct ProcessLeaseStore {
201    store: Arc<dyn ObjectStore>,
202    node: NodeId,
203    ttl_ms: i64,
204    prune_running: Arc<AtomicBool>,
205    prune_healthy: Arc<AtomicBool>,
206    writes_since_prune: AtomicU64,
207    sealed_term: AtomicU64,
208}
209
210impl std::fmt::Debug for ProcessLeaseStore {
211    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        formatter
213            .debug_struct("ProcessLeaseStore")
214            .field("node", &self.node)
215            .field("ttl_ms", &self.ttl_ms)
216            .finish_non_exhaustive()
217    }
218}
219
220impl ProcessLeaseStore {
221    /// Create an authority for `node`.
222    #[must_use]
223    pub fn new(store: Arc<dyn ObjectStore>, node: NodeId, ttl_ms: i64) -> Self {
224        Self {
225            store,
226            node,
227            ttl_ms,
228            prune_running: Arc::new(AtomicBool::new(false)),
229            prune_healthy: Arc::new(AtomicBool::new(true)),
230            writes_since_prune: AtomicU64::new(0),
231            sealed_term: AtomicU64::new(0),
232        }
233    }
234
235    async fn list_seqs_from(
236        store: &Arc<dyn ObjectStore>,
237        node: NodeId,
238    ) -> Result<Vec<u64>, ProcessLeaseError> {
239        let prefix_string = lease_prefix(node);
240        let prefix = OsPath::from(prefix_string.clone());
241        let mut entries = store.list(Some(&prefix));
242        let mut sequences = Vec::new();
243        while let Some(entry) = entries.next().await {
244            let entry = entry.map_err(|error| ProcessLeaseError::Io(error.to_string()))?;
245            if sequences.len() == PROCESS_LEASE_MAX_LIST_RECORDS {
246                return Err(ProcessLeaseError::Invalid(format!(
247                    "process lease history exceeds the fixed {PROCESS_LEASE_MAX_LIST_RECORDS}-record scan bound"
248                )));
249            }
250            sequences.push(sequence_from_path(node, &entry.location)?);
251        }
252        sequences.sort_unstable();
253        sequences.dedup();
254        Ok(sequences)
255    }
256
257    async fn list_seqs(&self) -> Result<Vec<u64>, ProcessLeaseError> {
258        Self::list_seqs_from(&self.store, self.node).await
259    }
260
261    async fn oldest_prune_window(
262        store: &Arc<dyn ObjectStore>,
263        node: NodeId,
264    ) -> Result<(u64, Vec<u64>), ProcessLeaseError> {
265        let prefix = OsPath::from(lease_prefix(node));
266        let mut entries = store.list(Some(&prefix));
267        let window_len = PROCESS_LEASE_PRUNE_BATCH_RECORDS
268            .checked_add(1)
269            .ok_or_else(|| {
270                ProcessLeaseError::Invalid("process lease prune window overflow".into())
271            })?;
272        let mut oldest = BinaryHeap::with_capacity(window_len);
273        let mut count = 0_u64;
274        while let Some(entry) = entries.next().await {
275            let entry = entry.map_err(|error| ProcessLeaseError::Io(error.to_string()))?;
276            let sequence = sequence_from_path(node, &entry.location)?;
277            count = count.checked_add(1).ok_or_else(|| {
278                ProcessLeaseError::Invalid("process lease history count exhausted".into())
279            })?;
280            if oldest.len() < window_len {
281                oldest.push(sequence);
282            } else if oldest.peek().is_some_and(|largest| sequence < *largest) {
283                oldest.pop();
284                oldest.push(sequence);
285            }
286        }
287        Ok((count, oldest.into_sorted_vec()))
288    }
289
290    fn schedule_history_prune(&self, force: bool) {
291        if !force
292            && self.writes_since_prune.fetch_add(1, Ordering::AcqRel) + 1
293                < PROCESS_LEASE_WRITES_PER_PRUNE
294        {
295            return;
296        }
297        if self
298            .prune_running
299            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
300            .is_err()
301        {
302            return;
303        }
304        self.writes_since_prune.store(0, Ordering::Release);
305        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
306            self.prune_running.store(false, Ordering::Release);
307            self.prune_healthy.store(false, Ordering::Release);
308            return;
309        };
310        let store = Arc::clone(&self.store);
311        let node = self.node;
312        let prune_running = Arc::clone(&self.prune_running);
313        let prune_healthy = Arc::clone(&self.prune_healthy);
314        runtime.spawn(async move {
315            let prune = Self::prune_history(&store, node).await;
316            if let Err(error) = prune {
317                prune_healthy.store(false, Ordering::Release);
318                tracing::warn!(node = node.0, %error, "process lease history prune failed");
319            } else {
320                prune_healthy.store(true, Ordering::Release);
321            }
322            prune_running.store(false, Ordering::Release);
323        });
324    }
325
326    async fn prune_history(
327        store: &Arc<dyn ObjectStore>,
328        node: NodeId,
329    ) -> Result<(), ProcessLeaseError> {
330        for _ in 0..PROCESS_LEASE_MAX_PRUNE_BATCHES {
331            let done = tokio::time::timeout(
332                PROCESS_LEASE_PRUNE_IO_TIMEOUT,
333                Self::prune_history_batch(store, node),
334            )
335            .await
336            .map_err(|_| ProcessLeaseError::Io("process lease history prune timed out".into()))??;
337            if done {
338                return Ok(());
339            }
340            tokio::task::yield_now().await;
341        }
342        Err(ProcessLeaseError::Io(
343            "process lease history still exceeds the bounded prune budget".into(),
344        ))
345    }
346
347    async fn repair_unhealthy_prune(&self) -> Result<(), ProcessLeaseError> {
348        if self.prune_healthy.load(Ordering::Acquire) {
349            return Ok(());
350        }
351        Self::prune_history(&self.store, self.node).await?;
352        self.prune_healthy.store(true, Ordering::Release);
353        self.writes_since_prune.store(0, Ordering::Release);
354        Ok(())
355    }
356
357    async fn prune_history_batch(
358        store: &Arc<dyn ObjectStore>,
359        node: NodeId,
360    ) -> Result<bool, ProcessLeaseError> {
361        let (count, sequences) = Self::oldest_prune_window(store, node).await?;
362        let retain = u64::try_from(PROCESS_LEASE_HISTORY_TO_RETAIN)
363            .map_err(|_| ProcessLeaseError::Invalid("process lease retention overflow".into()))?;
364        if count <= retain {
365            return Ok(true);
366        }
367        let delete_count_u64 =
368            (count - retain).min(u64::try_from(PROCESS_LEASE_PRUNE_BATCH_RECORDS).map_err(
369                |_| ProcessLeaseError::Invalid("process lease prune batch overflow".into()),
370            )?);
371        let delete_count = usize::try_from(delete_count_u64).map_err(|_| {
372            ProcessLeaseError::Invalid("process lease delete count overflow".into())
373        })?;
374        if sequences.len() <= delete_count {
375            return Err(ProcessLeaseError::Invalid(
376                "process lease prune window is missing its retained boundary".into(),
377            ));
378        }
379        let mut reads = futures::stream::iter(sequences.iter().copied().map(|sequence| {
380            let store = Arc::clone(store);
381            async move {
382                Self::read_record_from(&store, node, sequence)
383                    .await
384                    .map(|record| record.map(|record| (sequence, record)))
385            }
386        }))
387        .buffer_unordered(PROCESS_LEASE_PRUNE_READ_CONCURRENCY);
388        let mut records = Vec::with_capacity(sequences.len());
389        while let Some(record) = reads.next().await {
390            let Some(record) = record? else {
391                return Ok(false);
392            };
393            records.push(record);
394        }
395        records.sort_unstable_by_key(|(sequence, _)| *sequence);
396        // Seal every newly observed term transition under the predecessor boot identity before
397        // deleting either side. The indexed certificate keeps recovery lookup O(1) and prevents
398        // routine renewals or many later terms from erasing takeover evidence.
399        for pair in records.windows(2) {
400            let (left_sequence, left) = &pair[0];
401            let (right_sequence, right) = &pair[1];
402            if left_sequence.checked_add(1) != Some(*right_sequence) {
403                return Err(ProcessLeaseError::Invalid(
404                    "process lease history contains a noncontiguous sequence".into(),
405                ));
406            }
407            if left.owner != right.owner || left.term != right.term {
408                if left.term.checked_add(1) != Some(right.term) || left.owner == right.owner {
409                    return Err(ProcessLeaseError::Invalid(
410                        "process lease history contains a noncanonical term transition".into(),
411                    ));
412                }
413                Self::seal_fence(store, &ProcessLeaseFence::new(left.clone(), right.clone())?)
414                    .await?;
415            }
416        }
417        let deletable = records
418            .iter()
419            .take(delete_count)
420            .map(|(sequence, _)| *sequence)
421            .collect::<Vec<_>>();
422        let deletions = futures::stream::iter(
423            deletable
424                .into_iter()
425                .map(move |sequence| Ok::<_, object_store::Error>(lease_path(node, sequence))),
426        )
427        .boxed();
428        let mut results = store.delete_stream(deletions);
429        while let Some(result) = results.next().await {
430            if let Err(error) = result {
431                if !matches!(error, object_store::Error::NotFound { .. }) {
432                    return Err(ProcessLeaseError::Io(error.to_string()));
433                }
434            }
435        }
436        Ok(count.saturating_sub(delete_count_u64) <= retain)
437    }
438
439    async fn read_record_from(
440        store: &Arc<dyn ObjectStore>,
441        node: NodeId,
442        sequence: u64,
443    ) -> Result<Option<ProcessLease>, ProcessLeaseError> {
444        let result = match store.get(&lease_path(node, sequence)).await {
445            Ok(result) => result,
446            Err(object_store::Error::NotFound { .. }) => return Ok(None),
447            Err(error) => return Err(ProcessLeaseError::Io(error.to_string())),
448        };
449        if result.meta.size == 0 || result.meta.size > MAX_PROCESS_LEASE_RECORD_BYTES {
450            return Err(ProcessLeaseError::Invalid(format!(
451                "process lease record is {} bytes; expected 1..={MAX_PROCESS_LEASE_RECORD_BYTES}",
452                result.meta.size
453            )));
454        }
455        let bytes = result
456            .bytes()
457            .await
458            .map_err(|error| ProcessLeaseError::Io(error.to_string()))?;
459        let lease: ProcessLease = serde_json::from_slice(&bytes)?;
460        lease.validate(node)?;
461        if lease.seq != sequence {
462            return Err(ProcessLeaseError::Invalid(
463                "record sequence does not match its object name".into(),
464            ));
465        }
466        let canonical = serde_json::to_vec(&lease)?;
467        if canonical.as_slice() != bytes.as_ref() {
468            return Err(ProcessLeaseError::Invalid(format!(
469                "process lease record {sequence} does not use its canonical body"
470            )));
471        }
472        Ok(Some(lease))
473    }
474
475    async fn read_history(
476        store: &Arc<dyn ObjectStore>,
477        node: NodeId,
478    ) -> Result<Vec<(u64, ProcessLease)>, ProcessLeaseError> {
479        for attempt in 0..PROCESS_LEASE_HEAD_READ_ATTEMPTS {
480            let sequences = Self::list_seqs_from(store, node).await?;
481            let mut reads = futures::stream::iter(sequences.iter().copied().map(|sequence| {
482                let store = Arc::clone(store);
483                async move {
484                    Self::read_record_from(&store, node, sequence)
485                        .await
486                        .map(|record| record.map(|record| (sequence, record)))
487                }
488            }))
489            .buffer_unordered(PROCESS_LEASE_PRUNE_READ_CONCURRENCY);
490            let mut records = Vec::with_capacity(sequences.len());
491            let mut changed = false;
492            while let Some(record) = reads.next().await {
493                match record? {
494                    Some(record) => records.push(record),
495                    None => changed = true,
496                }
497            }
498            if !changed {
499                records.sort_unstable_by_key(|(sequence, _)| *sequence);
500                return Ok(records);
501            }
502            if attempt + 1 < PROCESS_LEASE_HEAD_READ_ATTEMPTS {
503                tokio::task::yield_now().await;
504            }
505        }
506        Err(ProcessLeaseError::Io(format!(
507            "process lease history changed during {PROCESS_LEASE_HEAD_READ_ATTEMPTS} read attempts"
508        )))
509    }
510
511    async fn load_fence_at(
512        store: &Arc<dyn ObjectStore>,
513        path: &OsPath,
514    ) -> Result<Option<ProcessLeaseFence>, ProcessLeaseError> {
515        let result = match store.get(path).await {
516            Ok(result) => result,
517            Err(object_store::Error::NotFound { .. }) => return Ok(None),
518            Err(error) => return Err(ProcessLeaseError::Io(error.to_string())),
519        };
520        if result.meta.size == 0 || result.meta.size > MAX_PROCESS_LEASE_FENCE_BYTES {
521            return Err(ProcessLeaseError::Invalid(format!(
522                "process lease fence is {} bytes; expected 1..={MAX_PROCESS_LEASE_FENCE_BYTES}",
523                result.meta.size
524            )));
525        }
526        let bytes = result
527            .bytes()
528            .await
529            .map_err(|error| ProcessLeaseError::Io(error.to_string()))?;
530        let fence: ProcessLeaseFence = serde_json::from_slice(&bytes)?;
531        if !fence.is_canonical() {
532            return Err(ProcessLeaseError::Invalid(
533                "indexed process lease fence is not canonical".into(),
534            ));
535        }
536        let canonical = serde_json::to_vec(&fence)?;
537        if canonical.as_slice() != bytes.as_ref() {
538            return Err(ProcessLeaseError::Invalid(
539                "indexed process lease fence does not use its canonical body".into(),
540            ));
541        }
542        Ok(Some(fence))
543    }
544
545    async fn load_fence(
546        store: &Arc<dyn ObjectStore>,
547        node: NodeId,
548        predecessor: Uuid,
549    ) -> Result<Option<ProcessLeaseFence>, ProcessLeaseError> {
550        let fence = Self::load_fence_at(store, &fence_path(node, predecessor)).await?;
551        if fence.as_ref().is_some_and(|fence| {
552            fence.predecessor.node != node || fence.predecessor.owner != predecessor
553        }) {
554            return Err(ProcessLeaseError::Invalid(
555                "predecessor-indexed process lease fence has the wrong identity".into(),
556            ));
557        }
558        Ok(fence)
559    }
560
561    async fn load_successor_fence(
562        store: &Arc<dyn ObjectStore>,
563        node: NodeId,
564        successor: Uuid,
565        term: u64,
566    ) -> Result<Option<ProcessLeaseFence>, ProcessLeaseError> {
567        let fence =
568            Self::load_fence_at(store, &successor_fence_path(node, successor, term)).await?;
569        if fence.as_ref().is_some_and(|fence| {
570            fence.successor.node != node
571                || fence.successor.owner != successor
572                || fence.successor.term != term
573        }) {
574            return Err(ProcessLeaseError::Invalid(
575                "successor-indexed process lease fence has the wrong identity".into(),
576            ));
577        }
578        Ok(fence)
579    }
580
581    async fn seal_fence_at(
582        store: &Arc<dyn ObjectStore>,
583        path: &OsPath,
584        fence: &ProcessLeaseFence,
585    ) -> Result<(), ProcessLeaseError> {
586        let options = PutOptions {
587            mode: PutMode::Create,
588            ..PutOptions::default()
589        };
590        let payload = PutPayload::from(Bytes::from(serde_json::to_vec(fence)?));
591        let put_error = store.put_opts(path, payload, options).await.err();
592        match Self::load_fence_at(store, path).await {
593            Ok(Some(stored)) if stored == *fence => Ok(()),
594            Ok(Some(_)) => Err(ProcessLeaseError::Invalid(
595                "process fence index maps to conflicting takeover evidence".into(),
596            )),
597            Ok(None) => Err(ProcessLeaseError::Io(
598                "process lease fence write was not durably visible".into(),
599            )),
600            Err(reconcile_error) => {
601                if let Some(put_error) = put_error {
602                    Err(ProcessLeaseError::Io(format!(
603                        "process lease fence write failed ({put_error}); reconciliation failed ({reconcile_error})"
604                    )))
605                } else {
606                    Err(reconcile_error)
607                }
608            }
609        }
610    }
611
612    async fn seal_fence(
613        store: &Arc<dyn ObjectStore>,
614        fence: &ProcessLeaseFence,
615    ) -> Result<(), ProcessLeaseError> {
616        if !fence.is_canonical() {
617            return Err(ProcessLeaseError::Invalid(
618                "cannot seal a noncanonical process lease fence".into(),
619            ));
620        }
621        Self::seal_fence_at(
622            store,
623            &fence_path(fence.predecessor.node, fence.predecessor.owner),
624            fence,
625        )
626        .await?;
627        Self::seal_fence_at(
628            store,
629            &successor_fence_path(
630                fence.successor.node,
631                fence.successor.owner,
632                fence.successor.term,
633            ),
634            fence,
635        )
636        .await
637    }
638
639    async fn read_record(&self, sequence: u64) -> Result<Option<ProcessLease>, ProcessLeaseError> {
640        Self::read_record_from(&self.store, self.node, sequence).await
641    }
642
643    async fn find_takeover_from(
644        &self,
645        owner: Uuid,
646    ) -> Result<Option<ProcessLeaseFence>, ProcessLeaseError> {
647        if let Some(fence) = Self::load_fence(&self.store, self.node, owner).await? {
648            return Ok(Some(fence));
649        }
650        let records = Self::read_history(&self.store, self.node).await?;
651        let mut found = None;
652        for pair in records.windows(2) {
653            let (predecessor_sequence, predecessor) = &pair[0];
654            let (successor_sequence, successor) = &pair[1];
655            if predecessor.owner != owner {
656                continue;
657            }
658            if predecessor_sequence.checked_add(1) != Some(*successor_sequence) {
659                continue;
660            }
661            let Ok(fence) = ProcessLeaseFence::new(predecessor.clone(), successor.clone()) else {
662                continue;
663            };
664            if found.replace(fence).is_some() {
665                return Err(ProcessLeaseError::Invalid(
666                    "process owner appears in more than one durable takeover transition".into(),
667                ));
668            }
669        }
670        if let Some(fence) = found {
671            Self::seal_fence(&self.store, &fence).await?;
672            Ok(Some(fence))
673        } else {
674            // A concurrent pruner may have sealed the direct index and removed the history pair
675            // after our first point read but before the scan completed.
676            Self::load_fence(&self.store, self.node, owner).await
677        }
678    }
679
680    async fn ensure_current_term_fence(
681        &self,
682        current: &ProcessLease,
683    ) -> Result<(), ProcessLeaseError> {
684        if current.term == 1 || self.sealed_term.load(Ordering::Acquire) == current.term {
685            self.sealed_term.store(current.term, Ordering::Release);
686            return Ok(());
687        }
688        if let Some(fence) =
689            Self::load_successor_fence(&self.store, self.node, current.owner, current.term).await?
690        {
691            if fence.successor.seq > current.seq {
692                return Err(ProcessLeaseError::Invalid(
693                    "process term fence starts after the current lease head".into(),
694                ));
695            }
696            self.sealed_term.store(current.term, Ordering::Release);
697            return Ok(());
698        }
699
700        let records = Self::read_history(&self.store, self.node).await?;
701        for pair in records.windows(2) {
702            let (left_sequence, left) = &pair[0];
703            let (right_sequence, right) = &pair[1];
704            if left_sequence.checked_add(1) == Some(*right_sequence)
705                && right.owner == current.owner
706                && right.term == current.term
707            {
708                let fence = ProcessLeaseFence::new(left.clone(), right.clone())?;
709                Self::seal_fence(&self.store, &fence).await?;
710                self.sealed_term.store(current.term, Ordering::Release);
711                return Ok(());
712            }
713        }
714        Err(ProcessLeaseError::Invalid(
715            "current process term has no durable takeover evidence".into(),
716        ))
717    }
718
719    /// Load the highest durable sequence.
720    ///
721    /// # Errors
722    /// Fails on object-store I/O, malformed JSON, or a record in the wrong node namespace.
723    pub async fn load(&self) -> Result<Option<ProcessLease>, ProcessLeaseError> {
724        let mut observed_head = false;
725        for attempt in 0..PROCESS_LEASE_HEAD_READ_ATTEMPTS {
726            let sequences = match self.list_seqs().await {
727                Ok(sequences) => sequences,
728                Err(error) => {
729                    self.schedule_history_prune(true);
730                    return Err(error);
731                }
732            };
733            let Some(sequence) = sequences.last().copied() else {
734                if !observed_head {
735                    return Ok(None);
736                }
737                if attempt + 1 < PROCESS_LEASE_HEAD_READ_ATTEMPTS {
738                    tokio::task::yield_now().await;
739                    continue;
740                }
741                break;
742            };
743            observed_head = true;
744            match self.read_record(sequence).await? {
745                Some(lease) => return Ok(Some(lease)),
746                None if attempt + 1 < PROCESS_LEASE_HEAD_READ_ATTEMPTS => {
747                    tokio::task::yield_now().await;
748                }
749                None => break,
750            }
751        }
752        Err(ProcessLeaseError::Io(format!(
753            "process lease head changed during {PROCESS_LEASE_HEAD_READ_ATTEMPTS} read attempts"
754        )))
755    }
756
757    /// Acquire or renew this stable node identity for `owner` at `now_ms`.
758    ///
759    /// # Errors
760    /// Fails closed on object-store I/O or an invalid durable record.
761    pub async fn try_acquire(
762        &self,
763        owner: Uuid,
764        now_ms: i64,
765    ) -> Result<ProcessLeaseOutcome, ProcessLeaseError> {
766        if owner.is_nil() || self.node.is_unassigned() || self.ttl_ms <= 0 {
767            return Err(ProcessLeaseError::Invalid(
768                "node, owner, and lease TTL must be nonzero".into(),
769            ));
770        }
771        self.repair_unhealthy_prune().await?;
772        let current = self.load().await?;
773        if let Some(lease) = current.as_ref().filter(|lease| lease.owner == owner) {
774            self.ensure_current_term_fence(lease).await?;
775        }
776        let candidate = match current {
777            None => ProcessLease {
778                node: self.node,
779                owner,
780                term: 1,
781                seq: 1,
782                expires_at_ms: now_ms.saturating_add(self.ttl_ms),
783            },
784            Some(ref lease) if lease.owner == owner => ProcessLease {
785                node: self.node,
786                owner,
787                term: lease.term,
788                seq: lease
789                    .seq
790                    .checked_add(1)
791                    .ok_or_else(|| ProcessLeaseError::Invalid("lease sequence exhausted".into()))?,
792                expires_at_ms: now_ms.saturating_add(self.ttl_ms),
793            },
794            Some(lease) => return Ok(ProcessLeaseOutcome::Held(lease)),
795        };
796        if candidate.term == 0 || candidate.seq == 0 {
797            return Err(ProcessLeaseError::Invalid(
798                "lease term or sequence exhausted".into(),
799            ));
800        }
801
802        let options = PutOptions {
803            mode: PutMode::Create,
804            ..PutOptions::default()
805        };
806        let payload = PutPayload::from(Bytes::from(serde_json::to_vec(&candidate)?));
807        match self
808            .store
809            .put_opts(&lease_path(self.node, candidate.seq), payload, options)
810            .await
811        {
812            Ok(_) => {
813                self.sealed_term.store(candidate.term, Ordering::Release);
814                self.schedule_history_prune(false);
815                Ok(ProcessLeaseOutcome::Acquired(candidate))
816            }
817            Err(
818                object_store::Error::AlreadyExists { .. }
819                | object_store::Error::Precondition { .. },
820            ) => {
821                let winner = self.load().await?.ok_or_else(|| {
822                    ProcessLeaseError::Io("CAS conflict but the winner was not readable".into())
823                })?;
824                self.schedule_history_prune(false);
825                if winner.owner == owner {
826                    self.ensure_current_term_fence(&winner).await?;
827                    Ok(ProcessLeaseOutcome::Acquired(winner))
828                } else {
829                    Ok(ProcessLeaseOutcome::Held(winner))
830                }
831            }
832            Err(error) => Err(ProcessLeaseError::Io(error.to_string())),
833        }
834    }
835
836    /// Start a candidate-local monotonic observation of a rival lease.
837    ///
838    /// # Errors
839    /// Rejects a malformed record or one from another node namespace.
840    pub fn observe_rival(
841        &self,
842        lease: &ProcessLease,
843    ) -> Result<ProcessLeaseObservation, ProcessLeaseError> {
844        lease.validate(self.node)?;
845        Ok(ProcessLeaseObservation {
846            lease: lease.clone(),
847            started: std::time::Instant::now(),
848        })
849    }
850
851    /// Attempt takeover after the same sequence and owner have been observed unchanged for a
852    /// full TTL on this candidate's monotonic clock.
853    ///
854    /// # Errors
855    /// Fails closed on early observation, object-store I/O, or malformed durable state.
856    pub async fn try_takeover(
857        &self,
858        owner: Uuid,
859        observation: &ProcessLeaseObservation,
860        now_ms: i64,
861    ) -> Result<ProcessLeaseOutcome, ProcessLeaseError> {
862        if owner.is_nil() || self.ttl_ms <= 0 {
863            return Err(ProcessLeaseError::Invalid(
864                "takeover owner and lease TTL must be nonzero".into(),
865            ));
866        }
867        self.repair_unhealthy_prune().await?;
868        observation.lease.validate(self.node)?;
869        if observation.started.elapsed()
870            < Duration::from_millis(u64::try_from(self.ttl_ms).unwrap_or(u64::MAX))
871        {
872            return Ok(ProcessLeaseOutcome::Held(observation.lease.clone()));
873        }
874        let current = self.load().await?.ok_or_else(|| {
875            ProcessLeaseError::Invalid("observed process lease disappeared".into())
876        })?;
877        if current != observation.lease {
878            return Ok(ProcessLeaseOutcome::Held(current));
879        }
880        let candidate = ProcessLease {
881            node: self.node,
882            owner,
883            term: current
884                .term
885                .checked_add(1)
886                .ok_or_else(|| ProcessLeaseError::Invalid("process term exhausted".into()))?,
887            seq: current
888                .seq
889                .checked_add(1)
890                .ok_or_else(|| ProcessLeaseError::Invalid("lease sequence exhausted".into()))?,
891            expires_at_ms: now_ms.saturating_add(self.ttl_ms),
892        };
893        let options = PutOptions {
894            mode: PutMode::Create,
895            ..PutOptions::default()
896        };
897        let payload = PutPayload::from(Bytes::from(serde_json::to_vec(&candidate)?));
898        match self
899            .store
900            .put_opts(&lease_path(self.node, candidate.seq), payload, options)
901            .await
902        {
903            Ok(_) => {
904                let fence = ProcessLeaseFence::new(current, candidate.clone())?;
905                Self::seal_fence(&self.store, &fence).await?;
906                self.sealed_term.store(candidate.term, Ordering::Release);
907                self.schedule_history_prune(true);
908                Ok(ProcessLeaseOutcome::Acquired(candidate))
909            }
910            Err(
911                object_store::Error::AlreadyExists { .. }
912                | object_store::Error::Precondition { .. },
913            ) => {
914                let winner = self.load().await?.ok_or_else(|| {
915                    ProcessLeaseError::Io("takeover CAS winner was not readable".into())
916                })?;
917                self.schedule_history_prune(true);
918                Ok(ProcessLeaseOutcome::Held(winner))
919            }
920            Err(error) => Err(ProcessLeaseError::Io(error.to_string())),
921        }
922    }
923}
924
925/// Shared authority for proving that an exact process incarnation has been durably revoked.
926pub struct ProcessLeaseAuthority {
927    store: Arc<dyn ObjectStore>,
928    ttl: Duration,
929    ttl_ms: i64,
930}
931
932impl std::fmt::Debug for ProcessLeaseAuthority {
933    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
934        formatter
935            .debug_struct("ProcessLeaseAuthority")
936            .field("ttl", &self.ttl)
937            .finish_non_exhaustive()
938    }
939}
940
941impl ProcessLeaseAuthority {
942    /// Bind all stable-node lease namespaces on one shared object store and TTL.
943    ///
944    /// # Errors
945    /// Rejects a zero, sub-millisecond, fractional-millisecond, or oversized TTL.
946    pub fn new(store: Arc<dyn ObjectStore>, ttl: Duration) -> Result<Self, ProcessLeaseError> {
947        let ttl_ms = i64::try_from(ttl.as_millis())
948            .ok()
949            .filter(|value| *value > 0)
950            .ok_or_else(|| ProcessLeaseError::Invalid("process lease TTL is invalid".into()))?;
951        if Duration::from_millis(u64::try_from(ttl_ms).unwrap_or(u64::MAX)) != ttl {
952            return Err(ProcessLeaseError::Invalid(
953                "process lease TTL must use whole milliseconds".into(),
954            ));
955        }
956        Ok(Self { store, ttl, ttl_ms })
957    }
958
959    /// Open one stable-node namespace over the shared authority.
960    #[must_use]
961    pub fn store_for(&self, node: NodeId) -> Arc<ProcessLeaseStore> {
962        Arc::new(ProcessLeaseStore::new(
963            Arc::clone(&self.store),
964            node,
965            self.ttl_ms,
966        ))
967    }
968
969    /// Build a monotonic deadline that covers the mandatory full-TTL observation plus bounded
970    /// authority I/O. Callers need not duplicate the process-lease TTL as another runtime knob.
971    ///
972    /// # Errors
973    /// Rejects a zero I/O budget or monotonic-clock overflow.
974    pub fn fencing_deadline(
975        &self,
976        io_budget: Duration,
977    ) -> Result<tokio::time::Instant, ProcessLeaseError> {
978        if io_budget.is_zero() {
979            return Err(ProcessLeaseError::Invalid(
980                "process lease fencing I/O budget must be nonzero".into(),
981            ));
982        }
983        tokio::time::Instant::now()
984            .checked_add(self.ttl)
985            .and_then(|deadline| deadline.checked_add(io_budget))
986            .ok_or_else(|| {
987                ProcessLeaseError::Invalid("process lease fencing deadline overflow".into())
988            })
989    }
990
991    async fn bounded<T>(
992        deadline: tokio::time::Instant,
993        operation: &str,
994        future: impl Future<Output = Result<T, ProcessLeaseError>>,
995    ) -> Result<T, ProcessLeaseError> {
996        if tokio::time::Instant::now() >= deadline {
997            return Err(ProcessLeaseError::Deadline(format!(
998                "deadline expired before {operation}"
999            )));
1000        }
1001        tokio::time::timeout_at(deadline, future)
1002            .await
1003            .map_err(|_| {
1004                ProcessLeaseError::Deadline(format!("deadline expired during {operation}"))
1005            })?
1006    }
1007
1008    async fn recover_won_fence(
1009        &self,
1010        participant: crate::checkpoint::CheckpointParticipant,
1011        head: ProcessLease,
1012        deadline: tokio::time::Instant,
1013    ) -> Result<ProcessLeaseFence, ProcessLeaseError> {
1014        let node = NodeId(participant.node_id);
1015        if head.node != node || head.owner == participant.boot_incarnation {
1016            return Err(ProcessLeaseError::Invalid(
1017                "process lease head has not superseded the requested incarnation".into(),
1018            ));
1019        }
1020        let store = self.store_for(node);
1021        let fence = Self::bounded(
1022            deadline,
1023            "locating process lease takeover evidence",
1024            store.find_takeover_from(participant.boot_incarnation),
1025        )
1026        .await?
1027        .ok_or_else(|| {
1028            ProcessLeaseError::Invalid(format!(
1029                "process lease takeover evidence for {} is missing",
1030                participant.boot_incarnation
1031            ))
1032        })?;
1033        if !self.verify_fence(&fence, deadline).await? {
1034            return Err(ProcessLeaseError::Invalid(
1035                "process lease takeover is no longer durably verifiable".into(),
1036            ));
1037        }
1038        Ok(fence)
1039    }
1040
1041    /// Durably supersede an unchanged process incarnation after observing it for one full TTL.
1042    ///
1043    /// A retry after the create-only takeover won reconstructs the exact fence from the two
1044    /// retained history records. It never waits a second TTL for that already-durable result.
1045    ///
1046    /// # Errors
1047    /// Fails closed on renewal, deadline expiry, missing history, or any authority I/O failure.
1048    pub async fn fence_incarnation(
1049        &self,
1050        participant: crate::checkpoint::CheckpointParticipant,
1051        deadline: tokio::time::Instant,
1052    ) -> Result<ProcessLeaseFence, ProcessLeaseError> {
1053        let node = NodeId(participant.node_id);
1054        if node.is_unassigned() || participant.boot_incarnation.is_nil() {
1055            return Err(ProcessLeaseError::Invalid(
1056                "process lease fence participant is not canonical".into(),
1057            ));
1058        }
1059        let store = self.store_for(node);
1060        let head = Self::bounded(deadline, "loading process lease fence head", store.load())
1061            .await?
1062            .ok_or_else(|| {
1063                ProcessLeaseError::Invalid("process lease fence history is missing".into())
1064            })?;
1065        if head.owner != participant.boot_incarnation {
1066            return self.recover_won_fence(participant, head, deadline).await;
1067        }
1068
1069        let observation = store.observe_rival(&head)?;
1070        let observation_until = tokio::time::Instant::now()
1071            .checked_add(self.ttl)
1072            .ok_or_else(|| ProcessLeaseError::Invalid("process lease TTL overflows time".into()))?;
1073        if observation_until >= deadline {
1074            return Err(ProcessLeaseError::Deadline(
1075                "deadline does not cover one full process lease TTL".into(),
1076            ));
1077        }
1078        tokio::time::sleep_until(observation_until).await;
1079
1080        let mut revoker = Uuid::new_v4();
1081        while revoker.is_nil() || revoker == participant.boot_incarnation {
1082            revoker = Uuid::new_v4();
1083        }
1084        let outcome = Self::bounded(
1085            deadline,
1086            "publishing process lease takeover",
1087            store.try_takeover(revoker, &observation, now_millis()),
1088        )
1089        .await?;
1090        match outcome {
1091            ProcessLeaseOutcome::Acquired(successor) => {
1092                let fence = ProcessLeaseFence::new(head, successor)?;
1093                if !self.verify_fence(&fence, deadline).await? {
1094                    return Err(ProcessLeaseError::Invalid(
1095                        "won process lease takeover could not be verified".into(),
1096                    ));
1097                }
1098                Ok(fence)
1099            }
1100            ProcessLeaseOutcome::Held(current) if current.owner == participant.boot_incarnation => {
1101                Err(ProcessLeaseError::Invalid(
1102                    "process incarnation renewed during the full-TTL observation".into(),
1103                ))
1104            }
1105            ProcessLeaseOutcome::Held(current) => {
1106                self.recover_won_fence(participant, current, deadline).await
1107            }
1108        }
1109    }
1110
1111    /// Verify that an exact boot incarnation is the current durable owner of its stable node.
1112    ///
1113    /// # Errors
1114    /// Fails closed on deadline expiry, missing takeover evidence, malformed state, or I/O.
1115    pub async fn verify_current_participant(
1116        &self,
1117        participant: crate::checkpoint::CheckpointParticipant,
1118        deadline: tokio::time::Instant,
1119    ) -> Result<bool, ProcessLeaseError> {
1120        self.verify_current_participant_identity(participant, None, deadline)
1121            .await
1122    }
1123
1124    /// Verify an exact boot and process term against the current durable stable-node authority.
1125    ///
1126    /// # Errors
1127    /// Fails closed on deadline expiry, missing takeover evidence, malformed state, or I/O.
1128    pub async fn verify_current_participant_term(
1129        &self,
1130        participant: crate::checkpoint::CheckpointParticipant,
1131        process_term: u64,
1132        deadline: tokio::time::Instant,
1133    ) -> Result<bool, ProcessLeaseError> {
1134        if process_term == 0 {
1135            return Err(ProcessLeaseError::Invalid(
1136                "process lease term must be nonzero".into(),
1137            ));
1138        }
1139        self.verify_current_participant_identity(participant, Some(process_term), deadline)
1140            .await
1141    }
1142
1143    async fn verify_current_participant_identity(
1144        &self,
1145        participant: crate::checkpoint::CheckpointParticipant,
1146        process_term: Option<u64>,
1147        deadline: tokio::time::Instant,
1148    ) -> Result<bool, ProcessLeaseError> {
1149        let node = NodeId(participant.node_id);
1150        if node.is_unassigned() || participant.boot_incarnation.is_nil() {
1151            return Err(ProcessLeaseError::Invalid(
1152                "process lease participant is not canonical".into(),
1153            ));
1154        }
1155        let store = self.store_for(node);
1156        let Some(head) =
1157            Self::bounded(deadline, "loading current process lease", store.load()).await?
1158        else {
1159            return Ok(false);
1160        };
1161        if head.owner != participant.boot_incarnation
1162            || process_term.is_some_and(|term| head.term != term)
1163        {
1164            return Ok(false);
1165        }
1166        Self::bounded(
1167            deadline,
1168            "verifying current process term evidence",
1169            store.ensure_current_term_fence(&head),
1170        )
1171        .await?;
1172        let Some(after) =
1173            Self::bounded(deadline, "rechecking current process lease", store.load()).await?
1174        else {
1175            return Ok(false);
1176        };
1177        Ok(after.owner == head.owner
1178            && after.term == head.term
1179            && after.seq >= head.seq
1180            && process_term.is_none_or(|term| after.term == term))
1181    }
1182
1183    /// Verify that both exact fence records remain present and the fenced owner is not current.
1184    ///
1185    /// # Errors
1186    /// Fails closed on deadline expiry, missing history, malformed records, or authority I/O.
1187    pub async fn verify_fence(
1188        &self,
1189        fence: &ProcessLeaseFence,
1190        deadline: tokio::time::Instant,
1191    ) -> Result<bool, ProcessLeaseError> {
1192        if !fence.is_canonical() {
1193            return Err(ProcessLeaseError::Invalid(
1194                "process lease fence is not canonical".into(),
1195            ));
1196        }
1197        let store = self.store_for(fence.predecessor.node);
1198        let durable_fence = Self::bounded(
1199            deadline,
1200            "verifying indexed process lease fence",
1201            ProcessLeaseStore::load_fence(
1202                &store.store,
1203                fence.predecessor.node,
1204                fence.predecessor.owner,
1205            ),
1206        )
1207        .await?
1208        .ok_or_else(|| {
1209            ProcessLeaseError::Invalid("indexed process lease fence is missing".into())
1210        })?;
1211        let head = Self::bounded(deadline, "verifying process lease fence head", store.load())
1212            .await?
1213            .ok_or_else(|| {
1214                ProcessLeaseError::Invalid("process lease fence durable head is missing".into())
1215            })?;
1216        Ok(durable_fence == *fence
1217            && head.seq >= fence.successor.seq
1218            && head.term >= fence.successor.term
1219            && (head.term != fence.successor.term || head.owner == fence.successor.owner)
1220            && head.owner != fence.predecessor.owner)
1221    }
1222}
1223
1224/// Internal renewal timings for the stable-node lease.
1225#[derive(Debug, Clone, Copy)]
1226pub struct ProcessLeaseConfig {
1227    /// Lease lifetime.
1228    pub ttl: Duration,
1229    /// Renewal cadence.
1230    pub renew_interval: Duration,
1231}
1232
1233impl Default for ProcessLeaseConfig {
1234    fn default() -> Self {
1235        Self {
1236            ttl: Duration::from_secs(15),
1237            renew_interval: Duration::from_secs(5),
1238        }
1239    }
1240}
1241
1242/// Renews an already-acquired process lease and publishes terminal lease loss.
1243pub struct ProcessLeaseManager {
1244    store: Arc<ProcessLeaseStore>,
1245    owner: Uuid,
1246    config: ProcessLeaseConfig,
1247    initial_valid_until: std::time::Instant,
1248    live_tx: watch::Sender<bool>,
1249    deadline: Arc<LeaseDeadline>,
1250}
1251
1252impl ProcessLeaseManager {
1253    /// Construct a renewal manager for an acquired lease.
1254    ///
1255    /// # Errors
1256    /// Rejects a lease that does not match the store namespace or owner.
1257    pub fn new(
1258        store: Arc<ProcessLeaseStore>,
1259        owner: Uuid,
1260        config: ProcessLeaseConfig,
1261        acquisition_started_at: Instant,
1262        initial: &ProcessLease,
1263    ) -> Result<Self, ProcessLeaseError> {
1264        initial.validate(store.node)?;
1265        let store_ttl = u64::try_from(store.ttl_ms)
1266            .ok()
1267            .filter(|ttl| *ttl > 0)
1268            .map(Duration::from_millis)
1269            .ok_or_else(|| {
1270                ProcessLeaseError::Invalid("process lease TTL must be positive".into())
1271            })?;
1272        if initial.owner != owner
1273            || config.ttl.is_zero()
1274            || config.ttl != store_ttl
1275            || config.renew_interval.is_zero()
1276            || config.renew_interval >= config.ttl
1277        {
1278            return Err(ProcessLeaseError::Invalid(
1279                "renewal manager requires this boot's lease, the exact store TTL, and a renewal interval below TTL".into(),
1280            ));
1281        }
1282        let (live_tx, _live_rx) = watch::channel(true);
1283        let ttl = config.ttl;
1284        let now = Instant::now();
1285        if acquisition_started_at > now {
1286            return Err(ProcessLeaseError::Invalid(
1287                "process lease acquisition start is in the future".into(),
1288            ));
1289        }
1290        let initial_valid_until = acquisition_started_at
1291            .checked_add(ttl)
1292            .ok_or_else(|| ProcessLeaseError::Invalid("process lease TTL overflows time".into()))?;
1293        if now >= initial_valid_until {
1294            return Err(ProcessLeaseError::Invalid(
1295                "process lease acquisition response arrived after its local deadline".into(),
1296            ));
1297        }
1298        let deadline = Arc::new(LeaseDeadline::uninitialized());
1299        deadline.extend_until(initial_valid_until);
1300        if !deadline.is_live() {
1301            return Err(ProcessLeaseError::Invalid(
1302                "process lease acquisition response arrived after its local deadline".into(),
1303            ));
1304        }
1305        Ok(Self {
1306            store,
1307            owner,
1308            config,
1309            initial_valid_until,
1310            live_tx,
1311            deadline,
1312        })
1313    }
1314
1315    /// Watch terminal ownership status.
1316    #[must_use]
1317    pub fn live_watch(&self) -> watch::Receiver<bool> {
1318        self.live_tx.subscribe()
1319    }
1320
1321    /// Shared monotonic deadline for hot-path fencing.
1322    #[must_use]
1323    pub fn deadline(&self) -> Arc<LeaseDeadline> {
1324        Arc::clone(&self.deadline)
1325    }
1326
1327    /// Spawn the renewal loop. Once ownership is uncertain past its expiry or a rival is observed,
1328    /// the watch becomes false and this manager never reacquires.
1329    #[cfg(feature = "cluster")]
1330    #[must_use]
1331    pub fn spawn(
1332        self,
1333        shutdown: tokio_util::sync::CancellationToken,
1334    ) -> tokio::task::JoinHandle<()> {
1335        tokio::spawn(async move {
1336            let mut valid_until = self.initial_valid_until;
1337            let mut ticker = tokio::time::interval(self.config.renew_interval);
1338            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1339            // The acquisition itself is the first successful tick.
1340            ticker.tick().await;
1341            loop {
1342                let now = std::time::Instant::now();
1343                if now >= valid_until {
1344                    self.deadline.fence();
1345                    self.live_tx.send_replace(false);
1346                    return;
1347                }
1348                tokio::select! {
1349                    biased;
1350                    () = shutdown.cancelled() => {
1351                        self.deadline.fence();
1352                        self.live_tx.send_replace(false);
1353                        return;
1354                    },
1355                    () = tokio::time::sleep_until(tokio::time::Instant::from_std(valid_until)) => {
1356                        self.deadline.fence();
1357                        self.live_tx.send_replace(false);
1358                        return;
1359                    }
1360                    _ = ticker.tick() => {}
1361                }
1362
1363                let now = std::time::Instant::now();
1364                if now >= valid_until {
1365                    self.deadline.fence();
1366                    self.live_tx.send_replace(false);
1367                    return;
1368                }
1369                let attempt_started_at = Instant::now();
1370                let Some(attempt_valid_until) = attempt_started_at.checked_add(self.config.ttl)
1371                else {
1372                    self.deadline.fence();
1373                    self.live_tx.send_replace(false);
1374                    return;
1375                };
1376                let renewal = tokio::time::timeout_at(
1377                    tokio::time::Instant::from_std(valid_until),
1378                    self.store.try_acquire(self.owner, now_millis()),
1379                )
1380                .await;
1381                match renewal {
1382                    Ok(Ok(ProcessLeaseOutcome::Acquired(_))) => {
1383                        let response_at = Instant::now();
1384                        if response_at >= valid_until || response_at >= attempt_valid_until {
1385                            self.deadline.fence();
1386                            self.live_tx.send_replace(false);
1387                            return;
1388                        }
1389                        valid_until = attempt_valid_until;
1390                        self.deadline.extend_until(attempt_valid_until);
1391                    }
1392                    Ok(Ok(ProcessLeaseOutcome::Held(rival))) => {
1393                        tracing::error!(
1394                            node = self.store.node.0,
1395                            owner = %rival.owner,
1396                            term = rival.term,
1397                            "stable node identity lease was lost"
1398                        );
1399                        self.deadline.fence();
1400                        self.live_tx.send_replace(false);
1401                        return;
1402                    }
1403                    Ok(Err(error)) => {
1404                        tracing::warn!(%error, "stable node identity lease renewal failed");
1405                    }
1406                    Err(_) => {
1407                        self.deadline.fence();
1408                        self.live_tx.send_replace(false);
1409                        return;
1410                    }
1411                }
1412            }
1413        })
1414    }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420    use async_trait::async_trait;
1421    use object_store::memory::InMemory;
1422
1423    fn store(node: NodeId, ttl_ms: i64) -> ProcessLeaseStore {
1424        ProcessLeaseStore::new(Arc::new(InMemory::new()), node, ttl_ms)
1425    }
1426
1427    #[tokio::test]
1428    async fn first_acquire() {
1429        let store = store(NodeId(7), 1_000);
1430        let owner = Uuid::from_u128(1);
1431        let ProcessLeaseOutcome::Acquired(lease) = store.try_acquire(owner, 10).await.unwrap()
1432        else {
1433            panic!("empty store must be acquired");
1434        };
1435        assert_eq!(lease.node, NodeId(7));
1436        assert_eq!(lease.owner, owner);
1437        assert_eq!(lease.term, 1);
1438        assert_eq!(lease.seq, 1);
1439        assert_eq!(lease.expires_at_ms, 1_010);
1440    }
1441
1442    #[tokio::test]
1443    async fn same_incarnation_renews_without_changing_term() {
1444        let store = store(NodeId(7), 1_000);
1445        let owner = Uuid::from_u128(1);
1446        store.try_acquire(owner, 10).await.unwrap();
1447        let ProcessLeaseOutcome::Acquired(lease) = store.try_acquire(owner, 500).await.unwrap()
1448        else {
1449            panic!("live owner must renew");
1450        };
1451        assert_eq!(lease.term, 1);
1452        assert_eq!(lease.seq, 2);
1453        assert_eq!(lease.expires_at_ms, 1_500);
1454    }
1455
1456    #[tokio::test]
1457    async fn live_rival_is_denied() {
1458        let store = store(NodeId(7), 1_000);
1459        let incumbent = Uuid::from_u128(1);
1460        store.try_acquire(incumbent, 10).await.unwrap();
1461        let ProcessLeaseOutcome::Held(lease) =
1462            store.try_acquire(Uuid::from_u128(2), 500).await.unwrap()
1463        else {
1464            panic!("live incumbent must not be replaced");
1465        };
1466        assert_eq!(lease.owner, incumbent);
1467        assert_eq!(lease.term, 1);
1468    }
1469
1470    #[tokio::test]
1471    async fn expired_takeover_advances_term() {
1472        let store = store(NodeId(7), 10);
1473        store.try_acquire(Uuid::from_u128(1), 10).await.unwrap();
1474        let replacement = Uuid::from_u128(2);
1475        let ProcessLeaseOutcome::Held(incumbent) =
1476            store.try_acquire(replacement, 10_000).await.unwrap()
1477        else {
1478            panic!("rival timestamps must not authorize takeover");
1479        };
1480        let observation = store.observe_rival(&incumbent).unwrap();
1481        tokio::time::sleep(Duration::from_millis(15)).await;
1482        let ProcessLeaseOutcome::Acquired(lease) = store
1483            .try_takeover(replacement, &observation, 20)
1484            .await
1485            .unwrap()
1486        else {
1487            panic!("expired identity must be replaceable");
1488        };
1489        assert_eq!(lease.owner, replacement);
1490        assert_eq!(lease.term, 2);
1491        assert_eq!(lease.seq, 2);
1492    }
1493
1494    #[tokio::test]
1495    async fn shared_fencing_authority_fails_when_the_predecessor_renews() {
1496        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1497        let authority = Arc::new(
1498            ProcessLeaseAuthority::new(Arc::clone(&backing), Duration::from_millis(50)).unwrap(),
1499        );
1500        let store = authority.store_for(NodeId(7));
1501        let owner = Uuid::from_u128(71);
1502        store.try_acquire(owner, 1).await.unwrap();
1503        let participant = crate::checkpoint::CheckpointParticipant {
1504            node_id: 7,
1505            boot_incarnation: owner,
1506        };
1507        let fencing = authority.fence_incarnation(
1508            participant,
1509            tokio::time::Instant::now() + Duration::from_secs(1),
1510        );
1511        tokio::pin!(fencing);
1512        tokio::select! {
1513            biased;
1514            result = &mut fencing => panic!("fencing completed before its full TTL: {result:?}"),
1515            () = tokio::task::yield_now() => {}
1516        }
1517        assert!(matches!(
1518            store.try_acquire(owner, 2).await.unwrap(),
1519            ProcessLeaseOutcome::Acquired(ProcessLease { seq: 2, .. })
1520        ));
1521
1522        let error = fencing.await.unwrap_err();
1523        assert!(error.to_string().contains("renewed"), "{error}");
1524    }
1525
1526    #[tokio::test]
1527    async fn shared_fencing_authority_recovers_and_verifies_its_exact_takeover() {
1528        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1529        let authority =
1530            ProcessLeaseAuthority::new(Arc::clone(&backing), Duration::from_millis(5)).unwrap();
1531        let store = authority.store_for(NodeId(7));
1532        let owner = Uuid::from_u128(71);
1533        store.try_acquire(owner, 1).await.unwrap();
1534        let participant = crate::checkpoint::CheckpointParticipant {
1535            node_id: 7,
1536            boot_incarnation: owner,
1537        };
1538
1539        let fence = authority
1540            .fence_incarnation(
1541                participant,
1542                tokio::time::Instant::now() + Duration::from_secs(1),
1543            )
1544            .await
1545            .unwrap();
1546        assert_eq!(fence.predecessor.owner, owner);
1547        assert_ne!(fence.successor.owner, owner);
1548        assert!(authority
1549            .verify_fence(&fence, tokio::time::Instant::now() + Duration::from_secs(1))
1550            .await
1551            .unwrap());
1552
1553        let recovered = authority
1554            .fence_incarnation(
1555                participant,
1556                tokio::time::Instant::now() + Duration::from_secs(1),
1557            )
1558            .await
1559            .unwrap();
1560        assert_eq!(recovered, fence);
1561
1562        store
1563            .try_acquire(fence.successor.owner, now_millis())
1564            .await
1565            .unwrap();
1566        assert!(authority
1567            .verify_fence(&fence, tokio::time::Instant::now() + Duration::from_secs(1))
1568            .await
1569            .unwrap());
1570        assert_eq!(
1571            authority
1572                .fence_incarnation(
1573                    participant,
1574                    tokio::time::Instant::now() + Duration::from_secs(1),
1575                )
1576                .await
1577                .unwrap(),
1578            fence
1579        );
1580    }
1581
1582    #[tokio::test]
1583    async fn pruning_preserves_every_takeover_boundary_but_removes_routine_renewals() {
1584        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1585        let store = ProcessLeaseStore::new(Arc::clone(&backing), NodeId(7), 1);
1586        let first = Uuid::from_u128(71);
1587        let second = Uuid::from_u128(72);
1588        let third = Uuid::from_u128(73);
1589
1590        store.try_acquire(first, 1).await.unwrap();
1591        store.try_acquire(first, 2).await.unwrap();
1592        let ProcessLeaseOutcome::Acquired(first_head) = store.try_acquire(first, 3).await.unwrap()
1593        else {
1594            panic!("first process must renew");
1595        };
1596        let first_observation = store.observe_rival(&first_head).unwrap();
1597        tokio::time::sleep(Duration::from_millis(2)).await;
1598        let ProcessLeaseOutcome::Acquired(second_start) = store
1599            .try_takeover(second, &first_observation, 4)
1600            .await
1601            .unwrap()
1602        else {
1603            panic!("second process must take over");
1604        };
1605        store.try_acquire(second, 5).await.unwrap();
1606        let ProcessLeaseOutcome::Acquired(second_head) =
1607            store.try_acquire(second, 6).await.unwrap()
1608        else {
1609            panic!("second process must renew");
1610        };
1611        let second_observation = store.observe_rival(&second_head).unwrap();
1612        tokio::time::sleep(Duration::from_millis(2)).await;
1613        let ProcessLeaseOutcome::Acquired(third_start) = store
1614            .try_takeover(third, &second_observation, 7)
1615            .await
1616            .unwrap()
1617        else {
1618            panic!("third process must take over");
1619        };
1620        let ProcessLeaseOutcome::Acquired(third_head) = store.try_acquire(third, 8).await.unwrap()
1621        else {
1622            panic!("third process must renew");
1623        };
1624
1625        ProcessLeaseStore::prune_history_batch(&backing, NodeId(7))
1626            .await
1627            .unwrap();
1628        assert_eq!(store.list_seqs().await.unwrap(), vec![7, 8]);
1629        let first_fence = ProcessLeaseFence::new(first_head, second_start).unwrap();
1630        assert_eq!(
1631            store.find_takeover_from(first).await.unwrap().unwrap(),
1632            first_fence
1633        );
1634        assert_eq!(
1635            store.find_takeover_from(second).await.unwrap().unwrap(),
1636            ProcessLeaseFence::new(second_head, third_start).unwrap()
1637        );
1638        assert_eq!(store.load().await.unwrap(), Some(third_head));
1639        let authority = ProcessLeaseAuthority::new(backing, Duration::from_millis(1)).unwrap();
1640        assert!(authority
1641            .verify_fence(
1642                &first_fence,
1643                tokio::time::Instant::now() + Duration::from_secs(1)
1644            )
1645            .await
1646            .unwrap());
1647    }
1648
1649    #[tokio::test]
1650    async fn oversized_history_can_prune_back_below_the_normal_scan_bound() {
1651        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1652        let node = NodeId(7);
1653        let owner = Uuid::from_u128(71);
1654        let record_count = PROCESS_LEASE_MAX_LIST_RECORDS + 4;
1655        let expected_head = u64::try_from(record_count).unwrap();
1656        for sequence in 1..=record_count {
1657            let sequence = u64::try_from(sequence).unwrap();
1658            let lease = ProcessLease {
1659                node,
1660                owner,
1661                term: 1,
1662                seq: sequence,
1663                expires_at_ms: i64::try_from(sequence).unwrap(),
1664            };
1665            backing
1666                .put_opts(
1667                    &lease_path(node, sequence),
1668                    PutPayload::from(Bytes::from(serde_json::to_vec(&lease).unwrap())),
1669                    PutOptions {
1670                        mode: PutMode::Create,
1671                        ..PutOptions::default()
1672                    },
1673                )
1674                .await
1675                .unwrap();
1676        }
1677        let store = ProcessLeaseStore::new(Arc::clone(&backing), node, 1);
1678        assert!(store.list_seqs().await.is_err());
1679
1680        assert!(!ProcessLeaseStore::prune_history_batch(&backing, node)
1681            .await
1682            .unwrap());
1683
1684        let sequences = store.list_seqs().await.unwrap();
1685        assert_eq!(sequences.first(), Some(&257));
1686        assert_eq!(sequences.last(), Some(&expected_head));
1687        assert_eq!(store.load().await.unwrap().unwrap().seq, expected_head);
1688    }
1689
1690    #[tokio::test]
1691    async fn shared_fencing_authority_rejects_missing_predecessor_history() {
1692        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1693        let authority =
1694            ProcessLeaseAuthority::new(Arc::clone(&backing), Duration::from_millis(5)).unwrap();
1695        let store = authority.store_for(NodeId(7));
1696        let owner = Uuid::from_u128(71);
1697        store.try_acquire(owner, 1).await.unwrap();
1698        let participant = crate::checkpoint::CheckpointParticipant {
1699            node_id: 7,
1700            boot_incarnation: owner,
1701        };
1702        let fence = authority
1703            .fence_incarnation(
1704                participant,
1705                tokio::time::Instant::now() + Duration::from_secs(1),
1706            )
1707            .await
1708            .unwrap();
1709        ProcessLeaseStore::prune_history_batch(&backing, NodeId(7))
1710            .await
1711            .unwrap();
1712        backing
1713            .delete(&fence_path(NodeId(7), fence.predecessor.owner))
1714            .await
1715            .unwrap();
1716        backing
1717            .delete(&successor_fence_path(
1718                NodeId(7),
1719                fence.successor.owner,
1720                fence.successor.term,
1721            ))
1722            .await
1723            .unwrap();
1724        backing
1725            .delete(&lease_path(NodeId(7), fence.predecessor.seq))
1726            .await
1727            .unwrap();
1728
1729        let error = authority
1730            .fence_incarnation(
1731                participant,
1732                tokio::time::Instant::now() + Duration::from_secs(1),
1733            )
1734            .await
1735            .unwrap_err();
1736        assert!(error.to_string().contains("missing"), "{error}");
1737    }
1738
1739    #[tokio::test]
1740    async fn delayed_previous_owner_renewal_cannot_overwrite_takeover() {
1741        let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1742        let authority = ProcessLeaseStore::new(Arc::clone(&object_store), NodeId(7), 1);
1743        let first_owner = Uuid::from_u128(1);
1744        let ProcessLeaseOutcome::Acquired(first) =
1745            authority.try_acquire(first_owner, 1).await.unwrap()
1746        else {
1747            panic!("first owner must acquire the lease");
1748        };
1749        let delayed_renewal = ProcessLease {
1750            node: first.node,
1751            owner: first.owner,
1752            term: first.term,
1753            seq: 2,
1754            expires_at_ms: 100,
1755        };
1756        let release = Arc::new(tokio::sync::Semaphore::new(0));
1757        let delayed_server_put = {
1758            let object_store = Arc::clone(&object_store);
1759            let release = Arc::clone(&release);
1760            tokio::spawn(async move {
1761                release.acquire().await.unwrap().forget();
1762                object_store
1763                    .put_opts(
1764                        &lease_path(NodeId(7), 2),
1765                        PutPayload::from(Bytes::from(
1766                            serde_json::to_vec(&delayed_renewal).unwrap(),
1767                        )),
1768                        PutOptions {
1769                            mode: PutMode::Create,
1770                            ..PutOptions::default()
1771                        },
1772                    )
1773                    .await
1774            })
1775        };
1776
1777        let replacement = Uuid::from_u128(2);
1778        let ProcessLeaseOutcome::Held(incumbent) =
1779            authority.try_acquire(replacement, 10).await.unwrap()
1780        else {
1781            panic!("replacement must observe the incumbent");
1782        };
1783        let observation = authority.observe_rival(&incumbent).unwrap();
1784        tokio::time::sleep(Duration::from_millis(3)).await;
1785        let ProcessLeaseOutcome::Acquired(takeover) = authority
1786            .try_takeover(replacement, &observation, 20)
1787            .await
1788            .unwrap()
1789        else {
1790            panic!("replacement must win sequence two");
1791        };
1792        release.add_permits(1);
1793        assert!(matches!(
1794            delayed_server_put.await.unwrap(),
1795            Err(object_store::Error::AlreadyExists { .. }
1796                | object_store::Error::Precondition { .. })
1797        ));
1798        assert_eq!(authority.load().await.unwrap(), Some(takeover));
1799    }
1800
1801    struct PutBarrierStore {
1802        inner: Arc<dyn ObjectStore>,
1803        path: OsPath,
1804        arrivals: Option<tokio::sync::Barrier>,
1805        delay_response: bool,
1806        committed: tokio::sync::Semaphore,
1807        release: tokio::sync::Semaphore,
1808        conflict_as_precondition: bool,
1809    }
1810
1811    impl std::fmt::Debug for PutBarrierStore {
1812        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1813            formatter
1814                .debug_struct("PutBarrierStore")
1815                .finish_non_exhaustive()
1816        }
1817    }
1818
1819    impl std::fmt::Display for PutBarrierStore {
1820        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1821            formatter.write_str("PutBarrierStore")
1822        }
1823    }
1824
1825    #[async_trait]
1826    impl ObjectStore for PutBarrierStore {
1827        async fn put_opts(
1828            &self,
1829            location: &OsPath,
1830            payload: PutPayload,
1831            options: PutOptions,
1832        ) -> object_store::Result<object_store::PutResult> {
1833            if location == &self.path {
1834                if let Some(arrivals) = &self.arrivals {
1835                    arrivals.wait().await;
1836                }
1837            }
1838            let result = self.inner.put_opts(location, payload, options).await;
1839            if location == &self.path && self.delay_response {
1840                self.committed.add_permits(1);
1841                self.release
1842                    .acquire()
1843                    .await
1844                    .map_err(|error| object_store::Error::Generic {
1845                        store: "PutBarrierStore",
1846                        source: Box::new(error),
1847                    })?
1848                    .forget();
1849            }
1850            if self.conflict_as_precondition
1851                && matches!(&result, Err(object_store::Error::AlreadyExists { .. }))
1852            {
1853                return Err(object_store::Error::Precondition {
1854                    path: location.to_string(),
1855                    source: Box::new(std::io::Error::other("injected create precondition")),
1856                });
1857            }
1858            result
1859        }
1860
1861        async fn put_multipart_opts(
1862            &self,
1863            location: &OsPath,
1864            options: object_store::PutMultipartOptions,
1865        ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1866            self.inner.put_multipart_opts(location, options).await
1867        }
1868
1869        async fn get_opts(
1870            &self,
1871            location: &OsPath,
1872            options: object_store::GetOptions,
1873        ) -> object_store::Result<object_store::GetResult> {
1874            self.inner.get_opts(location, options).await
1875        }
1876
1877        fn delete_stream(
1878            &self,
1879            locations: futures::stream::BoxStream<'static, object_store::Result<OsPath>>,
1880        ) -> futures::stream::BoxStream<'static, object_store::Result<OsPath>> {
1881            self.inner.delete_stream(locations)
1882        }
1883
1884        fn list(
1885            &self,
1886            prefix: Option<&OsPath>,
1887        ) -> futures::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1888        {
1889            self.inner.list(prefix)
1890        }
1891
1892        async fn list_with_delimiter(
1893            &self,
1894            prefix: Option<&OsPath>,
1895        ) -> object_store::Result<object_store::ListResult> {
1896            self.inner.list_with_delimiter(prefix).await
1897        }
1898
1899        async fn copy_opts(
1900            &self,
1901            from: &OsPath,
1902            to: &OsPath,
1903            options: object_store::CopyOptions,
1904        ) -> object_store::Result<()> {
1905            self.inner.copy_opts(from, to, options).await
1906        }
1907    }
1908
1909    struct GetBarrierStore {
1910        inner: Arc<dyn ObjectStore>,
1911        path: OsPath,
1912        armed: AtomicBool,
1913        entered: tokio::sync::Semaphore,
1914        release: tokio::sync::Semaphore,
1915    }
1916
1917    impl std::fmt::Debug for GetBarrierStore {
1918        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1919            formatter
1920                .debug_struct("GetBarrierStore")
1921                .finish_non_exhaustive()
1922        }
1923    }
1924
1925    impl std::fmt::Display for GetBarrierStore {
1926        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1927            formatter.write_str("GetBarrierStore")
1928        }
1929    }
1930
1931    #[async_trait]
1932    impl ObjectStore for GetBarrierStore {
1933        async fn put_opts(
1934            &self,
1935            location: &OsPath,
1936            payload: PutPayload,
1937            options: PutOptions,
1938        ) -> object_store::Result<object_store::PutResult> {
1939            self.inner.put_opts(location, payload, options).await
1940        }
1941
1942        async fn put_multipart_opts(
1943            &self,
1944            location: &OsPath,
1945            options: object_store::PutMultipartOptions,
1946        ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1947            self.inner.put_multipart_opts(location, options).await
1948        }
1949
1950        async fn get_opts(
1951            &self,
1952            location: &OsPath,
1953            options: object_store::GetOptions,
1954        ) -> object_store::Result<object_store::GetResult> {
1955            if location == &self.path && self.armed.swap(false, Ordering::AcqRel) {
1956                self.entered.add_permits(1);
1957                self.release
1958                    .acquire()
1959                    .await
1960                    .map_err(|error| object_store::Error::Generic {
1961                        store: "GetBarrierStore",
1962                        source: Box::new(error),
1963                    })?
1964                    .forget();
1965            }
1966            self.inner.get_opts(location, options).await
1967        }
1968
1969        fn delete_stream(
1970            &self,
1971            locations: futures::stream::BoxStream<'static, object_store::Result<OsPath>>,
1972        ) -> futures::stream::BoxStream<'static, object_store::Result<OsPath>> {
1973            self.inner.delete_stream(locations)
1974        }
1975
1976        fn list(
1977            &self,
1978            prefix: Option<&OsPath>,
1979        ) -> futures::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1980        {
1981            self.inner.list(prefix)
1982        }
1983
1984        async fn list_with_delimiter(
1985            &self,
1986            prefix: Option<&OsPath>,
1987        ) -> object_store::Result<object_store::ListResult> {
1988            self.inner.list_with_delimiter(prefix).await
1989        }
1990
1991        async fn copy_opts(
1992            &self,
1993            from: &OsPath,
1994            to: &OsPath,
1995            options: object_store::CopyOptions,
1996        ) -> object_store::Result<()> {
1997            self.inner.copy_opts(from, to, options).await
1998        }
1999    }
2000
2001    #[tokio::test]
2002    async fn participant_term_verification_rechecks_the_head_after_fence_evidence() {
2003        let node = NodeId(7);
2004        let first = Uuid::from_u128(71);
2005        let second = Uuid::from_u128(72);
2006        let third = Uuid::from_u128(73);
2007        let backing: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
2008        let store = ProcessLeaseStore::new(Arc::clone(&backing), node, 1);
2009        let ProcessLeaseOutcome::Acquired(first_head) = store.try_acquire(first, 1).await.unwrap()
2010        else {
2011            panic!("first process must acquire the lease");
2012        };
2013        let first_observation = store.observe_rival(&first_head).unwrap();
2014        tokio::time::sleep(Duration::from_millis(2)).await;
2015        let ProcessLeaseOutcome::Acquired(second_head) = store
2016            .try_takeover(second, &first_observation, 2)
2017            .await
2018            .unwrap()
2019        else {
2020            panic!("second process must take over the lease");
2021        };
2022        let second_observation = store.observe_rival(&second_head).unwrap();
2023        tokio::time::sleep(Duration::from_millis(2)).await;
2024
2025        let gated = Arc::new(GetBarrierStore {
2026            inner: Arc::clone(&backing),
2027            path: successor_fence_path(node, second, second_head.term),
2028            armed: AtomicBool::new(true),
2029            entered: tokio::sync::Semaphore::new(0),
2030            release: tokio::sync::Semaphore::new(0),
2031        });
2032        let authority_store: Arc<dyn ObjectStore> = gated.clone();
2033        let authority = Arc::new(
2034            ProcessLeaseAuthority::new(authority_store, Duration::from_millis(1)).unwrap(),
2035        );
2036        let participant = crate::checkpoint::CheckpointParticipant {
2037            node_id: node.0,
2038            boot_incarnation: second,
2039        };
2040        let verify = {
2041            let authority = Arc::clone(&authority);
2042            tokio::spawn(async move {
2043                authority
2044                    .verify_current_participant_term(
2045                        participant,
2046                        second_head.term,
2047                        tokio::time::Instant::now() + Duration::from_secs(1),
2048                    )
2049                    .await
2050            })
2051        };
2052        tokio::time::timeout(Duration::from_secs(1), gated.entered.acquire())
2053            .await
2054            .unwrap()
2055            .unwrap()
2056            .forget();
2057        assert!(matches!(
2058            store
2059                .try_takeover(third, &second_observation, 3)
2060                .await
2061                .unwrap(),
2062            ProcessLeaseOutcome::Acquired(ProcessLease { owner, term: 3, .. }) if owner == third
2063        ));
2064        gated.release.add_permits(1);
2065
2066        assert!(!verify.await.unwrap().unwrap());
2067    }
2068
2069    #[tokio::test]
2070    async fn create_cas_has_one_winner() {
2071        let node = NodeId(7);
2072        let racing: Arc<dyn ObjectStore> = Arc::new(PutBarrierStore {
2073            inner: Arc::new(InMemory::new()),
2074            path: lease_path(node, 1),
2075            arrivals: Some(tokio::sync::Barrier::new(2)),
2076            delay_response: false,
2077            committed: tokio::sync::Semaphore::new(0),
2078            release: tokio::sync::Semaphore::new(0),
2079            conflict_as_precondition: true,
2080        });
2081        let first = ProcessLeaseStore::new(Arc::clone(&racing), node, 1_000);
2082        let second = ProcessLeaseStore::new(racing, node, 1_000);
2083        let (left, right) = tokio::join!(
2084            first.try_acquire(Uuid::from_u128(1), 10),
2085            second.try_acquire(Uuid::from_u128(2), 10)
2086        );
2087        let (left, right) = (left.unwrap(), right.unwrap());
2088        let winners = usize::from(matches!(&left, ProcessLeaseOutcome::Acquired(_)))
2089            + usize::from(matches!(&right, ProcessLeaseOutcome::Acquired(_)));
2090        assert_eq!(winners, 1);
2091        let durable = first.load().await.unwrap().unwrap();
2092        assert!(matches!(
2093            (left, right),
2094            (ProcessLeaseOutcome::Acquired(ref won), ProcessLeaseOutcome::Held(ref held))
2095                | (ProcessLeaseOutcome::Held(ref held), ProcessLeaseOutcome::Acquired(ref won))
2096                if won == &durable && held == &durable
2097        ));
2098    }
2099
2100    #[tokio::test]
2101    async fn local_filesystem_supports_create_only_renewal() {
2102        let temp = tempfile::tempdir().unwrap();
2103        let filesystem: Arc<dyn ObjectStore> =
2104            Arc::new(object_store::local::LocalFileSystem::new_with_prefix(temp.path()).unwrap());
2105        let store = ProcessLeaseStore::new(filesystem, NodeId(7), 1_000);
2106        let owner = Uuid::from_u128(1);
2107        assert!(matches!(
2108            store.try_acquire(owner, 10).await.unwrap(),
2109            ProcessLeaseOutcome::Acquired(_)
2110        ));
2111        assert!(matches!(
2112            store.try_acquire(owner, 500).await.unwrap(),
2113            ProcessLeaseOutcome::Acquired(ProcessLease { seq: 2, .. })
2114        ));
2115    }
2116
2117    #[tokio::test]
2118    async fn renewal_history_keeps_only_latest_and_predecessor() {
2119        let store = store(NodeId(7), 1_000);
2120        let owner = Uuid::from_u128(1);
2121        for now in 0..8 {
2122            assert!(matches!(
2123                store.try_acquire(owner, now).await.unwrap(),
2124                ProcessLeaseOutcome::Acquired(_)
2125            ));
2126        }
2127        ProcessLeaseStore::prune_history(&store.store, NodeId(7))
2128            .await
2129            .unwrap();
2130        assert_eq!(store.list_seqs().await.unwrap(), vec![7, 8]);
2131        assert_eq!(store.load().await.unwrap().unwrap().seq, 8);
2132    }
2133
2134    #[tokio::test]
2135    async fn an_unhealthy_pruner_is_repaired_before_the_next_renewal() {
2136        let store = store(NodeId(7), 1_000);
2137        let owner = Uuid::from_u128(1);
2138        for now in 0..8 {
2139            store.try_acquire(owner, now).await.unwrap();
2140        }
2141        store.prune_healthy.store(false, Ordering::Release);
2142
2143        assert!(matches!(
2144            store.try_acquire(owner, 9).await.unwrap(),
2145            ProcessLeaseOutcome::Acquired(ProcessLease { seq: 9, .. })
2146        ));
2147        assert_eq!(store.list_seqs().await.unwrap(), vec![7, 8, 9]);
2148        assert!(store.prune_healthy.load(Ordering::Acquire));
2149    }
2150
2151    #[test]
2152    fn renewal_manager_requires_the_exact_store_ttl() {
2153        let store = Arc::new(ProcessLeaseStore::new(
2154            Arc::new(InMemory::new()),
2155            NodeId(7),
2156            10,
2157        ));
2158        let owner = Uuid::from_u128(1);
2159        let initial = ProcessLease {
2160            node: NodeId(7),
2161            owner,
2162            term: 1,
2163            seq: 1,
2164            expires_at_ms: 10,
2165        };
2166        for ttl in [
2167            Duration::from_millis(20),
2168            Duration::from_millis(10) + Duration::from_nanos(1),
2169        ] {
2170            let Err(error) = ProcessLeaseManager::new(
2171                Arc::clone(&store),
2172                owner,
2173                ProcessLeaseConfig {
2174                    ttl,
2175                    renew_interval: Duration::from_millis(2),
2176                },
2177                Instant::now(),
2178                &initial,
2179            ) else {
2180                panic!("mismatched manager TTL must be rejected");
2181            };
2182            assert!(error.to_string().contains("exact store TTL"), "{error}");
2183        }
2184    }
2185
2186    #[tokio::test]
2187    async fn delayed_acquisition_response_cannot_publish_a_fresh_local_deadline() {
2188        let node = NodeId(7);
2189        let owner = Uuid::from_u128(1);
2190        let ttl = Duration::from_millis(30);
2191        let delayed = Arc::new(PutBarrierStore {
2192            inner: Arc::new(InMemory::new()),
2193            path: lease_path(node, 1),
2194            arrivals: None,
2195            delay_response: true,
2196            committed: tokio::sync::Semaphore::new(0),
2197            release: tokio::sync::Semaphore::new(0),
2198            conflict_as_precondition: false,
2199        });
2200        let object_store: Arc<dyn ObjectStore> = delayed.clone();
2201        let store = Arc::new(ProcessLeaseStore::new(object_store, node, 30));
2202        let acquisition_store = Arc::clone(&store);
2203        let acquisition_started_at = Instant::now();
2204        let acquisition =
2205            tokio::spawn(async move { acquisition_store.try_acquire(owner, 0).await });
2206
2207        tokio::time::timeout(Duration::from_secs(1), delayed.committed.acquire())
2208            .await
2209            .unwrap()
2210            .unwrap()
2211            .forget();
2212        assert!(matches!(
2213            store.load().await.unwrap(),
2214            Some(ProcessLease { owner: current, .. }) if current == owner
2215        ));
2216        tokio::time::sleep(ttl + Duration::from_millis(20)).await;
2217        delayed.release.add_permits(1);
2218        let ProcessLeaseOutcome::Acquired(initial) =
2219            tokio::time::timeout(Duration::from_secs(1), acquisition)
2220                .await
2221                .unwrap()
2222                .unwrap()
2223                .unwrap()
2224        else {
2225            panic!("delayed durable acquisition must still return its committed lease");
2226        };
2227
2228        let error = ProcessLeaseManager::new(
2229            store,
2230            owner,
2231            ProcessLeaseConfig {
2232                ttl,
2233                renew_interval: Duration::from_millis(5),
2234            },
2235            acquisition_started_at,
2236            &initial,
2237        )
2238        .err()
2239        .expect("an acquisition response after its TTL must fail closed");
2240        assert!(
2241            error.to_string().contains("response arrived after"),
2242            "{error}"
2243        );
2244    }
2245
2246    #[cfg(feature = "cluster")]
2247    #[tokio::test]
2248    async fn delayed_first_poll_cannot_renew_after_initial_deadline() {
2249        let store = Arc::new(ProcessLeaseStore::new(
2250            Arc::new(InMemory::new()),
2251            NodeId(7),
2252            10,
2253        ));
2254        let owner = Uuid::from_u128(1);
2255        let acquisition_started_at = Instant::now();
2256        let ProcessLeaseOutcome::Acquired(initial) = store.try_acquire(owner, 0).await.unwrap()
2257        else {
2258            panic!("initial process lease must be acquired");
2259        };
2260        let manager = ProcessLeaseManager::new(
2261            Arc::clone(&store),
2262            owner,
2263            ProcessLeaseConfig {
2264                ttl: Duration::from_millis(10),
2265                renew_interval: Duration::from_millis(2),
2266            },
2267            acquisition_started_at,
2268            &initial,
2269        )
2270        .unwrap();
2271        let deadline = manager.deadline();
2272        let live = manager.live_watch();
2273
2274        tokio::time::sleep(Duration::from_millis(30)).await;
2275        assert!(!deadline.is_live());
2276        tokio::time::timeout(
2277            Duration::from_millis(100),
2278            manager.spawn(tokio_util::sync::CancellationToken::new()),
2279        )
2280        .await
2281        .unwrap()
2282        .unwrap();
2283
2284        assert!(!*live.borrow());
2285        assert_eq!(store.load().await.unwrap(), Some(initial));
2286    }
2287
2288    #[cfg(feature = "cluster")]
2289    #[tokio::test]
2290    async fn shutdown_fences_the_published_process_grant() {
2291        let store = Arc::new(ProcessLeaseStore::new(
2292            Arc::new(InMemory::new()),
2293            NodeId(7),
2294            100,
2295        ));
2296        let owner = Uuid::from_u128(1);
2297        let acquisition_started_at = Instant::now();
2298        let ProcessLeaseOutcome::Acquired(initial) = store.try_acquire(owner, 0).await.unwrap()
2299        else {
2300            panic!("initial process lease must be acquired");
2301        };
2302        let manager = ProcessLeaseManager::new(
2303            store,
2304            owner,
2305            ProcessLeaseConfig {
2306                ttl: Duration::from_millis(100),
2307                renew_interval: Duration::from_millis(20),
2308            },
2309            acquisition_started_at,
2310            &initial,
2311        )
2312        .unwrap();
2313        let deadline = manager.deadline();
2314        let live = manager.live_watch();
2315        let shutdown = tokio_util::sync::CancellationToken::new();
2316        shutdown.cancel();
2317
2318        manager.spawn(shutdown).await.unwrap();
2319
2320        assert!(!deadline.is_live());
2321        assert!(!*live.borrow());
2322    }
2323}