#![cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
use std::sync::Arc;
use boatramp_core::kv::KvStore;
use serde::{Deserialize, Serialize};
const TOMBSTONE_PREFIX: &str = "tenant-tombstone";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tombstone {
#[serde(default = "one")]
pub version: u32,
pub project: String,
pub renamed_db: String,
pub original_db: String,
pub role: String,
pub engine: String,
pub compute: String,
pub superuser: String,
pub cred_workload: String,
pub deleted_at: u64,
pub delete_after: u64,
}
fn one() -> u32 {
1
}
impl Tombstone {
pub fn key(&self) -> String {
key_for(&self.project, &self.renamed_db)
}
pub fn is_due(&self, now: u64) -> bool {
self.delete_after <= now
}
}
fn key_for(project: &str, renamed_db: &str) -> String {
format!("{TOMBSTONE_PREFIX}/{project}/{renamed_db}")
}
pub async fn put(kv: &Arc<dyn KvStore>, ts: &Tombstone) -> Result<(), String> {
let bytes = serde_json::to_vec(ts).map_err(|e| format!("serialize tombstone: {e}"))?;
kv.put(&ts.key(), bytes)
.await
.map_err(|e| format!("store tombstone {}: {e}", ts.key()))
}
pub async fn get(
kv: &Arc<dyn KvStore>,
project: &str,
renamed_db: &str,
) -> Result<Option<Tombstone>, String> {
let key = key_for(project, renamed_db);
match kv.get(&key).await.map_err(|e| e.to_string())? {
Some(bytes) => {
let ts = serde_json::from_slice(&bytes)
.map_err(|e| format!("deserialize tombstone {key}: {e}"))?;
Ok(Some(ts))
}
None => Ok(None),
}
}
pub async fn list(kv: &Arc<dyn KvStore>) -> Result<Vec<Tombstone>, String> {
let prefix = format!("{TOMBSTONE_PREFIX}/");
let keys = kv
.list_prefix(&prefix)
.await
.map_err(|e| format!("list tombstones: {e}"))?;
let mut out = Vec::with_capacity(keys.len());
for key in keys {
if let Some(bytes) = kv.get(&key).await.map_err(|e| e.to_string())? {
match serde_json::from_slice::<Tombstone>(&bytes) {
Ok(ts) => out.push(ts),
Err(e) => {
tracing::warn!(%key, error = %e, "skipping undeserializable tenant tombstone");
}
}
}
}
Ok(out)
}
pub async fn delete(kv: &Arc<dyn KvStore>, ts: &Tombstone) -> Result<(), String> {
kv.delete(&ts.key())
.await
.map_err(|e| format!("delete tombstone {}: {e}", ts.key()))
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::kv::MemoryKv;
fn sample() -> Tombstone {
Tombstone {
version: 1,
project: "acme".into(),
renamed_db: "appdb_acme__deleted_1700000000".into(),
original_db: "appdb_acme".into(),
role: "appdb_acme_role".into(),
engine: "postgres".into(),
compute: "pg".into(),
superuser: "super".into(),
cred_workload: "pg/acme_hash".into(),
deleted_at: 1_700_000_000,
delete_after: 1_700_604_800, }
}
#[test]
fn key_scheme_folds_project_and_renamed_db() {
let ts = sample();
assert_eq!(
ts.key(),
"tenant-tombstone/acme/appdb_acme__deleted_1700000000"
);
}
#[test]
fn is_due_only_at_or_after_delete_after() {
let ts = sample();
assert!(!ts.is_due(ts.delete_after - 1), "before grace: not due");
assert!(ts.is_due(ts.delete_after), "at delete_after: due");
assert!(ts.is_due(ts.delete_after + 1), "after grace: due");
}
#[tokio::test]
async fn tombstone_kv_round_trip() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let ts = sample();
put(&kv, &ts).await.unwrap();
let got = get(&kv, &ts.project, &ts.renamed_db)
.await
.unwrap()
.expect("tombstone present after put");
assert_eq!(got, ts, "get round-trips the exact record");
let listed = list(&kv).await.unwrap();
assert_eq!(listed, vec![ts.clone()], "list returns the one tombstone");
delete(&kv, &ts).await.unwrap();
assert!(
get(&kv, &ts.project, &ts.renamed_db)
.await
.unwrap()
.is_none(),
"tombstone gone after delete"
);
assert!(
list(&kv).await.unwrap().is_empty(),
"list empty after delete"
);
}
#[tokio::test]
async fn distinct_renamed_names_do_not_collide() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let mut a = sample();
a.renamed_db = "appdb_acme__deleted_1700000000".into();
let mut b = sample();
b.renamed_db = "appdb_acme__deleted_1700000500".into();
b.deleted_at = 1_700_000_500;
put(&kv, &a).await.unwrap();
put(&kv, &b).await.unwrap();
assert_ne!(a.key(), b.key());
let mut listed = list(&kv).await.unwrap();
listed.sort_by(|x, y| x.renamed_db.cmp(&y.renamed_db));
assert_eq!(listed, vec![a, b], "both tombstones coexist");
}
}