use serde::{Deserialize, Serialize};
use super::error::AppError;
use super::store::{KS_PENDING_PURGES, Store, WriteBatch};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PendingPurge {
pub domain: String,
pub scheduled_at: u64,
pub grace_seconds: u64,
pub reason: String,
pub scheduled_by: String,
}
fn pending_key(domain: &str) -> String {
format!("pending_purges:{domain}")
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScheduleOutcome {
Created(PendingPurge),
Replaced {
previous: PendingPurge,
new: PendingPurge,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum CancelOutcome {
Removed(PendingPurge),
Missing,
}
pub async fn schedule(
store: &Store,
domain: &str,
scheduled_at: u64,
grace_seconds: u64,
reason: &str,
scheduled_by: &str,
) -> Result<ScheduleOutcome, AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
let key = pending_key(domain);
let new = PendingPurge {
domain: domain.to_string(),
scheduled_at,
grace_seconds,
reason: reason.to_string(),
scheduled_by: scheduled_by.to_string(),
};
let outcome = match ks.get::<PendingPurge>(key.clone()).await? {
Some(previous) => ScheduleOutcome::Replaced {
previous,
new: new.clone(),
},
None => ScheduleOutcome::Created(new.clone()),
};
ks.insert(key, &new).await?;
Ok(outcome)
}
pub async fn cancel(store: &Store, domain: &str) -> Result<CancelOutcome, AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
let key = pending_key(domain);
let Some(existing) = ks.get::<PendingPurge>(key.clone()).await? else {
return Ok(CancelOutcome::Missing);
};
ks.remove(key).await?;
Ok(CancelOutcome::Removed(existing))
}
pub fn stage_schedule(
store: &Store,
batch: &mut WriteBatch,
domain: &str,
scheduled_at: u64,
grace_seconds: u64,
reason: &str,
scheduled_by: &str,
) -> Result<PendingPurge, AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
let new = PendingPurge {
domain: domain.to_string(),
scheduled_at,
grace_seconds,
reason: reason.to_string(),
scheduled_by: scheduled_by.to_string(),
};
batch.insert(&ks, pending_key(domain), &new)?;
Ok(new)
}
pub fn stage_cancel(store: &Store, batch: &mut WriteBatch, domain: &str) -> Result<(), AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
batch.remove(&ks, pending_key(domain));
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum PurgeCheck {
Due,
Superseded,
Stale(&'static str),
}
pub async fn check_still_due(store: &Store, listed: &PendingPurge) -> Result<PurgeCheck, AppError> {
if get(store, &listed.domain).await?.as_ref() != Some(listed) {
return Ok(PurgeCheck::Superseded);
}
if listed.reason == super::domain::DISABLE_PURGE_REASON {
if let Some(entry) = super::domain::get_domain(store, &listed.domain).await?
&& entry.status.is_active()
{
return Ok(PurgeCheck::Stale("the domain is active again"));
}
} else if super::assignment::get(store, &listed.domain)
.await?
.is_some()
{
return Ok(PurgeCheck::Stale(
"the domain is assigned to this server again",
));
}
Ok(PurgeCheck::Due)
}
pub async fn cancel_if_unchanged(store: &Store, listed: &PendingPurge) -> Result<bool, AppError> {
if get(store, &listed.domain).await?.as_ref() != Some(listed) {
return Ok(false);
}
store
.keyspace(KS_PENDING_PURGES)?
.remove(pending_key(&listed.domain))
.await?;
Ok(true)
}
pub async fn get(store: &Store, domain: &str) -> Result<Option<PendingPurge>, AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
ks.get(pending_key(domain)).await
}
pub async fn list(store: &Store) -> Result<Vec<PendingPurge>, AppError> {
let ks = store.keyspace(KS_PENDING_PURGES)?;
let raw = ks.prefix_iter_raw(b"pending_purges:".to_vec()).await?;
let mut out = Vec::with_capacity(raw.len());
for (_k, v) in raw {
if let Ok(p) = serde_json::from_slice::<PendingPurge>(&v) {
out.push(p);
}
}
Ok(out)
}
impl PendingPurge {
pub fn is_ripe(&self, now_epoch: u64) -> bool {
self.scheduled_at.saturating_add(self.grace_seconds) <= now_epoch
}
}
pub fn parse_grace_string(s: &str) -> Result<u64, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty duration".into());
}
let (digits, unit_idx) = trimmed
.find(|c: char| !c.is_ascii_digit())
.map(|i| (&trimmed[..i], i))
.unwrap_or((trimmed, trimmed.len()));
if digits.is_empty() {
return Err(format!("missing leading digits in '{s}'"));
}
let n: u64 = digits
.parse()
.map_err(|e| format!("invalid digits '{digits}': {e}"))?;
let unit = trimmed[unit_idx..].trim().to_ascii_lowercase();
let multiplier: u64 = match unit.as_str() {
"" | "s" | "sec" | "secs" | "second" | "seconds" => 1,
"m" | "min" | "mins" | "minute" | "minutes" => 60,
"h" | "hr" | "hrs" | "hour" | "hours" => 60 * 60,
"d" | "day" | "days" => 24 * 60 * 60,
other => {
return Err(format!("unsupported unit '{other}'; use s / m / h / d"));
}
};
n.checked_mul(multiplier)
.ok_or_else(|| format!("duration '{s}' overflows u64 seconds"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
#[tokio::test]
async fn schedule_then_get() {
let store = fjall_store().await;
let outcome = schedule(&store, "a.example", 100, 60, "grace-expired", "did:c")
.await
.unwrap();
assert!(matches!(outcome, ScheduleOutcome::Created(_)));
let fetched = get(&store, "a.example").await.unwrap().unwrap();
assert_eq!(fetched.domain, "a.example");
assert_eq!(fetched.scheduled_at, 100);
assert_eq!(fetched.grace_seconds, 60);
assert_eq!(fetched.reason, "grace-expired");
}
#[tokio::test]
async fn schedule_replaces_existing_and_resets_timer() {
let store = fjall_store().await;
schedule(&store, "a.example", 100, 60, "first", "did:c")
.await
.unwrap();
let outcome = schedule(&store, "a.example", 500, 120, "second", "did:c2")
.await
.unwrap();
let ScheduleOutcome::Replaced { previous, new } = outcome else {
panic!("expected Replaced");
};
assert_eq!(previous.scheduled_at, 100);
assert_eq!(new.scheduled_at, 500);
assert_eq!(new.grace_seconds, 120);
}
#[tokio::test]
async fn cancel_removes_existing() {
let store = fjall_store().await;
schedule(&store, "a.example", 1, 60, "x", "y")
.await
.unwrap();
let outcome = cancel(&store, "a.example").await.unwrap();
let CancelOutcome::Removed(_) = outcome else {
panic!("expected Removed");
};
assert!(get(&store, "a.example").await.unwrap().is_none());
}
#[tokio::test]
async fn cancel_missing_is_no_op() {
let store = fjall_store().await;
let outcome = cancel(&store, "absent.example").await.unwrap();
assert_eq!(outcome, CancelOutcome::Missing);
}
#[tokio::test]
async fn list_yields_every_pending() {
let store = fjall_store().await;
schedule(&store, "a.example", 1, 1, "x", "y").await.unwrap();
schedule(&store, "b.example", 1, 1, "x", "y").await.unwrap();
schedule(&store, "c.example", 1, 1, "x", "y").await.unwrap();
let mut names: Vec<String> = list(&store)
.await
.unwrap()
.into_iter()
.map(|p| p.domain)
.collect();
names.sort();
assert_eq!(names, vec!["a.example", "b.example", "c.example"]);
}
#[test]
fn ripeness_check() {
let p = PendingPurge {
domain: "x".into(),
scheduled_at: 100,
grace_seconds: 60,
reason: "x".into(),
scheduled_by: "y".into(),
};
assert!(!p.is_ripe(100));
assert!(!p.is_ripe(159));
assert!(p.is_ripe(160));
assert!(p.is_ripe(u64::MAX));
}
#[test]
fn parse_grace_string_known_units() {
assert_eq!(parse_grace_string("30s").unwrap(), 30);
assert_eq!(parse_grace_string("45").unwrap(), 45); assert_eq!(parse_grace_string("5m").unwrap(), 300);
assert_eq!(parse_grace_string("2h").unwrap(), 7200);
assert_eq!(parse_grace_string("1d").unwrap(), 86_400);
assert_eq!(parse_grace_string(" 2H ").unwrap(), 7200); }
#[test]
fn parse_grace_string_rejects_garbage() {
assert!(parse_grace_string("").is_err());
assert!(parse_grace_string("h").is_err()); assert!(parse_grace_string("10x").is_err()); assert!(parse_grace_string("abc").is_err());
}
}