1mod 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
30pub 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#[derive(Debug, thiserror::Error)]
44pub enum HostLeaseError {
45 #[error("invalid host lease request: {0}")]
47 InvalidRequest(String),
48 #[error("host lease state I/O failed: {0}")]
50 Io(#[from] std::io::Error),
51 #[error("host lease database failed: {0}")]
53 Database(#[from] rusqlite::Error),
54 #[error("host lease metadata serialization failed: {0}")]
56 Serialization(#[from] serde_json::Error),
57 #[error("host lease watcher failed: {0}")]
59 Watch(String),
60 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum HostLeasePriorityClass {
98 Interactive,
100 Measurement,
102 CiVerify,
104 #[default]
105 Deferrable,
107}
108
109impl HostLeasePriorityClass {
110 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum HostLeaseResourceClass {
142 #[default]
144 WholeMachine,
145 RustHeavy,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct HostLeaseResourceDefinition {
152 pub name: &'static str,
154 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 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 pub const fn as_str(self) -> &'static str {
180 self.definition().name
181 }
182
183 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204pub struct HostLeaseResourceKey {
205 pub machine: String,
208 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
226pub struct HostLeaseRequest {
227 pub host: String,
229 #[serde(default)]
230 pub resource_class: HostLeaseResourceClass,
233 #[serde(default)]
234 pub execution_context: Option<HostLeaseExecutionContext>,
236 pub owner: String,
238 #[serde(default)]
239 pub priority_class: HostLeasePriorityClass,
241 #[serde(default)]
245 pub ttl_ms: Option<u64>,
246 #[serde(default)]
247 pub owner_pid: Option<u32>,
249 #[serde(default)]
250 pub reason: Option<String>,
252 #[serde(default)]
253 pub metadata: BTreeMap<String, String>,
255}
256
257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259pub struct HostLeaseHandle {
260 pub schema_version: u32,
262 pub host: String,
264 #[serde(default)]
265 pub resource_class: HostLeaseResourceClass,
267 #[serde(default)]
268 pub execution_context: Option<HostLeaseExecutionContext>,
270 pub lease_id: String,
272 pub owner: String,
274 pub priority_class: HostLeasePriorityClass,
276 pub acquired_at_ms: i64,
278 pub updated_at_ms: i64,
280 #[serde(default)]
281 pub expires_at_ms: Option<i64>,
283 #[serde(default)]
284 pub owner_pid: Option<u32>,
286 #[serde(default)]
287 pub owner_process_identity: Option<u64>,
289 #[serde(default)]
290 pub reason: Option<String>,
292 #[serde(default)]
293 pub metadata: BTreeMap<String, String>,
295}
296
297#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum HostLeaseAcquireStatus {
301 Acquired,
303 Deferred,
305}
306
307#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub enum HostLeaseDeferReason {
310 #[serde(rename = "host_lease_contended")]
312 Contended,
313 #[serde(rename = "host_lease_registry_busy")]
315 RegistryBusy,
316}
317
318impl HostLeaseDeferReason {
319 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
330pub struct HostLeaseDeferReceipt {
331 pub host: String,
333 #[serde(default)]
334 pub resource_class: HostLeaseResourceClass,
336 pub deferred_reason: HostLeaseDeferReason,
338 pub observed_at_ms: i64,
340 #[serde(default)]
341 pub next_wake_at_ms: Option<i64>,
343 #[serde(default)]
344 pub deadline_at_ms: Option<i64>,
346 #[serde(default)]
349 pub active: Option<HostLeaseHandle>,
350}
351
352#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
354pub struct HostLeaseAcquireReceipt {
355 pub schema_version: u32,
357 pub status: HostLeaseAcquireStatus,
359 pub observed_at_ms: i64,
361 pub waited_ms: u64,
363 #[serde(default)]
364 pub handle: Option<HostLeaseHandle>,
366 #[serde(default)]
367 pub defer: Option<HostLeaseDeferReceipt>,
369 pub recovered_stale_lease: bool,
371}
372
373#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
375pub struct HostLeaseState {
376 pub schema_version: u32,
378 pub host: String,
380 #[serde(default)]
381 pub resource_class: HostLeaseResourceClass,
383 pub observed_at_ms: i64,
385 #[serde(default)]
386 pub active: Option<HostLeaseHandle>,
388 pub recovered_stale_lease: bool,
390}
391
392#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
394pub struct HostLeaseRenewReceipt {
395 pub schema_version: u32,
397 pub renewed: bool,
399 pub observed_at_ms: i64,
401 #[serde(default)]
402 pub handle: Option<HostLeaseHandle>,
404}
405
406#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
408pub struct HostLeaseReleaseReceipt {
409 pub schema_version: u32,
411 pub released: bool,
413 pub host: String,
415 #[serde(default)]
416 pub resource_class: HostLeaseResourceClass,
418 pub lease_id: String,
420 pub observed_at_ms: i64,
422}
423
424#[derive(Clone, Debug)]
426pub struct HostLeaseStore {
427 root: PathBuf,
428 db_path: PathBuf,
429 process_inspector: Arc<dyn ProcessInspector>,
430}
431
432impl HostLeaseStore {
433 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 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 pub fn root(&self) -> &Path {
473 &self.root
474 }
475
476 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 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 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 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 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 pub fn try_acquire(
548 &self,
549 request: HostLeaseRequest,
550 ) -> Result<HostLeaseAcquireReceipt, HostLeaseError> {
551 self.try_acquire_once(request, None, None)
552 }
553
554 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 pub fn status(&self, host: &str) -> Result<HostLeaseState, HostLeaseError> {
610 self.status_for_resource(host, HostLeaseResourceClass::WholeMachine)
611 }
612
613 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 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 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 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 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;