use crate::shared_lease_state::{
read_epoch, read_state, reject_symlink, remove_state, write_epoch, write_state,
};
use crate::{ProviderError, ProviderResult};
use fs2::FileExt;
use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LeaseOwner(String);
impl LeaseOwner {
pub fn new(value: impl Into<String>) -> ProviderResult<Self> {
let value = value.into();
validate_label("lease owner", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeaseToken {
pub resource: String,
pub owner: LeaseOwner,
pub epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedResourceLease {
pub token: LeaseToken,
pub acquired_at_ms: u64,
pub heartbeat_at_ms: u64,
pub expires_at_ms: u64,
}
impl SharedResourceLease {
pub fn is_expired(&self, now_ms: u64, policy: &LeasePolicy) -> bool {
now_ms.saturating_sub(policy.clock_skew_ms) >= self.expires_at_ms
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeaseHeartbeat {
pub token: LeaseToken,
pub now_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LeasePolicy {
pub ttl_ms: u64,
pub heartbeat_ms: u64,
pub clock_skew_ms: u64,
}
impl LeasePolicy {
pub fn new(ttl_ms: u64, heartbeat_ms: u64, clock_skew_ms: u64) -> ProviderResult<Self> {
let policy = Self {
ttl_ms,
heartbeat_ms,
clock_skew_ms,
};
policy.validate()?;
Ok(policy)
}
fn validate(self) -> ProviderResult<()> {
if self.ttl_ms == 0
|| self.heartbeat_ms == 0
|| self.ttl_ms <= self.heartbeat_ms
|| self.clock_skew_ms >= self.ttl_ms
{
return Err(ProviderError::InvalidConfiguration(
"lease ttl must exceed heartbeat and clock skew".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaseDecision {
Allowed,
NoLease,
Expired,
WrongOwner,
StaleEpoch,
}
pub trait LeaseRepository: Send + Sync {
fn acquire(
&self,
resource: &str,
owner: LeaseOwner,
now_ms: u64,
) -> ProviderResult<SharedResourceLease>;
fn heartbeat(&self, heartbeat: LeaseHeartbeat) -> ProviderResult<SharedResourceLease>;
fn release(&self, token: &LeaseToken) -> ProviderResult<()>;
fn current(&self, resource: &str) -> ProviderResult<Option<SharedResourceLease>>;
fn check_fence(&self, token: &LeaseToken, now_ms: u64) -> ProviderResult<LeaseDecision>;
}
#[derive(Debug, Clone)]
pub struct FileLeaseRepository {
root: PathBuf,
policy: LeasePolicy,
}
impl FileLeaseRepository {
pub fn open(root: impl Into<PathBuf>, policy: LeasePolicy) -> ProviderResult<Self> {
let root = root.into();
policy.validate()?;
reject_symlink(&root)?;
fs::create_dir_all(&root)
.map_err(|error| initialization(format!("lease root create failed: {error}")))?;
reject_symlink(&root)?;
if !root.is_dir() {
return Err(invalid("lease root is not a directory"));
}
Ok(Self { root, policy })
}
pub fn policy(&self) -> LeasePolicy {
self.policy
}
fn lock_path(&self, resource: &str) -> ProviderResult<PathBuf> {
Ok(self.root.join(format!("{}.lock", resource_file(resource)?)))
}
fn state_path(&self, resource: &str) -> ProviderResult<PathBuf> {
Ok(self
.root
.join(format!("{}.lease", resource_file(resource)?)))
}
fn epoch_path(&self, resource: &str) -> ProviderResult<PathBuf> {
Ok(self
.root
.join(format!("{}.epoch", resource_file(resource)?)))
}
fn with_lock<T>(
&self,
resource: &str,
operation: impl FnOnce(&Path) -> ProviderResult<T>,
) -> ProviderResult<T> {
reject_symlink(&self.root)?;
let lock_path = self.lock_path(resource)?;
reject_symlink(&lock_path)?;
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)
.map_err(|error| initialization(format!("lease lock open failed: {error}")))?;
file.lock_exclusive()
.map_err(|error| initialization(format!("lease lock failed: {error}")))?;
let result = operation(&self.state_path(resource)?);
FileExt::unlock(&file).map_err(|_| initialization("lease unlock failed"))?;
result
}
}
impl LeaseRepository for FileLeaseRepository {
fn acquire(
&self,
resource: &str,
owner: LeaseOwner,
now_ms: u64,
) -> ProviderResult<SharedResourceLease> {
self.with_lock(resource, |state_path| {
let current = read_state(state_path, resource)?;
if let Some(current) = ¤t {
if !current.is_expired(now_ms, &self.policy) && current.token.owner != owner {
return Err(ProviderError::Initialization(
"shared resource lease is held by another owner".to_string(),
));
}
}
let epoch_path = self.epoch_path(resource)?;
let previous_epoch = read_epoch(&epoch_path)?
.into_iter()
.chain(current.iter().map(|lease| lease.token.epoch))
.max()
.unwrap_or(0);
let next_epoch = previous_epoch
.checked_add(1)
.ok_or_else(|| initialization("shared resource fencing epoch exhausted"))?;
let expires_at_ms = lease_expiration(now_ms, self.policy.ttl_ms)?;
let lease = SharedResourceLease {
token: LeaseToken {
resource: resource.to_string(),
owner,
epoch: next_epoch,
},
acquired_at_ms: now_ms,
heartbeat_at_ms: now_ms,
expires_at_ms,
};
write_epoch(&epoch_path, next_epoch)?;
write_state(state_path, &lease)?;
Ok(lease)
})
}
fn heartbeat(&self, heartbeat: LeaseHeartbeat) -> ProviderResult<SharedResourceLease> {
self.with_lock(&heartbeat.token.resource, |state_path| {
let mut current =
read_state(state_path, &heartbeat.token.resource)?.ok_or_else(|| {
ProviderError::Initialization("shared resource lease is absent".to_string())
})?;
ensure_current(¤t, &heartbeat.token, heartbeat.now_ms, self.policy)?;
if heartbeat.now_ms < current.heartbeat_at_ms {
return Err(initialization("lease heartbeat clock moved backwards"));
}
current.heartbeat_at_ms = heartbeat.now_ms;
current.expires_at_ms = lease_expiration(heartbeat.now_ms, self.policy.ttl_ms)?;
write_state(state_path, ¤t)?;
Ok(current)
})
}
fn release(&self, token: &LeaseToken) -> ProviderResult<()> {
self.with_lock(&token.resource, |state_path| {
let current = read_state(state_path, &token.resource)?.ok_or_else(|| {
ProviderError::Initialization("shared resource lease is absent".to_string())
})?;
ensure_same_token(¤t, token)?;
remove_state(state_path)
})
}
fn current(&self, resource: &str) -> ProviderResult<Option<SharedResourceLease>> {
self.with_lock(resource, |state_path| read_state(state_path, resource))
}
fn check_fence(&self, token: &LeaseToken, now_ms: u64) -> ProviderResult<LeaseDecision> {
self.with_lock(&token.resource, |state_path| {
let Some(current) = read_state(state_path, &token.resource)? else {
return Ok(LeaseDecision::NoLease);
};
if current.is_expired(now_ms, &self.policy) {
return Ok(LeaseDecision::Expired);
}
if current.token.owner != token.owner {
return Ok(LeaseDecision::WrongOwner);
}
if current.token.epoch != token.epoch {
return Ok(LeaseDecision::StaleEpoch);
}
Ok(LeaseDecision::Allowed)
})
}
}
fn lease_expiration(now_ms: u64, ttl_ms: u64) -> ProviderResult<u64> {
now_ms
.checked_add(ttl_ms)
.ok_or_else(|| initialization("lease expiration timestamp overflow"))
}
fn ensure_current(
current: &SharedResourceLease,
token: &LeaseToken,
now_ms: u64,
policy: LeasePolicy,
) -> ProviderResult<()> {
ensure_same_token(current, token)?;
if current.is_expired(now_ms, &policy) {
return Err(ProviderError::Initialization(
"shared resource lease has expired".to_string(),
));
}
Ok(())
}
fn ensure_same_token(current: &SharedResourceLease, token: &LeaseToken) -> ProviderResult<()> {
if current.token != *token {
return Err(ProviderError::Initialization(
"shared resource lease fencing token mismatch".to_string(),
));
}
Ok(())
}
fn resource_file(resource: &str) -> ProviderResult<String> {
validate_label("lease resource", resource)?;
Ok(resource.to_string())
}
fn validate_label(field: &'static str, value: &str) -> ProviderResult<()> {
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
{
return Err(ProviderError::InvalidConfiguration(format!(
"invalid {field}"
)));
}
Ok(())
}
fn initialization(message: impl Into<String>) -> ProviderError {
ProviderError::Initialization(message.into())
}
fn invalid(message: impl Into<String>) -> ProviderError {
ProviderError::InvalidConfiguration(message.into())
}
#[cfg(test)]
#[path = "shared_lease_tests.rs"]
mod tests;