Skip to main content

harn_hostlib/
host_lease.rs

1//! Atomic, machine-global leases for scarce host resources.
2//!
3//! The store is deliberately independent of project event logs: two Harn
4//! processes in different worktrees must still agree on one owner. SQLite's
5//! immediate transaction supplies the cross-process compare-and-set. A file
6//! watcher on the database directory wakes waiting callers after release or
7//! renewal; expiry and caller deadlines remain timer wakeups.
8
9mod execution;
10
11pub use execution::{
12    HostLeaseCargoExecutionContext, HostLeaseExecutionContext, HostLeaseOperationKind,
13    HostLeasePathIdentity, HostLeaseProcessExit, HostLeaseRunLaunchFailure, HostLeaseRunReceipt,
14    HostLeaseRunReleaseOutcome, HostLeaseRunStartFailure, HostLeaseRunState,
15};
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19use std::sync::{mpsc, Arc};
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22use notify::{RecursiveMode, Watcher};
23use rusqlite::{
24    params, Connection, ErrorCode, OptionalExtension, Transaction, TransactionBehavior,
25};
26use serde::{Deserialize, Serialize};
27use sysinfo::System;
28use uuid::Uuid;
29
30/// Overrides the machine-global directory containing host lease state.
31pub const HOST_LEASE_ROOT_ENV: &str = "HARN_HOST_LEASE_ROOT";
32const HARN_HOME_ENV: &str = "HARN_HOME";
33const LEASE_DB_FILE: &str = "host-leases.sqlite";
34const RUN_RECEIPTS_DIR: &str = "receipts";
35const SQLITE_MUTATION_BUSY_TIMEOUT: Duration = Duration::from_secs(1);
36const REGISTRY_BUSY_RETRY_INTERVAL: Duration = Duration::from_millis(250);
37const PROCESS_LIVENESS_RECHECK_INTERVAL: Duration = Duration::from_secs(5);
38const SCHEMA_VERSION: u32 = 2;
39const RUN_RECEIPT_SCHEMA_VERSION: u32 = 2;
40const WHOLE_MACHINE_RESOURCE_CLASS: &str = "whole-machine";
41
42/// Failures produced while validating or mutating host lease state.
43#[derive(Debug, thiserror::Error)]
44pub enum HostLeaseError {
45    /// The caller supplied an invalid or unsafe contract value.
46    #[error("invalid host lease request: {0}")]
47    InvalidRequest(String),
48    /// The state directory could not be read or written.
49    #[error("host lease state I/O failed: {0}")]
50    Io(#[from] std::io::Error),
51    /// SQLite could not complete an atomic lease operation.
52    #[error("host lease database failed: {0}")]
53    Database(#[from] rusqlite::Error),
54    /// Receipt metadata could not be encoded or decoded.
55    #[error("host lease metadata serialization failed: {0}")]
56    Serialization(#[from] serde_json::Error),
57    /// The cross-process filesystem watcher failed.
58    #[error("host lease watcher failed: {0}")]
59    Watch(String),
60    /// The system clock cannot produce a Unix timestamp.
61    #[error("system clock is before the Unix epoch")]
62    Clock,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66enum ProcessObservation {
67    Alive { identity: u64 },
68    Dead,
69    Unknown,
70}
71
72trait ProcessInspector: std::fmt::Debug + Send + Sync {
73    fn observe(&self, pid: u32) -> ProcessObservation;
74}
75
76#[derive(Debug)]
77struct SystemProcessInspector;
78
79impl ProcessInspector for SystemProcessInspector {
80    fn observe(&self, pid: u32) -> ProcessObservation {
81        match crate::process_liveness::process_liveness(pid) {
82            crate::process_liveness::ProcessLiveness::Dead => ProcessObservation::Dead,
83            crate::process_liveness::ProcessLiveness::Unknown => ProcessObservation::Unknown,
84            crate::process_liveness::ProcessLiveness::Alive => {
85                crate::process_liveness::process_identity(pid)
86                    .map_or(ProcessObservation::Unknown, |identity| {
87                        ProcessObservation::Alive { identity }
88                    })
89            }
90        }
91    }
92}
93
94/// Scheduling class attached to a lease and its receipts.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum HostLeasePriorityClass {
98    /// User-facing work that should remain latency-sensitive.
99    Interactive,
100    /// Authoritative measurement that must never be preempted or expire mid-run.
101    Measurement,
102    /// Build or verification work that may defer behind interactive/measurement work.
103    CiVerify,
104    #[default]
105    /// Background work that should run only when higher-priority work is absent.
106    Deferrable,
107}
108
109impl HostLeasePriorityClass {
110    /// Stable wire spelling used by SQLite, JSON receipts, and CLI output.
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Interactive => "interactive",
114            Self::Measurement => "measurement",
115            Self::CiVerify => "ci-verify",
116            Self::Deferrable => "deferrable",
117        }
118    }
119
120    fn parse(raw: &str) -> Result<Self, HostLeaseError> {
121        match raw {
122            "interactive" => Ok(Self::Interactive),
123            "measurement" => Ok(Self::Measurement),
124            "ci-verify" => Ok(Self::CiVerify),
125            "deferrable" => Ok(Self::Deferrable),
126            other => Err(HostLeaseError::InvalidRequest(format!(
127                "unknown priority class `{other}`"
128            ))),
129        }
130    }
131}
132
133/// Typed class for a scarce machine resource.
134///
135/// The initial store is intentionally capacity-one per class. Keeping the
136/// class separate from the machine name lets future schedulers add a new
137/// resource kind without inventing another registry or encoding policy in a
138/// caller-chosen string.
139#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum HostLeaseResourceClass {
142    /// Backward-compatible whole-machine lease used by existing callers.
143    #[default]
144    WholeMachine,
145    /// CPU-, linker-, and cache-intensive Rust build or verification work.
146    RustHeavy,
147}
148
149/// Central capacity and wire-name policy for one machine resource class.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct HostLeaseResourceDefinition {
152    /// Stable storage and wire spelling.
153    pub name: &'static str,
154    /// Maximum simultaneous holders on one machine.
155    pub capacity: u16,
156}
157
158const HOST_LEASE_RESOURCE_DEFINITIONS: [HostLeaseResourceDefinition; 2] = [
159    HostLeaseResourceDefinition {
160        name: WHOLE_MACHINE_RESOURCE_CLASS,
161        capacity: 1,
162    },
163    HostLeaseResourceDefinition {
164        name: "rust-heavy",
165        capacity: 1,
166    },
167];
168
169impl HostLeaseResourceClass {
170    /// Owning resource policy entry.
171    pub const fn definition(self) -> &'static HostLeaseResourceDefinition {
172        match self {
173            Self::WholeMachine => &HOST_LEASE_RESOURCE_DEFINITIONS[0],
174            Self::RustHeavy => &HOST_LEASE_RESOURCE_DEFINITIONS[1],
175        }
176    }
177
178    /// Stable storage and wire spelling.
179    pub const fn as_str(self) -> &'static str {
180        self.definition().name
181    }
182
183    /// Configured capacity for the initial local resource registry.
184    ///
185    /// Capacity is centralized on the resource definition rather than copied
186    /// into callers. The v1 SQLite key remains deliberately capacity-one.
187    pub const fn capacity(self) -> u16 {
188        self.definition().capacity
189    }
190
191    fn parse(raw: &str) -> Result<Self, HostLeaseError> {
192        match raw {
193            WHOLE_MACHINE_RESOURCE_CLASS => Ok(Self::WholeMachine),
194            "rust-heavy" => Ok(Self::RustHeavy),
195            other => Err(HostLeaseError::InvalidRequest(format!(
196                "unknown resource class `{other}`"
197            ))),
198        }
199    }
200}
201
202/// Names one capacity-one resource on a machine.
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204pub struct HostLeaseResourceKey {
205    /// Machine identity. This retains the historic `host` name at public CLI
206    /// boundaries for compatibility.
207    pub machine: String,
208    /// Independent exclusive resource class on that machine.
209    pub resource_class: HostLeaseResourceClass,
210}
211
212impl HostLeaseResourceKey {
213    fn normalize(
214        machine: &str,
215        resource_class: HostLeaseResourceClass,
216    ) -> Result<Self, HostLeaseError> {
217        Ok(Self {
218            machine: normalize_component("host", machine)?,
219            resource_class,
220        })
221    }
222}
223
224/// Request to acquire one exclusive host resource.
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
226pub struct HostLeaseRequest {
227    /// Machine resource name, normally the local hostname.
228    pub host: String,
229    #[serde(default)]
230    /// Resource class to acquire. Omitted legacy requests remain
231    /// whole-machine leases.
232    pub resource_class: HostLeaseResourceClass,
233    #[serde(default)]
234    /// Typed, redacted workload identity for supervised executions.
235    pub execution_context: Option<HostLeaseExecutionContext>,
236    /// Stable caller identity shown in contention receipts.
237    pub owner: String,
238    #[serde(default)]
239    /// Scheduling class recorded with the lease.
240    pub priority_class: HostLeasePriorityClass,
241    /// `None` means no wall-clock expiry. Non-expiring leases require an
242    /// owner PID so a later caller can recover after an owner crash without
243    /// expiring a healthy measurement mid-run.
244    #[serde(default)]
245    pub ttl_ms: Option<u64>,
246    #[serde(default)]
247    /// Process that owns the work, used with its start time for crash recovery.
248    pub owner_pid: Option<u32>,
249    #[serde(default)]
250    /// Human-readable reason for acquiring the machine.
251    pub reason: Option<String>,
252    #[serde(default)]
253    /// Structured caller metadata preserved without interpretation.
254    pub metadata: BTreeMap<String, String>,
255}
256
257/// Token-bearing authority for an active exclusive host lease.
258#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259pub struct HostLeaseHandle {
260    /// Contract schema version.
261    pub schema_version: u32,
262    /// Machine resource name.
263    pub host: String,
264    #[serde(default)]
265    /// Resource class held on this host.
266    pub resource_class: HostLeaseResourceClass,
267    #[serde(default)]
268    /// Typed, redacted workload identity. Legacy manual leases omit it.
269    pub execution_context: Option<HostLeaseExecutionContext>,
270    /// Unforgeable token required to renew or release this lease.
271    pub lease_id: String,
272    /// Stable caller identity.
273    pub owner: String,
274    /// Scheduling class attached at acquisition.
275    pub priority_class: HostLeasePriorityClass,
276    /// Acquisition timestamp in Unix milliseconds.
277    pub acquired_at_ms: i64,
278    /// Most recent renewal timestamp in Unix milliseconds.
279    pub updated_at_ms: i64,
280    #[serde(default)]
281    /// Optional wall-clock expiry; absent for protected measurement leases.
282    pub expires_at_ms: Option<i64>,
283    #[serde(default)]
284    /// Optional local owner PID.
285    pub owner_pid: Option<u32>,
286    #[serde(default)]
287    /// Native-resolution owner process identity, preventing PID-reuse liveness.
288    pub owner_process_identity: Option<u64>,
289    #[serde(default)]
290    /// Human-readable acquisition reason.
291    pub reason: Option<String>,
292    #[serde(default)]
293    /// Structured caller metadata.
294    pub metadata: BTreeMap<String, String>,
295}
296
297/// Terminal result of an acquire attempt.
298#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum HostLeaseAcquireStatus {
301    /// The caller atomically became the owner.
302    Acquired,
303    /// Another live owner still holds the host.
304    Deferred,
305}
306
307/// Stable reason an acquisition did not become the owner.
308#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub enum HostLeaseDeferReason {
310    /// Another live lease owns the same host resource.
311    #[serde(rename = "host_lease_contended")]
312    Contended,
313    /// Another registry transaction briefly owns SQLite's write lock.
314    #[serde(rename = "host_lease_registry_busy")]
315    RegistryBusy,
316}
317
318impl HostLeaseDeferReason {
319    /// Stable wire spelling used by CLI error envelopes.
320    pub const fn as_str(self) -> &'static str {
321        match self {
322            Self::Contended => "host_lease_contended",
323            Self::RegistryBusy => "host_lease_registry_busy",
324        }
325    }
326}
327
328/// Typed evidence explaining why an acquire attempt deferred.
329#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
330pub struct HostLeaseDeferReceipt {
331    /// Contended machine resource.
332    pub host: String,
333    #[serde(default)]
334    /// Resource class that remains contended.
335    pub resource_class: HostLeaseResourceClass,
336    /// Stable machine-readable reason.
337    pub deferred_reason: HostLeaseDeferReason,
338    /// Observation timestamp in Unix milliseconds.
339    pub observed_at_ms: i64,
340    #[serde(default)]
341    /// Earliest known state transition, normally the current lease expiry.
342    pub next_wake_at_ms: Option<i64>,
343    #[serde(default)]
344    /// Caller-supplied wait deadline when acquisition is bounded.
345    pub deadline_at_ms: Option<i64>,
346    /// Authority describing the current owner. Absent only when SQLite's
347    /// short write lock prevented the registry from reading lease state.
348    #[serde(default)]
349    pub active: Option<HostLeaseHandle>,
350}
351
352/// Versioned result of an immediate or waiting acquire operation.
353#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
354pub struct HostLeaseAcquireReceipt {
355    /// Contract schema version.
356    pub schema_version: u32,
357    /// Whether acquisition succeeded or deferred.
358    pub status: HostLeaseAcquireStatus,
359    /// Final observation timestamp in Unix milliseconds.
360    pub observed_at_ms: i64,
361    /// Time spent waiting on filesystem notifications or expiry.
362    pub waited_ms: u64,
363    #[serde(default)]
364    /// Token-bearing authority, present only after acquisition.
365    pub handle: Option<HostLeaseHandle>,
366    #[serde(default)]
367    /// Contention authority, present only after deferral.
368    pub defer: Option<HostLeaseDeferReceipt>,
369    /// True when acquisition first removed an expired or dead-owner row.
370    pub recovered_stale_lease: bool,
371}
372
373/// Current authoritative lease state for one host.
374#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
375pub struct HostLeaseState {
376    /// Contract schema version.
377    pub schema_version: u32,
378    /// Machine resource name.
379    pub host: String,
380    #[serde(default)]
381    /// Resource class inspected on this host.
382    pub resource_class: HostLeaseResourceClass,
383    /// Observation timestamp in Unix milliseconds.
384    pub observed_at_ms: i64,
385    #[serde(default)]
386    /// Current owner, or `None` when the host is available.
387    pub active: Option<HostLeaseHandle>,
388    /// True when this read removed an expired or dead-owner row.
389    pub recovered_stale_lease: bool,
390}
391
392/// Versioned result of a token-scoped lease renewal.
393#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
394pub struct HostLeaseRenewReceipt {
395    /// Contract schema version.
396    pub schema_version: u32,
397    /// True only when the supplied token owned the active lease.
398    pub renewed: bool,
399    /// Observation timestamp in Unix milliseconds.
400    pub observed_at_ms: i64,
401    #[serde(default)]
402    /// Updated handle when renewal succeeds.
403    pub handle: Option<HostLeaseHandle>,
404}
405
406/// Versioned result of a token-scoped lease release.
407#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
408pub struct HostLeaseReleaseReceipt {
409    /// Contract schema version.
410    pub schema_version: u32,
411    /// True only when the supplied token owned and removed the active lease.
412    pub released: bool,
413    /// Machine resource name.
414    pub host: String,
415    #[serde(default)]
416    /// Resource class released on this host.
417    pub resource_class: HostLeaseResourceClass,
418    /// Token supplied by the caller.
419    pub lease_id: String,
420    /// Observation timestamp in Unix milliseconds.
421    pub observed_at_ms: i64,
422}
423
424/// Atomic machine-global lease store for CLI and runtime adapters.
425#[derive(Clone, Debug)]
426pub struct HostLeaseStore {
427    root: PathBuf,
428    db_path: PathBuf,
429    process_inspector: Arc<dyn ProcessInspector>,
430}
431
432impl HostLeaseStore {
433    /// Resolve state from `HARN_HOST_LEASE_ROOT`, `HARN_HOME`, or the user home.
434    pub fn from_env() -> Result<Self, HostLeaseError> {
435        let root = if let Some(path) = std::env::var_os(HOST_LEASE_ROOT_ENV) {
436            PathBuf::from(path)
437        } else if let Some(path) = std::env::var_os(HARN_HOME_ENV) {
438            PathBuf::from(path).join("host-leases")
439        } else {
440            harn_vm::user_dirs::home_dir()
441                .ok_or_else(|| {
442                    HostLeaseError::InvalidRequest(
443                        "cannot resolve a home directory; set HARN_HOST_LEASE_ROOT".to_string(),
444                    )
445                })?
446                .join(".harn/host-leases")
447        };
448        Self::for_root(root)
449    }
450
451    /// Create a store at an explicit root, primarily for hermetic hosts and tests.
452    pub fn for_root(root: impl Into<PathBuf>) -> Result<Self, HostLeaseError> {
453        Self::for_root_with_inspector(root, Arc::new(SystemProcessInspector))
454    }
455
456    fn for_root_with_inspector(
457        root: impl Into<PathBuf>,
458        process_inspector: Arc<dyn ProcessInspector>,
459    ) -> Result<Self, HostLeaseError> {
460        let root = root.into();
461        std::fs::create_dir_all(&root)?;
462        let store = Self {
463            db_path: root.join(LEASE_DB_FILE),
464            root,
465            process_inspector,
466        };
467        store.initialize()?;
468        Ok(store)
469    }
470
471    /// Directory containing the lease database and watcher events.
472    pub fn root(&self) -> &Path {
473        &self.root
474    }
475
476    /// Persist intent for one supervised execution before its worker starts.
477    pub fn begin_run(
478        &self,
479        owner: &str,
480        priority_class: HostLeasePriorityClass,
481        resource: HostLeaseResourceKey,
482        execution_context: HostLeaseExecutionContext,
483        wait_limit_ms: u64,
484    ) -> Result<HostLeaseRunReceipt, HostLeaseError> {
485        let resource = HostLeaseResourceKey::normalize(&resource.machine, resource.resource_class)?;
486        let receipt = HostLeaseRunReceipt {
487            schema_version: RUN_RECEIPT_SCHEMA_VERSION,
488            run_id: Uuid::now_v7().to_string(),
489            owner: normalize_component("owner", owner)?,
490            priority_class,
491            wait_limit_ms,
492            resource,
493            execution_context,
494            status: HostLeaseRunState::Pending {
495                requested_at_ms: unix_now_ms()?,
496            },
497        };
498        let path = self.run_receipt_path(&receipt.run_id)?;
499        let bytes = serde_json::to_vec_pretty(&receipt)?;
500        harn_vm::atomic_io::atomic_write(&path, &bytes)?;
501        Ok(receipt)
502    }
503
504    /// Load one durable supervised-execution receipt.
505    pub fn load_run(&self, run_id: &str) -> Result<HostLeaseRunReceipt, HostLeaseError> {
506        let path = self.run_receipt_path(run_id)?;
507        Ok(serde_json::from_slice(&std::fs::read(path)?)?)
508    }
509
510    /// Advance one supervised execution through a validated lifecycle edge.
511    pub fn transition_run(
512        &self,
513        run_id: &str,
514        status: HostLeaseRunState,
515    ) -> Result<HostLeaseRunReceipt, HostLeaseError> {
516        let mut receipt = self.load_run(run_id)?;
517        if !receipt.status.may_transition_to(&status) {
518            return Err(HostLeaseError::InvalidRequest(format!(
519                "invalid run receipt transition from {:?} to {:?}",
520                receipt.status, status
521            )));
522        }
523        receipt.status = status;
524        let path = self.run_receipt_path(run_id)?;
525        let bytes = serde_json::to_vec_pretty(&receipt)?;
526        harn_vm::atomic_io::atomic_write(&path, &bytes)?;
527        Ok(receipt)
528    }
529
530    /// Stable path containing one run receipt.
531    pub fn run_receipt_path(&self, run_id: &str) -> Result<PathBuf, HostLeaseError> {
532        let run_id = normalize_component("run_id", run_id)?;
533        Ok(self
534            .root
535            .join(RUN_RECEIPTS_DIR)
536            .join(format!("{run_id}.json")))
537    }
538
539    /// Return the local hostname used when callers omit `--host`.
540    pub fn default_host() -> String {
541        System::host_name()
542            .filter(|name| !name.trim().is_empty())
543            .unwrap_or_else(|| "local".to_string())
544    }
545
546    /// Attempt one immediate atomic acquisition.
547    pub fn try_acquire(
548        &self,
549        request: HostLeaseRequest,
550    ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
551        self.try_acquire_once(request, None, None)
552    }
553
554    /// Wait on cross-process notifications and expiry, then retry atomically.
555    pub fn acquire_wait(
556        &self,
557        request: HostLeaseRequest,
558        wait_timeout: Duration,
559    ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
560        if wait_timeout.is_zero() {
561            return self.try_acquire(request);
562        }
563        let started_at_ms = unix_now_ms()?;
564        let started_at = Instant::now();
565        let deadline = started_at.checked_add(wait_timeout).ok_or_else(|| {
566            HostLeaseError::InvalidRequest("wait timeout exceeds the monotonic clock".to_string())
567        })?;
568        let deadline_at_ms = started_at_ms.saturating_add(duration_ms_i64(wait_timeout));
569        let (tx, rx) = mpsc::channel();
570        let mut watcher = notify::recommended_watcher(move |event| {
571            let _ = tx.send(event);
572        })
573        .map_err(|error| HostLeaseError::Watch(error.to_string()))?;
574        watcher
575            .watch(&self.root, RecursiveMode::NonRecursive)
576            .map_err(|error| HostLeaseError::Watch(error.to_string()))?;
577
578        loop {
579            let receipt =
580                self.try_acquire_once(request.clone(), Some(started_at), Some(deadline_at_ms))?;
581            if receipt.status == HostLeaseAcquireStatus::Acquired || Instant::now() >= deadline {
582                return Ok(receipt);
583            }
584            let wake_at = receipt
585                .defer
586                .as_ref()
587                .and_then(|defer| defer.next_wake_at_ms)
588                .map(|wake| wake.min(deadline_at_ms))
589                .unwrap_or(deadline_at_ms);
590            let wake_duration =
591                Duration::from_millis(wake_at.saturating_sub(receipt.observed_at_ms).max(1) as u64);
592            let remaining = deadline.saturating_duration_since(Instant::now());
593            if remaining.is_zero() {
594                return Ok(receipt);
595            }
596            match rx.recv_timeout(wake_duration.min(remaining)) {
597                Ok(Ok(_)) | Err(mpsc::RecvTimeoutError::Timeout) => {}
598                Ok(Err(error)) => return Err(HostLeaseError::Watch(error.to_string())),
599                Err(mpsc::RecvTimeoutError::Disconnected) => {
600                    return Err(HostLeaseError::Watch(
601                        "host lease watcher disconnected".to_string(),
602                    ));
603                }
604            }
605        }
606    }
607
608    /// Inspect one host, recovering expired or dead-owner state transactionally.
609    pub fn status(&self, host: &str) -> Result<HostLeaseState, HostLeaseError> {
610        self.status_for_resource(host, HostLeaseResourceClass::WholeMachine)
611    }
612
613    /// Inspect a specific resource class, recovering stale state transactionally.
614    pub fn status_for_resource(
615        &self,
616        host: &str,
617        resource_class: HostLeaseResourceClass,
618    ) -> Result<HostLeaseState, HostLeaseError> {
619        let resource = HostLeaseResourceKey::normalize(host, resource_class)?;
620        let mut conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
621        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
622        let now = unix_now_ms()?;
623        self.status_in_transaction(tx, &resource.machine, resource.resource_class, now)
624    }
625
626    /// Renew the active lease only when the token matches.
627    pub fn renew(
628        &self,
629        host: &str,
630        lease_id: &str,
631        ttl_ms: u64,
632    ) -> Result<HostLeaseRenewReceipt, HostLeaseError> {
633        self.renew_for_resource(host, HostLeaseResourceClass::WholeMachine, lease_id, ttl_ms)
634    }
635
636    /// Renew a lease for one resource class only when its token matches.
637    pub fn renew_for_resource(
638        &self,
639        host: &str,
640        resource_class: HostLeaseResourceClass,
641        lease_id: &str,
642        ttl_ms: u64,
643    ) -> Result<HostLeaseRenewReceipt, HostLeaseError> {
644        let resource = HostLeaseResourceKey::normalize(host, resource_class)?;
645        let lease_id = normalize_component("lease_id", lease_id)?;
646        validate_ttl(Some(ttl_ms))?;
647        let mut conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
648        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
649        let now = unix_now_ms()?;
650        let (active, _) = active_handle(
651            &tx,
652            &resource.machine,
653            resource.resource_class,
654            now,
655            self.process_inspector.as_ref(),
656        )?;
657        let Some(mut handle) = active.filter(|handle| handle.lease_id == lease_id) else {
658            tx.commit()?;
659            return Ok(HostLeaseRenewReceipt {
660                schema_version: SCHEMA_VERSION,
661                renewed: false,
662                observed_at_ms: now,
663                handle: None,
664            });
665        };
666        handle.updated_at_ms = now;
667        handle.expires_at_ms = Some(now.saturating_add(u64_ms_i64(ttl_ms)));
668        write_handle(&tx, &handle)?;
669        tx.commit()?;
670        Ok(HostLeaseRenewReceipt {
671            schema_version: SCHEMA_VERSION,
672            renewed: true,
673            observed_at_ms: now,
674            handle: Some(handle),
675        })
676    }
677
678    /// Release the active lease only when the token matches.
679    pub fn release(
680        &self,
681        host: &str,
682        lease_id: &str,
683    ) -> Result<HostLeaseReleaseReceipt, HostLeaseError> {
684        self.release_for_resource(host, HostLeaseResourceClass::WholeMachine, lease_id)
685    }
686
687    /// Release a lease for one resource class only when its token matches.
688    pub fn release_for_resource(
689        &self,
690        host: &str,
691        resource_class: HostLeaseResourceClass,
692        lease_id: &str,
693    ) -> Result<HostLeaseReleaseReceipt, HostLeaseError> {
694        let resource = HostLeaseResourceKey::normalize(host, resource_class)?;
695        let lease_id = normalize_component("lease_id", lease_id)?;
696        let conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
697        let released = conn.execute(
698            "DELETE FROM host_leases
699             WHERE host = ?1 AND resource_class = ?2 AND lease_id = ?3",
700            params![
701                &resource.machine,
702                resource.resource_class.as_str(),
703                &lease_id
704            ],
705        )? == 1;
706        let now = unix_now_ms()?;
707        Ok(HostLeaseReleaseReceipt {
708            schema_version: SCHEMA_VERSION,
709            released,
710            host: resource.machine,
711            resource_class: resource.resource_class,
712            lease_id,
713            observed_at_ms: now,
714        })
715    }
716
717    fn initialize(&self) -> Result<(), HostLeaseError> {
718        let mut conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
719        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
720        match lease_table_layout(&tx)? {
721            LeaseTableLayout::Missing => create_current_lease_table(&tx)?,
722            LeaseTableLayout::LegacyWholeMachine => migrate_legacy_lease_table(&tx)?,
723            LeaseTableLayout::ResourceClassWithoutExecutionContext => {
724                add_execution_context_column(&tx)?;
725            }
726            LeaseTableLayout::Current => {}
727        }
728        tx.commit()?;
729        Ok(())
730    }
731
732    fn connection(&self, busy_timeout: Duration) -> Result<Connection, HostLeaseError> {
733        let conn = Connection::open(&self.db_path)?;
734        conn.busy_timeout(busy_timeout)?;
735        Ok(conn)
736    }
737
738    fn try_acquire_once(
739        &self,
740        request: HostLeaseRequest,
741        started_at: Option<Instant>,
742        deadline_at_ms: Option<i64>,
743    ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
744        let request = normalize_request(request)?;
745        let mut conn = self.connection(Duration::ZERO)?;
746        let tx = match conn.transaction_with_behavior(TransactionBehavior::Immediate) {
747            Ok(tx) => tx,
748            Err(error) if sqlite_is_busy(&error) => {
749                let now = unix_now_ms()?;
750                return Ok(registry_busy_receipt(
751                    request.host,
752                    request.resource_class,
753                    now,
754                    started_at.map(|started| duration_ms_u64(started.elapsed())),
755                    deadline_at_ms,
756                ));
757            }
758            Err(error) => return Err(error.into()),
759        };
760        let now = unix_now_ms()?;
761        let waited_ms = started_at
762            .map(|started| duration_ms_u64(started.elapsed()))
763            .unwrap_or(0);
764        let host = request.host.clone();
765        let resource_class = request.resource_class;
766        match self.acquire_in_transaction(tx, request, now, deadline_at_ms, waited_ms) {
767            Err(HostLeaseError::Database(error)) if sqlite_is_busy(&error) => Ok(
768                registry_busy_receipt(host, resource_class, now, Some(waited_ms), deadline_at_ms),
769            ),
770            result => result,
771        }
772    }
773
774    #[cfg(test)]
775    fn try_acquire_at(
776        &self,
777        request: HostLeaseRequest,
778        now: i64,
779        deadline_at_ms: Option<i64>,
780        waited_ms: u64,
781    ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
782        let request = normalize_request(request)?;
783        let mut conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
784        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
785        self.acquire_in_transaction(tx, request, now, deadline_at_ms, waited_ms)
786    }
787
788    fn acquire_in_transaction(
789        &self,
790        tx: Transaction<'_>,
791        request: HostLeaseRequest,
792        now: i64,
793        deadline_at_ms: Option<i64>,
794        waited_ms: u64,
795    ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
796        let owner_process_identity = request
797            .owner_pid
798            .map(|pid| match self.process_inspector.observe(pid) {
799                ProcessObservation::Alive { identity } => Ok(identity),
800                ProcessObservation::Dead => Err(HostLeaseError::InvalidRequest(
801                    "owner_pid is not a live local process".to_string(),
802                )),
803                ProcessObservation::Unknown => Err(HostLeaseError::InvalidRequest(
804                    "owner_pid liveness could not be verified".to_string(),
805                )),
806            })
807            .transpose()?;
808        let (active, recovered_stale_lease) = active_handle(
809            &tx,
810            &request.host,
811            request.resource_class,
812            now,
813            self.process_inspector.as_ref(),
814        )?;
815        if let Some(active) = active {
816            let defer = HostLeaseDeferReceipt {
817                host: request.host,
818                resource_class: request.resource_class,
819                deferred_reason: HostLeaseDeferReason::Contended,
820                observed_at_ms: now,
821                next_wake_at_ms: Some(next_lease_wake_at(&active, now, deadline_at_ms)),
822                deadline_at_ms,
823                active: Some(active),
824            };
825            tx.commit()?;
826            return Ok(HostLeaseAcquireReceipt {
827                schema_version: SCHEMA_VERSION,
828                status: HostLeaseAcquireStatus::Deferred,
829                observed_at_ms: now,
830                waited_ms,
831                handle: None,
832                defer: Some(defer),
833                recovered_stale_lease,
834            });
835        }
836
837        let handle = HostLeaseHandle {
838            schema_version: SCHEMA_VERSION,
839            host: request.host,
840            resource_class: request.resource_class,
841            execution_context: request.execution_context,
842            lease_id: Uuid::now_v7().to_string(),
843            owner: request.owner,
844            priority_class: request.priority_class,
845            acquired_at_ms: now,
846            updated_at_ms: now,
847            expires_at_ms: request
848                .ttl_ms
849                .map(|ttl| now.saturating_add(u64_ms_i64(ttl))),
850            owner_pid: request.owner_pid,
851            owner_process_identity,
852            reason: request.reason,
853            metadata: request.metadata,
854        };
855        write_handle(&tx, &handle)?;
856        tx.commit()?;
857        Ok(HostLeaseAcquireReceipt {
858            schema_version: SCHEMA_VERSION,
859            status: HostLeaseAcquireStatus::Acquired,
860            observed_at_ms: now,
861            waited_ms,
862            handle: Some(handle),
863            defer: None,
864            recovered_stale_lease,
865        })
866    }
867
868    #[cfg(test)]
869    fn status_at(
870        &self,
871        host: &str,
872        resource_class: HostLeaseResourceClass,
873        now: i64,
874    ) -> Result<HostLeaseState, HostLeaseError> {
875        let mut conn = self.connection(SQLITE_MUTATION_BUSY_TIMEOUT)?;
876        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
877        self.status_in_transaction(tx, host, resource_class, now)
878    }
879
880    fn status_in_transaction(
881        &self,
882        tx: Transaction<'_>,
883        host: &str,
884        resource_class: HostLeaseResourceClass,
885        now: i64,
886    ) -> Result<HostLeaseState, HostLeaseError> {
887        let (active, recovered_stale_lease) = active_handle(
888            &tx,
889            host,
890            resource_class,
891            now,
892            self.process_inspector.as_ref(),
893        )?;
894        tx.commit()?;
895        Ok(HostLeaseState {
896            schema_version: SCHEMA_VERSION,
897            host: host.to_string(),
898            resource_class,
899            observed_at_ms: now,
900            active,
901            recovered_stale_lease,
902        })
903    }
904}
905
906fn normalize_request(mut request: HostLeaseRequest) -> Result<HostLeaseRequest, HostLeaseError> {
907    let resource = HostLeaseResourceKey::normalize(&request.host, request.resource_class)?;
908    request.host = resource.machine;
909    request.resource_class = resource.resource_class;
910    request.owner = normalize_component("owner", &request.owner)?;
911    validate_ttl(request.ttl_ms)?;
912    if request.ttl_ms.is_none() && request.owner_pid.is_none() {
913        return Err(HostLeaseError::InvalidRequest(
914            "a non-expiring lease requires owner_pid for crash recovery".to_string(),
915        ));
916    }
917    request.reason = request.reason.and_then(|reason| {
918        let trimmed = reason.trim();
919        (!trimmed.is_empty()).then(|| trimmed.to_string())
920    });
921    Ok(request)
922}
923
924#[derive(Clone, Copy, Debug, PartialEq, Eq)]
925enum LeaseTableLayout {
926    Missing,
927    LegacyWholeMachine,
928    ResourceClassWithoutExecutionContext,
929    Current,
930}
931
932fn lease_table_layout(tx: &Transaction<'_>) -> Result<LeaseTableLayout, HostLeaseError> {
933    let mut statement = tx.prepare("PRAGMA table_info(host_leases)")?;
934    let columns = statement
935        .query_map([], |row| row.get::<_, String>(1))?
936        .collect::<Result<Vec<_>, _>>()?;
937    if columns.is_empty() {
938        return Ok(LeaseTableLayout::Missing);
939    }
940    let has_resource_class = columns.iter().any(|column| column == "resource_class");
941    let has_execution_context = columns
942        .iter()
943        .any(|column| column == "execution_context_json");
944    if has_resource_class && has_execution_context {
945        return Ok(LeaseTableLayout::Current);
946    }
947    if has_resource_class {
948        return Ok(LeaseTableLayout::ResourceClassWithoutExecutionContext);
949    }
950    Ok(LeaseTableLayout::LegacyWholeMachine)
951}
952
953fn create_current_lease_table(tx: &Transaction<'_>) -> Result<(), HostLeaseError> {
954    tx.execute_batch(
955        "CREATE TABLE host_leases (
956            host TEXT NOT NULL,
957            resource_class TEXT NOT NULL,
958            lease_id TEXT NOT NULL,
959            owner TEXT NOT NULL,
960            priority_class TEXT NOT NULL,
961            acquired_at_ms INTEGER NOT NULL,
962            updated_at_ms INTEGER NOT NULL,
963            expires_at_ms INTEGER,
964            owner_pid INTEGER,
965            owner_process_identity INTEGER,
966            reason TEXT,
967            metadata_json TEXT NOT NULL,
968            execution_context_json TEXT,
969            PRIMARY KEY (host, resource_class)
970        );",
971    )?;
972    Ok(())
973}
974
975fn migrate_legacy_lease_table(tx: &Transaction<'_>) -> Result<(), HostLeaseError> {
976    tx.execute_batch(
977        "ALTER TABLE host_leases RENAME TO host_leases_v1;
978         CREATE TABLE host_leases (
979            host TEXT NOT NULL,
980            resource_class TEXT NOT NULL,
981            lease_id TEXT NOT NULL,
982            owner TEXT NOT NULL,
983            priority_class TEXT NOT NULL,
984            acquired_at_ms INTEGER NOT NULL,
985            updated_at_ms INTEGER NOT NULL,
986            expires_at_ms INTEGER,
987            owner_pid INTEGER,
988            owner_process_identity INTEGER,
989            reason TEXT,
990            metadata_json TEXT NOT NULL,
991            execution_context_json TEXT,
992            PRIMARY KEY (host, resource_class)
993         );
994         INSERT INTO host_leases (
995            host, resource_class, lease_id, owner, priority_class, acquired_at_ms,
996            updated_at_ms, expires_at_ms, owner_pid, owner_process_identity, reason, metadata_json,
997            execution_context_json
998         )
999         SELECT host, 'whole-machine', lease_id, owner, priority_class, acquired_at_ms,
1000            updated_at_ms, expires_at_ms, owner_pid, owner_process_identity, reason, metadata_json,
1001            NULL
1002         FROM host_leases_v1;
1003         DROP TABLE host_leases_v1;",
1004    )?;
1005    Ok(())
1006}
1007
1008fn add_execution_context_column(tx: &Transaction<'_>) -> Result<(), HostLeaseError> {
1009    tx.execute_batch("ALTER TABLE host_leases ADD COLUMN execution_context_json TEXT;")?;
1010    Ok(())
1011}
1012
1013fn normalize_component(name: &str, value: &str) -> Result<String, HostLeaseError> {
1014    let value = value.trim();
1015    if value.is_empty() {
1016        return Err(HostLeaseError::InvalidRequest(format!(
1017            "{name} cannot be empty"
1018        )));
1019    }
1020    if !value
1021        .chars()
1022        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
1023    {
1024        return Err(HostLeaseError::InvalidRequest(format!(
1025            "{name} may contain only ASCII letters, digits, '.', '_' and '-'"
1026        )));
1027    }
1028    Ok(value.to_string())
1029}
1030
1031fn validate_ttl(ttl_ms: Option<u64>) -> Result<(), HostLeaseError> {
1032    if ttl_ms == Some(0) {
1033        return Err(HostLeaseError::InvalidRequest(
1034            "ttl_ms must be greater than zero when supplied".to_string(),
1035        ));
1036    }
1037    Ok(())
1038}
1039
1040fn active_handle(
1041    tx: &Transaction<'_>,
1042    host: &str,
1043    resource_class: HostLeaseResourceClass,
1044    now: i64,
1045    process_inspector: &dyn ProcessInspector,
1046) -> Result<(Option<HostLeaseHandle>, bool), HostLeaseError> {
1047    let handle = read_handle(tx, host, resource_class)?;
1048    let Some(handle) = handle else {
1049        return Ok((None, false));
1050    };
1051    let expired = handle.expires_at_ms.is_some_and(|expiry| expiry <= now);
1052    let owner_dead = match (handle.owner_pid, handle.owner_process_identity) {
1053        (Some(pid), Some(expected_identity)) => match process_inspector.observe(pid) {
1054            ProcessObservation::Alive { identity } => identity != expected_identity,
1055            ProcessObservation::Dead => true,
1056            ProcessObservation::Unknown => false,
1057        },
1058        _ => false,
1059    };
1060    if expired || owner_dead {
1061        tx.execute(
1062            "DELETE FROM host_leases
1063             WHERE host = ?1 AND resource_class = ?2 AND lease_id = ?3",
1064            params![host, resource_class.as_str(), handle.lease_id],
1065        )?;
1066        return Ok((None, true));
1067    }
1068    Ok((Some(handle), false))
1069}
1070
1071fn next_lease_wake_at(active: &HostLeaseHandle, now: i64, deadline_at_ms: Option<i64>) -> i64 {
1072    let mut wake_at = deadline_at_ms.unwrap_or(i64::MAX);
1073    if let Some(expiry) = active.expires_at_ms {
1074        wake_at = wake_at.min(expiry);
1075    }
1076    if active.owner_pid.is_some() {
1077        wake_at =
1078            wake_at.min(now.saturating_add(duration_ms_i64(PROCESS_LIVENESS_RECHECK_INTERVAL)));
1079    }
1080    wake_at
1081}
1082
1083fn registry_busy_receipt(
1084    host: String,
1085    resource_class: HostLeaseResourceClass,
1086    now: i64,
1087    waited_ms: Option<u64>,
1088    deadline_at_ms: Option<i64>,
1089) -> HostLeaseAcquireReceipt {
1090    let next_wake_at_ms = now
1091        .saturating_add(duration_ms_i64(REGISTRY_BUSY_RETRY_INTERVAL))
1092        .min(deadline_at_ms.unwrap_or(i64::MAX));
1093    HostLeaseAcquireReceipt {
1094        schema_version: SCHEMA_VERSION,
1095        status: HostLeaseAcquireStatus::Deferred,
1096        observed_at_ms: now,
1097        waited_ms: waited_ms.unwrap_or(0),
1098        handle: None,
1099        defer: Some(HostLeaseDeferReceipt {
1100            host,
1101            resource_class,
1102            deferred_reason: HostLeaseDeferReason::RegistryBusy,
1103            observed_at_ms: now,
1104            next_wake_at_ms: Some(next_wake_at_ms),
1105            deadline_at_ms,
1106            active: None,
1107        }),
1108        recovered_stale_lease: false,
1109    }
1110}
1111
1112fn sqlite_is_busy(error: &rusqlite::Error) -> bool {
1113    matches!(
1114        error,
1115        rusqlite::Error::SqliteFailure(inner, _)
1116            if matches!(inner.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
1117    )
1118}
1119
1120fn read_handle(
1121    tx: &Transaction<'_>,
1122    host: &str,
1123    resource_class: HostLeaseResourceClass,
1124) -> Result<Option<HostLeaseHandle>, HostLeaseError> {
1125    tx.query_row(
1126        "SELECT resource_class, lease_id, owner, priority_class, acquired_at_ms, updated_at_ms,
1127                expires_at_ms, owner_pid, owner_process_identity, reason, metadata_json,
1128                execution_context_json
1129         FROM host_leases WHERE host = ?1 AND resource_class = ?2",
1130        params![host, resource_class.as_str()],
1131        |row| {
1132            let priority: String = row.get(3)?;
1133            let metadata_json: String = row.get(10)?;
1134            let execution_context_json: Option<String> = row.get(11)?;
1135            let owner_pid_i64: Option<i64> = row.get(7)?;
1136            let owner_identity_i64: Option<i64> = row.get(8)?;
1137            Ok((
1138                row.get::<_, String>(1)?,
1139                row.get::<_, String>(2)?,
1140                priority,
1141                row.get::<_, i64>(4)?,
1142                row.get::<_, i64>(5)?,
1143                row.get::<_, Option<i64>>(6)?,
1144                owner_pid_i64,
1145                owner_identity_i64,
1146                row.get::<_, Option<String>>(9)?,
1147                metadata_json,
1148                row.get::<_, String>(0)?,
1149                execution_context_json,
1150            ))
1151        },
1152    )
1153    .optional()?
1154    .map(
1155        |(
1156            lease_id,
1157            owner,
1158            priority,
1159            acquired_at_ms,
1160            updated_at_ms,
1161            expires_at_ms,
1162            owner_pid,
1163            owner_process_identity,
1164            reason,
1165            metadata_json,
1166            stored_resource_class,
1167            execution_context_json,
1168        )| {
1169            let owner_pid = owner_pid
1170                .map(|pid| {
1171                    u32::try_from(pid).map_err(|_| {
1172                        HostLeaseError::InvalidRequest(
1173                            "persisted owner_pid is outside the u32 range".to_string(),
1174                        )
1175                    })
1176                })
1177                .transpose()?;
1178            let owner_process_identity = owner_process_identity
1179                .map(|identity| {
1180                    u64::try_from(identity).map_err(|_| {
1181                        HostLeaseError::InvalidRequest(
1182                            "persisted process identity is negative".to_string(),
1183                        )
1184                    })
1185                })
1186                .transpose()?;
1187            Ok(HostLeaseHandle {
1188                schema_version: SCHEMA_VERSION,
1189                host: host.to_string(),
1190                resource_class: HostLeaseResourceClass::parse(&stored_resource_class)?,
1191                execution_context: execution_context_json
1192                    .map(|encoded| serde_json::from_str(&encoded))
1193                    .transpose()?,
1194                lease_id,
1195                owner,
1196                priority_class: HostLeasePriorityClass::parse(&priority)?,
1197                acquired_at_ms,
1198                updated_at_ms,
1199                expires_at_ms,
1200                owner_pid,
1201                owner_process_identity,
1202                reason,
1203                metadata: serde_json::from_str(&metadata_json)?,
1204            })
1205        },
1206    )
1207    .transpose()
1208}
1209
1210fn write_handle(tx: &Transaction<'_>, handle: &HostLeaseHandle) -> Result<(), HostLeaseError> {
1211    let metadata_json = serde_json::to_string(&handle.metadata)?;
1212    let execution_context_json = handle
1213        .execution_context
1214        .as_ref()
1215        .map(serde_json::to_string)
1216        .transpose()?;
1217    let owner_process_identity = handle
1218        .owner_process_identity
1219        .map(|value| {
1220            i64::try_from(value).map_err(|_| {
1221                HostLeaseError::InvalidRequest(
1222                    "owner process identity is outside the SQLite integer range".to_string(),
1223                )
1224            })
1225        })
1226        .transpose()?;
1227    tx.execute(
1228        "INSERT INTO host_leases (
1229            host, resource_class, lease_id, owner, priority_class, acquired_at_ms, updated_at_ms,
1230            expires_at_ms, owner_pid, owner_process_identity, reason, metadata_json,
1231            execution_context_json
1232         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
1233         ON CONFLICT(host, resource_class) DO UPDATE SET
1234            lease_id = excluded.lease_id,
1235            owner = excluded.owner,
1236            priority_class = excluded.priority_class,
1237            acquired_at_ms = excluded.acquired_at_ms,
1238            updated_at_ms = excluded.updated_at_ms,
1239            expires_at_ms = excluded.expires_at_ms,
1240            owner_pid = excluded.owner_pid,
1241            owner_process_identity = excluded.owner_process_identity,
1242            reason = excluded.reason,
1243            metadata_json = excluded.metadata_json,
1244            execution_context_json = excluded.execution_context_json",
1245        params![
1246            handle.host,
1247            handle.resource_class.as_str(),
1248            handle.lease_id,
1249            handle.owner,
1250            handle.priority_class.as_str(),
1251            handle.acquired_at_ms,
1252            handle.updated_at_ms,
1253            handle.expires_at_ms,
1254            handle.owner_pid.map(i64::from),
1255            owner_process_identity,
1256            handle.reason,
1257            metadata_json,
1258            execution_context_json,
1259        ],
1260    )?;
1261    Ok(())
1262}
1263
1264fn unix_now_ms() -> Result<i64, HostLeaseError> {
1265    let millis = SystemTime::now()
1266        .duration_since(UNIX_EPOCH)
1267        .map_err(|_| HostLeaseError::Clock)?
1268        .as_millis();
1269    Ok(millis.min(i64::MAX as u128) as i64)
1270}
1271
1272fn duration_ms_i64(duration: Duration) -> i64 {
1273    duration.as_millis().min(i64::MAX as u128) as i64
1274}
1275
1276fn duration_ms_u64(duration: Duration) -> u64 {
1277    duration.as_millis().min(u64::MAX as u128) as u64
1278}
1279
1280fn u64_ms_i64(value: u64) -> i64 {
1281    value.min(i64::MAX as u64) as i64
1282}
1283
1284#[cfg(test)]
1285#[path = "host_lease/tests.rs"]
1286mod tests;