use serde::{Deserialize, Serialize};
use crate::commit::tx::{ReadAssertion, ReadSet, TransactionEnvelope, WriteClass, WriteIntent};
use crate::commit::validation::Mutation;
use crate::error::AedbError;
use crate::permission::{CallerContext, Permission};
use crate::query::plan::ConsistencyMode;
use crate::{AedbInstance, CommitResult, FencedCommit, LeaseOutcome, MonitorLease, RenewOutcome};
pub const LEASE_KEY_PREFIX: &[u8] = b"\x00aedb:lease:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeaseRecord {
pub project_id: String,
pub scope_id: String,
pub name: String,
pub owner_id: Option<String>,
pub fencing_token: u64,
pub lease_until_micros: u64,
pub acquired_at_micros: u64,
pub meta_json: String,
}
const MAX_CAS_RETRIES: usize = 16;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct StoredLease {
#[serde(default)]
owner_id: Option<String>,
#[serde(default)]
fencing_token: u64,
#[serde(default)]
lease_until_micros: u64,
#[serde(default)]
acquired_at_micros: u64,
#[serde(default)]
meta_json: String,
}
fn lease_key(name: &str) -> Vec<u8> {
let mut k = Vec::with_capacity(LEASE_KEY_PREFIX.len() + name.len());
k.extend_from_slice(LEASE_KEY_PREFIX);
k.extend_from_slice(name.as_bytes());
k
}
fn is_retryable_conflict(err: &AedbError) -> bool {
matches!(
err,
AedbError::Conflict(_) | AedbError::AssertionFailed { .. }
)
}
fn cas_assertion(
project_id: &str,
scope_id: &str,
key: &[u8],
entry: Option<&crate::storage::keyspace::KvEntry>,
) -> ReadAssertion {
match entry {
Some(entry) => ReadAssertion::KeyVersion {
project_id: project_id.to_string(),
scope_id: scope_id.to_string(),
key: key.to_vec(),
expected_seq: entry.version,
},
None => ReadAssertion::KeyExists {
project_id: project_id.to_string(),
scope_id: scope_id.to_string(),
key: key.to_vec(),
expected: false,
},
}
}
fn decode(entry: Option<&crate::storage::keyspace::KvEntry>) -> StoredLease {
entry
.and_then(|e| serde_json::from_slice::<StoredLease>(&e.value).ok())
.unwrap_or_default()
}
impl AedbInstance {
pub async fn lease_acquire(
&self,
project_id: &str,
scope_id: &str,
name: &str,
owner_id: &str,
ttl_micros: u64,
) -> Result<LeaseOutcome, AedbError> {
self.lease_acquire_inner(None, project_id, scope_id, name, owner_id, ttl_micros, None)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn lease_acquire_as(
&self,
caller: CallerContext,
project_id: &str,
scope_id: &str,
name: &str,
owner_id: &str,
ttl_micros: u64,
meta_json: Option<String>,
) -> Result<LeaseOutcome, AedbError> {
self.lease_acquire_inner(
Some(caller),
project_id,
scope_id,
name,
owner_id,
ttl_micros,
meta_json,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn lease_acquire_inner(
&self,
caller: Option<CallerContext>,
project_id: &str,
scope_id: &str,
name: &str,
owner_id: &str,
ttl_micros: u64,
meta_json: Option<String>,
) -> Result<LeaseOutcome, AedbError> {
if name.is_empty() || owner_id.is_empty() {
return Err(AedbError::Validation(
"lease name and owner_id cannot be empty".into(),
));
}
if let Some(meta) = &meta_json {
ensure_valid_json(meta)?;
}
let key = lease_key(name);
for _ in 0..MAX_CAS_RETRIES {
let lease = self.acquire_snapshot(ConsistencyMode::AtLatest).await?;
let base_seq = lease.view.seq;
let entry = lease.view.keyspace.try_kv_get(project_id, scope_id, &key)?;
let mut stored = decode(entry.as_ref());
let now = crate::system_now_micros();
let held_by_other = stored.lease_until_micros > now
&& stored.owner_id.as_deref().is_some_and(|o| o != owner_id);
if held_by_other {
return Ok(LeaseOutcome::Held {
owner_id: stored.owner_id.clone().unwrap_or_default(),
lease_until_micros: stored.lease_until_micros,
});
}
stored.fencing_token = stored.fencing_token.saturating_add(1);
stored.owner_id = Some(owner_id.to_string());
stored.lease_until_micros = now.saturating_add(ttl_micros);
stored.acquired_at_micros = now;
if let Some(meta) = &meta_json {
stored.meta_json = meta.clone();
}
let outcome = LeaseOutcome::Acquired(MonitorLease {
owner_id: owner_id.to_string(),
fencing_token: stored.fencing_token,
lease_until_micros: stored.lease_until_micros,
});
match self
.write_lease(
caller.clone(),
project_id,
scope_id,
&key,
&stored,
cas_assertion(project_id, scope_id, &key, entry.as_ref()),
base_seq,
Vec::new(),
)
.await
{
Ok(_) => return Ok(outcome),
Err(err) if is_retryable_conflict(&err) => continue,
Err(err) => return Err(err),
}
}
Err(AedbError::Conflict(
"lease_acquire exhausted retries".into(),
))
}
pub async fn lease_renew(
&self,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
ttl_micros: u64,
) -> Result<RenewOutcome, AedbError> {
self.lease_renew_inner(
None,
project_id,
scope_id,
name,
fencing_token,
ttl_micros,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn lease_renew_as(
&self,
caller: CallerContext,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
ttl_micros: u64,
meta_json: Option<String>,
) -> Result<RenewOutcome, AedbError> {
self.lease_renew_inner(
Some(caller),
project_id,
scope_id,
name,
fencing_token,
ttl_micros,
meta_json,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn lease_renew_inner(
&self,
caller: Option<CallerContext>,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
ttl_micros: u64,
meta_json: Option<String>,
) -> Result<RenewOutcome, AedbError> {
if let Some(meta) = &meta_json {
ensure_valid_json(meta)?;
}
let key = lease_key(name);
for _ in 0..MAX_CAS_RETRIES {
let lease = self.acquire_snapshot(ConsistencyMode::AtLatest).await?;
let base_seq = lease.view.seq;
let entry = lease.view.keyspace.try_kv_get(project_id, scope_id, &key)?;
let mut stored = decode(entry.as_ref());
if stored.fencing_token != fencing_token {
return Ok(RenewOutcome::LeaseLost);
}
let now = crate::system_now_micros();
stored.lease_until_micros = now.saturating_add(ttl_micros);
if let Some(meta) = &meta_json {
stored.meta_json = meta.clone();
}
let renewed = RenewOutcome::Renewed(MonitorLease {
owner_id: stored.owner_id.clone().unwrap_or_default(),
fencing_token,
lease_until_micros: stored.lease_until_micros,
});
match self
.write_lease(
caller.clone(),
project_id,
scope_id,
&key,
&stored,
cas_assertion(project_id, scope_id, &key, entry.as_ref()),
base_seq,
Vec::new(),
)
.await
{
Ok(_) => return Ok(renewed),
Err(err) if is_retryable_conflict(&err) => continue,
Err(err) => return Err(err),
}
}
Err(AedbError::Conflict("lease_renew exhausted retries".into()))
}
pub async fn lease_release(
&self,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
) -> Result<(), AedbError> {
self.lease_release_inner(None, project_id, scope_id, name, fencing_token)
.await
}
pub async fn lease_release_as(
&self,
caller: CallerContext,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
) -> Result<(), AedbError> {
self.lease_release_inner(Some(caller), project_id, scope_id, name, fencing_token)
.await
}
async fn lease_release_inner(
&self,
caller: Option<CallerContext>,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
) -> Result<(), AedbError> {
let key = lease_key(name);
for _ in 0..MAX_CAS_RETRIES {
let lease = self.acquire_snapshot(ConsistencyMode::AtLatest).await?;
let base_seq = lease.view.seq;
let entry = lease.view.keyspace.try_kv_get(project_id, scope_id, &key)?;
let mut stored = decode(entry.as_ref());
if stored.fencing_token != fencing_token {
return Ok(()); }
stored.owner_id = None;
stored.lease_until_micros = 0;
match self
.write_lease(
caller.clone(),
project_id,
scope_id,
&key,
&stored,
cas_assertion(project_id, scope_id, &key, entry.as_ref()),
base_seq,
Vec::new(),
)
.await
{
Ok(_) => return Ok(()),
Err(err) if is_retryable_conflict(&err) => continue,
Err(err) => return Err(err),
}
}
Err(AedbError::Conflict(
"lease_release exhausted retries".into(),
))
}
pub async fn lease_guarded_commit(
&self,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
mutations: Vec<Mutation>,
) -> Result<FencedCommit, AedbError> {
self.lease_guarded_commit_inner(None, project_id, scope_id, name, fencing_token, mutations)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn lease_guarded_commit_as(
&self,
caller: CallerContext,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
mutations: Vec<Mutation>,
) -> Result<FencedCommit, AedbError> {
self.lease_guarded_commit_inner(
Some(caller),
project_id,
scope_id,
name,
fencing_token,
mutations,
)
.await
}
async fn lease_guarded_commit_inner(
&self,
caller: Option<CallerContext>,
project_id: &str,
scope_id: &str,
name: &str,
fencing_token: u64,
mutations: Vec<Mutation>,
) -> Result<FencedCommit, AedbError> {
let key = lease_key(name);
for _ in 0..MAX_CAS_RETRIES {
let lease = self.acquire_snapshot(ConsistencyMode::AtLatest).await?;
let base_seq = lease.view.seq;
let entry = lease.view.keyspace.try_kv_get(project_id, scope_id, &key)?;
let stored = decode(entry.as_ref());
let now = crate::system_now_micros();
if stored.fencing_token != fencing_token || stored.lease_until_micros <= now {
return Ok(FencedCommit::LeaseLost);
}
let assertion = cas_assertion(project_id, scope_id, &key, entry.as_ref());
let envelope = TransactionEnvelope {
caller: caller.clone(),
idempotency_key: None,
write_class: WriteClass::Standard,
assertions: vec![assertion],
read_set: ReadSet::default(),
write_intent: WriteIntent {
mutations: mutations.clone(),
},
base_seq,
};
match self.commit_envelope(envelope).await {
Ok(commit) => return Ok(FencedCommit::Applied(commit)),
Err(err) if is_retryable_conflict(&err) => continue,
Err(err) => return Err(err),
}
}
Err(AedbError::Conflict(
"lease_guarded_commit exhausted retries".into(),
))
}
pub async fn lease_status(
&self,
project_id: &str,
scope_id: &str,
name: &str,
consistency: ConsistencyMode,
) -> Result<Option<LeaseRecord>, AedbError> {
if self.require_authenticated_calls {
return Err(AedbError::PermissionDenied(
"authenticated caller required in secure mode".into(),
));
}
self.lease_status_inner(None, project_id, scope_id, name, consistency)
.await
}
pub async fn lease_status_as(
&self,
caller: &CallerContext,
project_id: &str,
scope_id: &str,
name: &str,
consistency: ConsistencyMode,
) -> Result<Option<LeaseRecord>, AedbError> {
self.lease_status_inner(Some(caller), project_id, scope_id, name, consistency)
.await
}
async fn lease_status_inner(
&self,
caller: Option<&CallerContext>,
project_id: &str,
scope_id: &str,
name: &str,
consistency: ConsistencyMode,
) -> Result<Option<LeaseRecord>, AedbError> {
let lease = self.acquire_snapshot(consistency).await?;
if let Some(caller) = caller {
let required = Permission::KvRead {
project_id: project_id.to_string(),
scope_id: Some(scope_id.to_string()),
prefix: Some(LEASE_KEY_PREFIX.to_vec()),
};
if !lease
.view
.catalog
.has_permission(&caller.caller_id, &required)
{
return Err(AedbError::PermissionDenied("permission denied".into()));
}
}
let Some(entry) = lease
.view
.keyspace
.try_kv_get(project_id, scope_id, &lease_key(name))?
else {
return Ok(None);
};
let stored = serde_json::from_slice::<StoredLease>(&entry.value)
.map_err(|_| AedbError::Decode("lease record is corrupt".into()))?;
Ok(Some(LeaseRecord {
project_id: project_id.to_string(),
scope_id: scope_id.to_string(),
name: name.to_string(),
owner_id: stored.owner_id,
fencing_token: stored.fencing_token,
lease_until_micros: stored.lease_until_micros,
acquired_at_micros: stored.acquired_at_micros,
meta_json: stored.meta_json,
}))
}
#[allow(clippy::too_many_arguments)]
async fn write_lease(
&self,
caller: Option<CallerContext>,
project_id: &str,
scope_id: &str,
key: &[u8],
stored: &StoredLease,
assertion: ReadAssertion,
base_seq: u64,
extra: Vec<Mutation>,
) -> Result<CommitResult, AedbError> {
let value = serde_json::to_vec(stored)
.map_err(|e| AedbError::Encode(format!("lease encode failed: {e}")))?;
let mut mutations = extra;
mutations.push(Mutation::KvSet {
project_id: project_id.to_string(),
scope_id: scope_id.to_string(),
key: key.to_vec(),
value,
});
self.commit_envelope(TransactionEnvelope {
caller,
idempotency_key: None,
write_class: WriteClass::Standard,
assertions: vec![assertion],
read_set: ReadSet::default(),
write_intent: WriteIntent { mutations },
base_seq,
})
.await
}
}
fn ensure_valid_json(s: &str) -> Result<(), AedbError> {
serde_json::from_str::<serde_json::Value>(s)
.map(|_| ())
.map_err(|_| AedbError::Validation("meta_json must be valid JSON".into()))
}
#[cfg(test)]
mod tests;