use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
use vta_sdk::protocols::app_state::{
AppStateRecord, AppStateWrite, PutManyMode, WriteOutcome, WriteResult,
};
use vti_common::pagination::{Cursor, CursorKey, MAX_LIMIT, paginate};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub const MAX_VALUE_BYTES: u64 = 65_536;
pub const MAX_GET_MANY_KEYS: usize = 256;
pub const MAX_PUT_MANY_WRITES: usize = 64;
pub const GET_MANY_RESPONSE_BUDGET_BYTES: u64 = 512 * 1024;
pub const PUT_MANY_REQUEST_BUDGET_BYTES: u64 = 768 * 1024;
pub const DEFAULT_TOMBSTONE_RETENTION_SECONDS: u64 = 30 * 24 * 60 * 60;
fn namespace_is_valid(ns: &str) -> bool {
if ns.is_empty() || ns.len() > 64 {
return false;
}
let bytes = ns.as_bytes();
if !bytes[0].is_ascii_lowercase() {
return false;
}
let mut prev_hyphen = false;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'a'..=b'z' | b'0'..=b'9' => prev_hyphen = false,
b'-' => {
if prev_hyphen || i + 1 == bytes.len() {
return false;
}
prev_hyphen = true;
}
_ => return false,
}
}
true
}
fn key_is_valid(key: &str) -> bool {
!key.is_empty() && key.len() <= 512 && !key.contains('\0')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ConflictReason {
VersionMismatch,
RecordExists,
RecordAbsent,
CreateOnlyNotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FilterConflict {
SinceVersionRequiresNamespace,
ChangeFeedCannotExcludeDeleted,
}
#[derive(Debug)]
pub enum AppStateError {
NotFound,
VersionConflict {
reason: ConflictReason,
current_version: Option<u64>,
current_value: Option<Value>,
current_deleted: Option<bool>,
},
ValueTooLarge { limit_bytes: u64, actual_bytes: u64 },
FilterConflict(FilterConflict),
WatermarkTooOld {
oldest_retained_version: u64,
high_watermark: u64,
},
DuplicateKey(Vec<String>),
AtomicBatchRejected(Vec<WriteResult>),
BatchTooLarge { limit_bytes: u64, actual_bytes: u64 },
Validation(String),
Internal(String),
}
impl std::fmt::Display for AppStateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "no record at that address"),
Self::VersionConflict {
reason,
current_version,
..
} => match (reason, current_version) {
(ConflictReason::VersionMismatch, Some(v)) => {
write!(
f,
"record is at version {v}; the supplied expectedVersion did not match"
)
}
(ConflictReason::RecordExists, Some(v)) => {
write!(
f,
"expectedVersion 0 requires no live record, but one exists at version {v}"
)
}
(ConflictReason::RecordAbsent, _) => {
write!(f, "expectedVersion was supplied but no live record exists")
}
(ConflictReason::CreateOnlyNotApplicable, _) => write!(
f,
"expectedVersion 0 is not applicable to a delete: a create-only \
precondition on a removal can never be satisfied"
),
(ConflictReason::VersionMismatch | ConflictReason::RecordExists, None) => {
write!(f, "the supplied expectedVersion did not match")
}
},
Self::ValueTooLarge {
limit_bytes,
actual_bytes,
} => write!(
f,
"value is {actual_bytes} bytes; this VTA's per-record cap is {limit_bytes}"
),
Self::FilterConflict(FilterConflict::SinceVersionRequiresNamespace) => write!(
f,
"sinceVersion requires namespace: the version counter is per (contextId, \
namespace), so a watermark spanning namespaces names no single point in time"
),
Self::FilterConflict(FilterConflict::ChangeFeedCannotExcludeDeleted) => write!(
f,
"a change feed cannot exclude tombstones: without them a consumer never \
learns of a deletion and its copy cannot converge"
),
Self::WatermarkTooOld {
oldest_retained_version,
..
} => write!(
f,
"tombstones before version {oldest_retained_version} have been reaped; \
resume from a snapshot rather than this watermark"
),
Self::DuplicateKey(keys) => {
write!(f, "duplicate keys in one batch: {}", keys.join(", "))
}
Self::AtomicBatchRejected(_) => {
write!(f, "atomic batch not applied; nothing was written")
}
Self::BatchTooLarge {
limit_bytes,
actual_bytes,
} => write!(
f,
"batch is {actual_bytes} bytes; this VTA accepts {limit_bytes} per request"
),
Self::Validation(m) => write!(f, "{m}"),
Self::Internal(m) => write!(f, "internal error: {m}"),
}
}
}
impl From<AppError> for AppStateError {
fn from(e: AppError) -> Self {
match e {
AppError::InvalidCursor => {
Self::Validation("cursor is not valid for this query".into())
}
other => Self::Internal(other.to_string()),
}
}
}
#[derive(Clone, Default)]
pub struct NamespaceLocks {
inner: Arc<StdMutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
}
impl NamespaceLocks {
pub async fn acquire(&self, context_id: &str, namespace: &str) -> OwnedMutexGuard<()> {
let name = format!("{context_id}\u{0}{namespace}");
let lock = {
let mut map = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(map.entry(name).or_default())
};
lock.lock_owned().await
}
}
fn record_key(context_id: &str, namespace: &str, key: &str) -> String {
format!("app:{context_id}:{namespace}:{key}")
}
fn namespace_prefix(context_id: &str, namespace: &str) -> String {
format!("app:{context_id}:{namespace}:")
}
fn context_prefix(context_id: &str) -> String {
format!("app:{context_id}:")
}
fn index_key(context_id: &str, namespace: &str, version: u64) -> String {
format!("appv:{context_id}:{namespace}:{version:020}")
}
fn index_prefix(context_id: &str, namespace: &str) -> String {
format!("appv:{context_id}:{namespace}:")
}
fn counter_key(context_id: &str, namespace: &str) -> String {
format!("appc:{context_id}:{namespace}")
}
fn reaped_key(context_id: &str, namespace: &str) -> String {
format!("appt:{context_id}:{namespace}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredRecord {
context_id: String,
namespace: String,
key: String,
version: u64,
deleted: bool,
#[serde(default)]
value: Value,
value_bytes: u64,
created_at: String,
updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
deleted_at: Option<String>,
}
impl StoredRecord {
fn to_wire(&self, with_value: bool) -> AppStateRecord {
AppStateRecord {
context_id: self.context_id.clone(),
namespace: self.namespace.clone(),
key: self.key.clone(),
version: self.version,
deleted: self.deleted,
value: (with_value && !self.deleted).then(|| self.value.clone()),
value_bytes: (!self.deleted).then_some(self.value_bytes),
created_at: Some(self.created_at.clone()),
updated_at: self.updated_at.clone(),
deleted_at: self.deleted_at.clone(),
}
}
}
async fn read_record(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
key: &str,
) -> Result<Option<StoredRecord>, AppStateError> {
let raw = ks.get_raw(record_key(context_id, namespace, key)).await?;
match raw {
None => Ok(None),
Some(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| AppStateError::Internal(format!("decode app-state record: {e}"))),
}
}
async fn read_counter(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
) -> Result<u64, AppStateError> {
read_u64(ks, counter_key(context_id, namespace)).await
}
async fn read_reaped_through(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
) -> Result<u64, AppStateError> {
read_u64(ks, reaped_key(context_id, namespace)).await
}
async fn read_u64(ks: &KeyspaceHandle, key: String) -> Result<u64, AppStateError> {
match ks.get_raw(key).await? {
None => Ok(0),
Some(bytes) => {
let text = std::str::from_utf8(&bytes)
.map_err(|e| AppStateError::Internal(format!("counter is not UTF-8: {e}")))?;
text.parse()
.map_err(|e| AppStateError::Internal(format!("counter is not a u64: {e}")))
}
}
}
async fn write_u64(ks: &KeyspaceHandle, key: String, value: u64) -> Result<(), AppStateError> {
ks.insert_raw(key, value.to_string().into_bytes()).await?;
Ok(())
}
async fn reserve_versions(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
count: u64,
) -> Result<u64, AppStateError> {
let current = read_counter(ks, context_id, namespace).await?;
let first = current + 1;
write_u64(ks, counter_key(context_id, namespace), current + count).await?;
ks.persist().await?;
vti_common::integrity::reseal_if_active()
.await
.map_err(|e| AppStateError::Internal(format!("reseal after version reservation: {e}")))?;
Ok(first)
}
fn value_size(value: &Value) -> u64 {
serde_json::to_vec(value)
.map(|v| v.len() as u64)
.unwrap_or(0)
}
fn merge_patch(target: &mut Value, patch: &Value) {
match patch {
Value::Object(patch_map) => {
if !target.is_object() {
*target = Value::Object(serde_json::Map::new());
}
let target_map = target.as_object_mut().expect("just made it an object");
for (k, v) in patch_map {
if v.is_null() {
target_map.remove(k);
} else {
let entry = target_map.entry(k.clone()).or_insert(Value::Null);
merge_patch(entry, v);
}
}
}
other => *target = other.clone(),
}
}
fn validate_address(namespace: &str, key: &str) -> Result<(), AppStateError> {
if !namespace_is_valid(namespace) {
return Err(AppStateError::Validation(format!(
"namespace `{namespace}` is not a valid partition name \
(lowercase alphanumeric with single interior hyphens, 1-64 bytes)"
)));
}
if !key_is_valid(key) {
return Err(AppStateError::Validation(
"key must be 1-512 bytes and must not contain NUL".into(),
));
}
Ok(())
}
pub async fn get(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
key: &str,
include_deleted: bool,
) -> Result<AppStateRecord, AppStateError> {
validate_address(namespace, key)?;
match read_record(ks, context_id, namespace, key).await? {
Some(r) if !r.deleted || include_deleted => Ok(r.to_wire(true)),
_ => Err(AppStateError::NotFound),
}
}
pub async fn get_many(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
keys: &[String],
include_deleted: bool,
) -> Result<(Vec<AppStateRecord>, Vec<String>, Vec<String>), AppStateError> {
if keys.is_empty() || keys.len() > MAX_GET_MANY_KEYS {
return Err(AppStateError::Validation(format!(
"keys must hold between 1 and {MAX_GET_MANY_KEYS} entries"
)));
}
let mut seen = HashSet::with_capacity(keys.len());
let mut dupes = Vec::new();
for k in keys {
if !seen.insert(k.as_str()) {
dupes.push(k.clone());
}
}
if !dupes.is_empty() {
dupes.sort();
dupes.dedup();
return Err(AppStateError::DuplicateKey(dupes));
}
for k in keys {
validate_address(namespace, k)?;
}
let mut records = Vec::new();
let mut missing = Vec::new();
let mut deferred = Vec::new();
let mut budget = GET_MANY_RESPONSE_BUDGET_BYTES;
for key in keys {
if !deferred.is_empty() {
deferred.push(key.clone());
continue;
}
match read_record(ks, context_id, namespace, key).await? {
Some(r) if !r.deleted || include_deleted => {
if r.value_bytes > budget && !records.is_empty() {
deferred.push(key.clone());
continue;
}
budget = budget.saturating_sub(r.value_bytes);
records.push(r.to_wire(true));
}
_ => missing.push(key.clone()),
}
}
Ok((records, missing, deferred))
}
#[derive(Debug, Clone)]
pub struct PutOutcome {
pub version: u64,
pub created: bool,
pub updated_at: String,
pub value_bytes: u64,
}
#[allow(clippy::too_many_arguments)]
async fn put_locked(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
key: &str,
value: Option<&Value>,
patch: Option<&Value>,
expected_version: Option<u64>,
version: u64,
now: &str,
) -> Result<PutOutcome, AppStateError> {
validate_address(namespace, key)?;
let existing = read_record(ks, context_id, namespace, key).await?;
let live = existing.as_ref().filter(|r| !r.deleted);
match expected_version {
None => {}
Some(0) => {
if let Some(cur) = live {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::RecordExists,
current_version: Some(cur.version),
current_value: Some(cur.value.clone()),
current_deleted: None,
});
}
}
Some(want) => match live {
None => {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::RecordAbsent,
current_version: existing.as_ref().map(|r| r.version),
current_value: None,
current_deleted: existing.as_ref().map(|r| r.deleted),
});
}
Some(cur) if cur.version != want => {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::VersionMismatch,
current_version: Some(cur.version),
current_value: Some(cur.value.clone()),
current_deleted: None,
});
}
Some(_) => {}
},
}
let new_value = match (value, patch) {
(Some(v), None) => v.clone(),
(None, Some(p)) => {
let Some(cur) = live else {
return Err(AppStateError::NotFound);
};
let mut base = cur.value.clone();
merge_patch(&mut base, p);
base
}
_ => {
return Err(AppStateError::Validation(
"exactly one of `value` or `mergePatch` must be supplied".into(),
));
}
};
let size = value_size(&new_value);
if size > MAX_VALUE_BYTES {
return Err(AppStateError::ValueTooLarge {
limit_bytes: MAX_VALUE_BYTES,
actual_bytes: size,
});
}
let next = version;
let created = live.is_none();
let created_at = existing
.as_ref()
.map(|r| r.created_at.clone())
.filter(|_| !created)
.unwrap_or_else(|| now.to_string());
let record = StoredRecord {
context_id: context_id.to_string(),
namespace: namespace.to_string(),
key: key.to_string(),
version: next,
deleted: false,
value: new_value,
value_bytes: size,
created_at,
updated_at: now.to_string(),
deleted_at: None,
};
if let Some(prev) = existing.as_ref() {
ks.remove(index_key(context_id, namespace, prev.version))
.await?;
}
ks.insert_raw(
index_key(context_id, namespace, next),
key.as_bytes().to_vec(),
)
.await?;
ks.insert(record_key(context_id, namespace, key), &record)
.await?;
Ok(PutOutcome {
version: next,
created,
updated_at: now.to_string(),
value_bytes: size,
})
}
pub async fn put(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
context_id: &str,
namespace: &str,
key: &str,
value: Option<&Value>,
patch: Option<&Value>,
expected_version: Option<u64>,
) -> Result<PutOutcome, AppStateError> {
validate_address(namespace, key)?;
let _guard = locks.acquire(context_id, namespace).await;
let now = Utc::now().to_rfc3339();
let version = reserve_versions(ks, context_id, namespace, 1).await?;
put_locked(
ks,
context_id,
namespace,
key,
value,
patch,
expected_version,
version,
&now,
)
.await
}
pub async fn put_many(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
context_id: &str,
namespace: &str,
writes: &[AppStateWrite],
mode: PutManyMode,
) -> Result<(Vec<WriteResult>, u64), AppStateError> {
if writes.is_empty() || writes.len() > MAX_PUT_MANY_WRITES {
return Err(AppStateError::Validation(format!(
"writes must hold between 1 and {MAX_PUT_MANY_WRITES} entries"
)));
}
let mut seen = HashSet::with_capacity(writes.len());
let mut dupes = Vec::new();
for w in writes {
if !seen.insert(w.key.as_str()) {
dupes.push(w.key.clone());
}
}
if !dupes.is_empty() {
dupes.sort();
dupes.dedup();
return Err(AppStateError::DuplicateKey(dupes));
}
for w in writes {
validate_address(namespace, &w.key)?;
}
let submitted: u64 = writes
.iter()
.map(|w| {
w.value
.as_ref()
.or(w.merge_patch.as_ref())
.map(value_size)
.unwrap_or(0)
})
.sum();
if submitted > PUT_MANY_REQUEST_BUDGET_BYTES {
return Err(AppStateError::BatchTooLarge {
limit_bytes: PUT_MANY_REQUEST_BUDGET_BYTES,
actual_bytes: submitted,
});
}
let _guard = locks.acquire(context_id, namespace).await;
let now = Utc::now().to_rfc3339();
if mode == PutManyMode::Atomic {
if let Some(failed_at) = dry_run_atomic(ks, context_id, namespace, writes).await? {
let results = writes
.iter()
.enumerate()
.map(|(i, w)| {
if i == failed_at.index {
failed_at.result.clone()
} else {
WriteResult {
key: w.key.clone(),
outcome: WriteOutcome::Skipped,
version: None,
created: None,
current_version: None,
current_value: None,
current_deleted: None,
limit_bytes: None,
actual_bytes: None,
}
}
})
.collect();
return Err(AppStateError::AtomicBatchRejected(results));
}
}
let first = reserve_versions(ks, context_id, namespace, writes.len() as u64).await?;
let mut results = Vec::with_capacity(writes.len());
for (i, w) in writes.iter().enumerate() {
let outcome = put_locked(
ks,
context_id,
namespace,
&w.key,
w.value.as_ref(),
w.merge_patch.as_ref(),
w.expected_version,
first + i as u64,
&now,
)
.await;
results.push(write_result(&w.key, outcome)?);
}
let high = read_counter(ks, context_id, namespace).await?;
Ok((results, high))
}
struct DryRunFailure {
index: usize,
result: WriteResult,
}
async fn dry_run_atomic(
ks: &KeyspaceHandle,
context_id: &str,
namespace: &str,
writes: &[AppStateWrite],
) -> Result<Option<DryRunFailure>, AppStateError> {
for (index, w) in writes.iter().enumerate() {
validate_address(namespace, &w.key)?;
let existing = read_record(ks, context_id, namespace, &w.key).await?;
let live = existing.as_ref().filter(|r| !r.deleted);
let conflict = match w.expected_version {
None => None,
Some(0) => live.map(|cur| (ConflictReason::RecordExists, Some(cur))),
Some(want) => match live {
None => Some((ConflictReason::RecordAbsent, None)),
Some(cur) if cur.version != want => {
Some((ConflictReason::VersionMismatch, Some(cur)))
}
Some(_) => None,
},
};
if let Some((_reason, cur)) = conflict {
return Ok(Some(DryRunFailure {
index,
result: WriteResult {
key: w.key.clone(),
outcome: WriteOutcome::Conflict,
version: None,
created: None,
current_version: cur
.map(|c| c.version)
.or(existing.as_ref().map(|r| r.version)),
current_value: cur.map(|c| c.value.clone()),
current_deleted: existing.as_ref().map(|r| r.deleted).filter(|d| *d),
limit_bytes: None,
actual_bytes: None,
},
}));
}
if w.merge_patch.is_some() && live.is_none() {
return Ok(Some(DryRunFailure {
index,
result: WriteResult {
key: w.key.clone(),
outcome: WriteOutcome::NotFound,
version: None,
created: None,
current_version: None,
current_value: None,
current_deleted: None,
limit_bytes: None,
actual_bytes: None,
},
}));
}
let projected = match (&w.value, &w.merge_patch) {
(Some(v), None) => v.clone(),
(None, Some(p)) => {
let mut base = live.map(|c| c.value.clone()).unwrap_or(Value::Null);
merge_patch(&mut base, p);
base
}
_ => {
return Err(AppStateError::Validation(format!(
"write for key `{}`: exactly one of `value` or `mergePatch` must be supplied",
w.key
)));
}
};
let size = value_size(&projected);
if size > MAX_VALUE_BYTES {
return Ok(Some(DryRunFailure {
index,
result: WriteResult {
key: w.key.clone(),
outcome: WriteOutcome::TooLarge,
version: None,
created: None,
current_version: None,
current_value: None,
current_deleted: None,
limit_bytes: Some(MAX_VALUE_BYTES),
actual_bytes: Some(size),
},
}));
}
}
Ok(None)
}
fn write_result(
key: &str,
outcome: Result<PutOutcome, AppStateError>,
) -> Result<WriteResult, AppStateError> {
let base = |o: WriteOutcome| WriteResult {
key: key.to_string(),
outcome: o,
version: None,
created: None,
current_version: None,
current_value: None,
current_deleted: None,
limit_bytes: None,
actual_bytes: None,
};
match outcome {
Ok(o) => Ok(WriteResult {
version: Some(o.version),
created: Some(o.created),
..base(WriteOutcome::Written)
}),
Err(AppStateError::VersionConflict {
current_version,
current_value,
current_deleted,
..
}) => Ok(WriteResult {
current_version,
current_value,
current_deleted,
..base(WriteOutcome::Conflict)
}),
Err(AppStateError::ValueTooLarge {
limit_bytes,
actual_bytes,
}) => Ok(WriteResult {
limit_bytes: Some(limit_bytes),
actual_bytes: Some(actual_bytes),
..base(WriteOutcome::TooLarge)
}),
Err(AppStateError::NotFound) => Ok(base(WriteOutcome::NotFound)),
Err(other) => Err(other),
}
}
#[derive(Debug, Clone)]
pub struct DeleteOutcome {
pub existed: bool,
pub version: Option<u64>,
pub deleted_at: Option<String>,
}
pub async fn delete(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
context_id: &str,
namespace: &str,
key: &str,
expected_version: Option<u64>,
) -> Result<DeleteOutcome, AppStateError> {
validate_address(namespace, key)?;
if expected_version == Some(0) {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::CreateOnlyNotApplicable,
current_version: None,
current_value: None,
current_deleted: None,
});
}
let _guard = locks.acquire(context_id, namespace).await;
let existing = read_record(ks, context_id, namespace, key).await?;
let live = existing.as_ref().filter(|r| !r.deleted);
if let Some(want) = expected_version {
match live {
None => {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::RecordAbsent,
current_version: existing.as_ref().map(|r| r.version),
current_value: None,
current_deleted: existing.as_ref().map(|r| r.deleted),
});
}
Some(cur) if cur.version != want => {
return Err(AppStateError::VersionConflict {
reason: ConflictReason::VersionMismatch,
current_version: Some(cur.version),
current_value: Some(cur.value.clone()),
current_deleted: None,
});
}
Some(_) => {}
}
}
let Some(prev) = existing else {
return Ok(DeleteOutcome {
existed: false,
version: None,
deleted_at: None,
});
};
if prev.deleted {
return Ok(DeleteOutcome {
existed: false,
version: Some(prev.version),
deleted_at: prev.deleted_at,
});
}
let now = Utc::now().to_rfc3339();
let next = reserve_versions(ks, context_id, namespace, 1).await?;
let tombstone = StoredRecord {
context_id: context_id.to_string(),
namespace: namespace.to_string(),
key: key.to_string(),
version: next,
deleted: true,
value: Value::Null,
value_bytes: 0,
created_at: prev.created_at,
updated_at: now.clone(),
deleted_at: Some(now.clone()),
};
ks.remove(index_key(context_id, namespace, prev.version))
.await?;
ks.insert_raw(
index_key(context_id, namespace, next),
key.as_bytes().to_vec(),
)
.await?;
ks.insert(record_key(context_id, namespace, key), &tombstone)
.await?;
Ok(DeleteOutcome {
existed: true,
version: Some(next),
deleted_at: Some(now),
})
}
pub async fn reap_tombstones_through(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
context_id: &str,
namespace: &str,
through: u64,
) -> Result<usize, AppStateError> {
let _guard = locks.acquire(context_id, namespace).await;
write_u64(ks, reaped_key(context_id, namespace), through).await?;
let mut reaped = 0;
for (_ik, key_bytes) in ks
.prefix_iter_raw(index_prefix(context_id, namespace))
.await?
{
let Ok(key) = std::str::from_utf8(&key_bytes) else {
continue;
};
let Some(rec) = read_record(ks, context_id, namespace, key).await? else {
continue;
};
if rec.deleted && rec.version <= through {
ks.remove(index_key(context_id, namespace, rec.version))
.await?;
ks.remove(record_key(context_id, namespace, key)).await?;
reaped += 1;
}
}
Ok(reaped)
}
pub async fn sweep_expired_tombstones(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
audit: &vta_audit::SharedAuditSink,
retention_seconds: u64,
) -> Result<usize, AppStateError> {
let cutoff = Utc::now() - chrono::Duration::seconds(retention_seconds as i64);
let mut by_namespace: HashMap<(String, String), Vec<(u64, Option<String>)>> = HashMap::new();
for (_sk, bytes) in ks.prefix_iter_raw("app:").await? {
let rec: StoredRecord = match serde_json::from_slice(&bytes) {
Ok(r) => r,
Err(e) => {
tracing::debug!(error = %e, "app-state sweeper: skipping unreadable row");
continue;
}
};
if !rec.deleted {
continue;
}
by_namespace
.entry((rec.context_id.clone(), rec.namespace.clone()))
.or_default()
.push((rec.version, rec.deleted_at.clone()));
}
let mut total = 0usize;
for ((context_id, namespace), mut tombstones) in by_namespace {
tombstones.sort_by_key(|(v, _)| *v);
let mut through = 0u64;
for (version, deleted_at) in &tombstones {
let expired = deleted_at
.as_deref()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.is_some_and(|t| t.with_timezone(&Utc) < cutoff);
if !expired {
break;
}
through = *version;
}
if through == 0 {
continue;
}
let reaped = reap_tombstones_through(ks, locks, &context_id, &namespace, through).await?;
if reaped == 0 {
continue;
}
total += reaped;
tracing::info!(
context_id = %context_id,
namespace = %namespace,
reaped,
through,
"app-state sweeper: reaped expired tombstones"
);
if let Err(e) = vta_audit::record(
audit,
"app_state.tombstone.purge",
"system:sweeper",
Some(&format!("{namespace}@{through}")),
"success:retention-expired",
None,
Some(&context_id),
)
.await
{
tracing::warn!(
error = %e,
"app-state sweeper: purge succeeded but audit::record failed"
);
}
}
Ok(total)
}
#[derive(Debug)]
pub struct ListPage {
pub records: Vec<AppStateRecord>,
pub truncated: bool,
pub cursor: Option<String>,
pub high_watermark: Option<u64>,
pub tombstone_retention_seconds: Option<u64>,
}
#[allow(clippy::too_many_arguments)]
pub async fn list(
ks: &KeyspaceHandle,
context_id: &str,
namespace: Option<&str>,
prefix: Option<&str>,
since_version: Option<u64>,
include_values: bool,
include_deleted: Option<bool>,
page_size: Option<usize>,
cursor: Option<&str>,
tombstone_retention_seconds: Option<u64>,
) -> Result<ListPage, AppStateError> {
if let Some(ns) = namespace
&& !namespace_is_valid(ns)
{
return Err(AppStateError::Validation(format!(
"namespace `{ns}` is not a valid partition name"
)));
}
if since_version.is_some() {
if namespace.is_none() {
return Err(AppStateError::FilterConflict(
FilterConflict::SinceVersionRequiresNamespace,
));
}
if include_deleted == Some(false) {
return Err(AppStateError::FilterConflict(
FilterConflict::ChangeFeedCannotExcludeDeleted,
));
}
}
let cursor_key = CursorKey::new(ks.clone()).get().await?;
let binding = format!(
"app-state|{context_id}|{}|{}|{}",
namespace.unwrap_or(""),
prefix.unwrap_or(""),
since_version.map(|v| v.to_string()).unwrap_or_default()
);
let decoded = match cursor {
None => None,
Some(raw) => Some(
Cursor::decode_bound(raw, &cursor_key, binding.as_bytes())
.map_err(|_| AppStateError::from(AppError::InvalidCursor))?,
),
};
let limit = page_size.unwrap_or(50).clamp(1, MAX_LIMIT);
let snapshot = Utc::now().timestamp().max(0) as u64;
let high_watermark = match namespace {
Some(ns) => Some(read_counter(ks, context_id, ns).await?),
None => None,
};
let pairs = match since_version {
Some(since) => {
let ns = namespace.expect("checked above");
let reaped_through = read_reaped_through(ks, context_id, ns).await?;
if since < reaped_through {
return Err(AppStateError::WatermarkTooOld {
oldest_retained_version: reaped_through + 1,
high_watermark: high_watermark.unwrap_or(0),
});
}
let mut out = Vec::new();
for (ik, key_bytes) in ks.prefix_iter_raw(index_prefix(context_id, ns)).await? {
let Some(version) = version_from_index_key(&ik) else {
continue;
};
if version <= since {
continue;
}
let Ok(key) = std::str::from_utf8(&key_bytes) else {
continue;
};
if let Some(p) = prefix
&& !key.starts_with(p)
{
continue;
}
let Some(rec) = read_record(ks, context_id, ns, key).await? else {
continue;
};
out.push((ik, serde_json::to_vec(&rec).map_err(encode_err)?));
}
out
}
None => {
let scan_prefix = match namespace {
Some(ns) => namespace_prefix(context_id, ns),
None => context_prefix(context_id),
};
let want_deleted = include_deleted.unwrap_or(false);
let mut out = Vec::new();
for (sk, bytes) in ks.prefix_iter_raw(scan_prefix).await? {
let rec: StoredRecord = serde_json::from_slice(&bytes).map_err(decode_err)?;
if rec.deleted && !want_deleted {
continue;
}
if let Some(p) = prefix
&& !rec.key.starts_with(p)
{
continue;
}
out.push((sk, bytes));
}
out
}
};
let page = paginate(pairs, decoded.as_ref(), limit, &cursor_key, snapshot, |v| {
let rec: StoredRecord = serde_json::from_slice(v).map_err(decode_err_app)?;
Ok(rec.to_wire(include_values))
})?;
let cursor = page
.next_cursor
.as_deref()
.map(|raw| rebind_cursor(raw, &cursor_key, binding.as_bytes(), snapshot))
.transpose()?;
Ok(ListPage {
truncated: cursor.is_some(),
records: page.items,
cursor,
high_watermark,
tombstone_retention_seconds: since_version
.is_some()
.then_some(tombstone_retention_seconds)
.flatten(),
})
}
fn rebind_cursor(
raw: &str,
cursor_key: &[u8; 32],
binding: &[u8],
snapshot: u64,
) -> Result<String, AppStateError> {
let decoded = Cursor::decode(raw, cursor_key)
.map_err(|e| AppStateError::Internal(format!("re-decode own cursor: {e}")))?;
Ok(Cursor::new(decoded.last_key, snapshot).encode_bound(cursor_key, binding))
}
fn version_from_index_key(index_key: &[u8]) -> Option<u64> {
let text = std::str::from_utf8(index_key).ok()?;
text.rsplit_once(':')?.1.parse().ok()
}
fn encode_err(e: serde_json::Error) -> AppStateError {
AppStateError::Internal(format!("encode app-state record: {e}"))
}
fn decode_err(e: serde_json::Error) -> AppStateError {
AppStateError::Internal(format!("decode app-state record: {e}"))
}
fn decode_err_app(e: serde_json::Error) -> AppError {
AppError::Internal(format!("decode app-state record: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn open() -> (tempfile::TempDir, KeyspaceHandle, NamespaceLocks) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace(crate::keyspaces::APP_STATE).unwrap();
(dir, ks, NamespaceLocks::default())
}
async fn put_value(
ks: &KeyspaceHandle,
locks: &NamespaceLocks,
ctx: &str,
ns: &str,
key: &str,
v: Value,
) -> PutOutcome {
put(ks, locks, ctx, ns, key, Some(&v), None, None)
.await
.expect("put")
}
#[tokio::test]
async fn version_counter_is_shared_across_the_namespace() {
let (_d, ks, locks) = open().await;
let a1 = put_value(&ks, &locks, "ctx", "openvtc", "a", json!(1)).await;
let b1 = put_value(&ks, &locks, "ctx", "openvtc", "b", json!(1)).await;
let a2 = put_value(&ks, &locks, "ctx", "openvtc", "a", json!(2)).await;
assert_eq!(a1.version, 1);
assert_eq!(b1.version, 2);
assert_eq!(
a2.version, 3,
"a's second write takes the namespace's next value, not its own"
);
}
#[tokio::test]
async fn namespaces_have_independent_counters() {
let (_d, ks, locks) = open().await;
let a = put_value(&ks, &locks, "ctx", "openvtc", "k", json!(1)).await;
let b = put_value(&ks, &locks, "ctx", "cnm", "k", json!(1)).await;
assert_eq!(a.version, 1);
assert_eq!(b.version, 1, "a second namespace starts its own counter");
}
#[tokio::test]
async fn expected_version_zero_creates_only_once() {
let (_d, ks, locks) = open().await;
let first = put(
&ks,
&locks,
"ctx",
"openvtc",
"lease",
Some(&json!({"holder": "a"})),
None,
Some(0),
)
.await
.expect("first create-only wins");
assert!(first.created);
let second = put(
&ks,
&locks,
"ctx",
"openvtc",
"lease",
Some(&json!({"holder": "b"})),
None,
Some(0),
)
.await;
match second {
Err(AppStateError::VersionConflict {
reason,
current_version,
current_value,
..
}) => {
assert_eq!(reason, ConflictReason::RecordExists);
assert_eq!(current_version, Some(first.version));
assert_eq!(
current_value,
Some(json!({"holder": "a"})),
"the loser must be handed the winner's value, not just a rejection"
);
}
other => panic!("expected a create-only conflict, got {other:?}"),
}
}
#[tokio::test]
async fn conflict_carries_the_current_version_and_value() {
let (_d, ks, locks) = open().await;
let first = put_value(
&ks,
&locks,
"ctx",
"openvtc",
"k",
json!({"role": "member"}),
)
.await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!({"role": "owner"})).await;
let stale = put(
&ks,
&locks,
"ctx",
"openvtc",
"k",
Some(&json!({"role": "admin"})),
None,
Some(first.version),
)
.await;
match stale {
Err(AppStateError::VersionConflict {
reason,
current_value,
..
}) => {
assert_eq!(reason, ConflictReason::VersionMismatch);
assert_eq!(
current_value,
Some(json!({"role": "owner"})),
"returning the winner's view is what removes the re-read race"
);
}
other => panic!("expected a version conflict, got {other:?}"),
}
}
#[tokio::test]
async fn create_only_succeeds_over_a_tombstone() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!(1)).await;
let del = delete(&ks, &locks, "ctx", "openvtc", "k", None)
.await
.unwrap();
let recreated = put(
&ks,
&locks,
"ctx",
"openvtc",
"k",
Some(&json!(2)),
None,
Some(0),
)
.await
.expect("create-only applies over a tombstone");
assert!(recreated.created);
assert!(recreated.version > del.version.unwrap());
}
#[tokio::test]
async fn merge_patch_edits_one_member_and_null_removes() {
let (_d, ks, locks) = open().await;
put_value(
&ks,
&locks,
"ctx",
"openvtc",
"k",
json!({"label": "Acme", "role": "member", "joinedAt": "2026-07-02"}),
)
.await;
put(
&ks,
&locks,
"ctx",
"openvtc",
"k",
None,
Some(&json!({"label": "Acme EMEA", "role": null})),
None,
)
.await
.expect("patch applies");
let rec = get(&ks, "ctx", "openvtc", "k", false).await.unwrap();
assert_eq!(
rec.value,
Some(json!({"label": "Acme EMEA", "joinedAt": "2026-07-02"})),
"RFC 7386: a null member is removed, untouched members survive"
);
}
#[tokio::test]
async fn merge_patch_on_absent_record_is_not_found() {
let (_d, ks, locks) = open().await;
let err = put(
&ks,
&locks,
"ctx",
"openvtc",
"nope",
None,
Some(&json!({"a": 1})),
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppStateError::NotFound), "{err:?}");
}
#[tokio::test]
async fn merge_patch_replaces_wholesale_when_not_an_object() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!({"a": 1})).await;
put(
&ks,
&locks,
"ctx",
"openvtc",
"k",
None,
Some(&json!("scalar")),
None,
)
.await
.unwrap();
let rec = get(&ks, "ctx", "openvtc", "k", false).await.unwrap();
assert_eq!(rec.value, Some(json!("scalar")));
}
#[tokio::test]
async fn delete_leaves_a_tombstone_the_change_feed_reports() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
let watermark = put_value(&ks, &locks, "ctx", "openvtc", "stays", json!(1))
.await
.version;
delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let feed = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(watermark),
true,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(feed.records.len(), 1);
assert_eq!(feed.records[0].key, "gone");
assert!(
feed.records[0].deleted,
"the deletion must reach the consumer as a tombstone"
);
assert!(feed.records[0].value.is_none());
}
#[tokio::test]
async fn snapshot_hides_tombstones_unless_asked() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "stays", json!(1)).await;
delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let plain = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(plain.records.len(), 1);
assert_eq!(plain.records[0].key, "stays");
let with_tombs = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
Some(true),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(with_tombs.records.len(), 2);
}
#[tokio::test]
async fn repeated_delete_takes_no_new_version() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!(1)).await;
let first = delete(&ks, &locks, "ctx", "openvtc", "k", None)
.await
.unwrap();
assert!(first.existed);
let second = delete(&ks, &locks, "ctx", "openvtc", "k", None)
.await
.unwrap();
assert!(
!second.existed,
"a repeat delete is a success, not an error"
);
assert_eq!(
second.version, first.version,
"a repeat delete must not advance the counter"
);
}
#[tokio::test]
async fn delete_of_never_written_address_writes_no_tombstone() {
let (_d, ks, locks) = open().await;
let out = delete(&ks, &locks, "ctx", "openvtc", "never", None)
.await
.unwrap();
assert!(!out.existed);
assert!(
out.version.is_none(),
"nothing existed to converge, so no tombstone is written"
);
assert_eq!(read_counter(&ks, "ctx", "openvtc").await.unwrap(), 0);
}
#[tokio::test]
async fn delete_with_expected_version_zero_is_refused() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!(1)).await;
let err = delete(&ks, &locks, "ctx", "openvtc", "k", Some(0))
.await
.unwrap_err();
assert!(
matches!(
err,
AppStateError::VersionConflict {
reason: ConflictReason::CreateOnlyNotApplicable,
..
}
),
"{err:?}"
);
}
#[tokio::test]
async fn change_feed_is_in_version_order() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "zebra", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "alpha", json!(1)).await;
let feed = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap();
let keys: Vec<_> = feed.records.iter().map(|r| r.key.as_str()).collect();
assert_eq!(
keys,
vec!["zebra", "alpha"],
"change feed orders by version, not by key"
);
}
#[tokio::test]
async fn high_watermark_is_the_counter_not_the_max_returned_version() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "community/a", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "other/b", json!(1)).await;
let feed = list(
&ks,
"ctx",
Some("openvtc"),
Some("community/"),
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(feed.records.len(), 1);
assert_eq!(feed.records[0].version, 1);
assert_eq!(
feed.high_watermark,
Some(2),
"the watermark is the namespace counter, past the filtered-out change"
);
}
#[tokio::test]
async fn a_gap_in_the_version_sequence_does_not_break_the_change_feed() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "before", json!(1)).await;
write_u64(&ks, counter_key("ctx", "openvtc"), 40)
.await
.unwrap();
let after = put_value(&ks, &locks, "ctx", "openvtc", "after", json!(1)).await;
assert_eq!(after.version, 41, "the next write resumes above the gap");
let feed = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap();
let keys: Vec<_> = feed.records.iter().map(|r| r.key.as_str()).collect();
assert_eq!(keys, vec!["before", "after"], "the gap is simply absent");
assert_eq!(feed.high_watermark, Some(41));
let resumed = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(41),
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(resumed.records.is_empty());
}
#[tokio::test]
async fn change_feed_requires_a_namespace() {
let (_d, ks, _l) = open().await;
let err = list(
&ks,
"ctx",
None,
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap_err();
assert!(
matches!(
err,
AppStateError::FilterConflict(FilterConflict::SinceVersionRequiresNamespace)
),
"{err:?}"
);
}
#[tokio::test]
async fn change_feed_cannot_be_asked_to_exclude_tombstones() {
let (_d, ks, _l) = open().await;
let err = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
Some(false),
None,
None,
None,
)
.await
.unwrap_err();
assert!(
matches!(
err,
AppStateError::FilterConflict(FilterConflict::ChangeFeedCannotExcludeDeleted)
),
"{err:?}"
);
}
fn test_audit_sink(ks: &KeyspaceHandle) -> vta_audit::SharedAuditSink {
vta_audit::shared_keyspace_sink(ks.clone())
}
#[tokio::test]
async fn sweeper_reaps_expired_tombstones_and_leaves_live_records() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "keep", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
let del = delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let reaped = sweep_expired_tombstones(&ks, &locks, &test_audit_sink(&ks), 0)
.await
.unwrap();
assert_eq!(reaped, 1, "only the tombstone is reaped");
assert!(get(&ks, "ctx", "openvtc", "keep", false).await.is_ok());
assert!(
matches!(
get(&ks, "ctx", "openvtc", "gone", true).await,
Err(AppStateError::NotFound)
),
"a reaped tombstone leaves nothing behind"
);
let err = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap_err();
match err {
AppStateError::WatermarkTooOld {
oldest_retained_version,
..
} => assert_eq!(oldest_retained_version, del.version.unwrap() + 1),
other => panic!("expected WatermarkTooOld, got {other:?}"),
}
}
#[tokio::test]
async fn sweeper_leaves_tombstones_inside_the_retention_window() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let reaped = sweep_expired_tombstones(&ks, &locks, &test_audit_sink(&ks), 3600)
.await
.unwrap();
assert_eq!(reaped, 0, "a fresh tombstone is inside the window");
let feed = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(feed.records.iter().any(|r| r.deleted));
}
#[tokio::test]
async fn sweeper_stops_at_the_first_unexpired_tombstone() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "old", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "new", json!(1)).await;
delete(&ks, &locks, "ctx", "openvtc", "old", None)
.await
.unwrap();
let newer = delete(&ks, &locks, "ctx", "openvtc", "new", None)
.await
.unwrap();
let mut rec = read_record(&ks, "ctx", "openvtc", "old")
.await
.unwrap()
.expect("tombstone");
rec.deleted_at = Some((Utc::now() - chrono::Duration::days(90)).to_rfc3339());
ks.insert(record_key("ctx", "openvtc", "old"), &rec)
.await
.unwrap();
let reaped =
sweep_expired_tombstones(&ks, &locks, &test_audit_sink(&ks), 30 * 24 * 60 * 60)
.await
.unwrap();
assert_eq!(reaped, 1, "only the backdated tombstone goes");
let still = read_record(&ks, "ctx", "openvtc", "new").await.unwrap();
assert!(still.is_some_and(|r| r.deleted));
assert!(
list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(newer.version.unwrap() - 1),
false,
None,
None,
None,
None,
)
.await
.is_ok(),
"a watermark above the reap point must still resume"
);
}
#[tokio::test]
async fn change_feed_reports_the_configured_retention_and_omits_it_when_disabled() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!(1)).await;
let configured = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
Some(604_800),
)
.await
.unwrap();
assert_eq!(configured.tombstone_retention_seconds, Some(604_800));
let disabled = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(disabled.tombstone_retention_seconds, None);
let snapshot = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
None,
None,
None,
Some(604_800),
)
.await
.unwrap();
assert_eq!(snapshot.tombstone_retention_seconds, None);
}
#[tokio::test]
async fn sweeper_is_namespace_scoped() {
let (_d, ks, locks) = open().await;
for ns in ["openvtc", "cnm"] {
put_value(&ks, &locks, "ctx", ns, "gone", json!(1)).await;
delete(&ks, &locks, "ctx", ns, "gone", None).await.unwrap();
}
let reaped = sweep_expired_tombstones(&ks, &locks, &test_audit_sink(&ks), 0)
.await
.unwrap();
assert_eq!(reaped, 2, "each namespace is swept on its own counter");
for ns in ["openvtc", "cnm"] {
assert!(read_record(&ks, "ctx", ns, "gone").await.unwrap().is_none());
}
}
#[tokio::test]
async fn reaped_tombstones_make_an_old_watermark_refuse_rather_than_lie() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
let del = delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
put_value(&ks, &locks, "ctx", "openvtc", "later", json!(1)).await;
let reaped = reap_tombstones_through(&ks, &locks, "ctx", "openvtc", del.version.unwrap())
.await
.unwrap();
assert_eq!(reaped, 1);
let err = list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(0),
false,
None,
None,
None,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, AppStateError::WatermarkTooOld { .. }),
"{err:?}"
);
assert!(
list(
&ks,
"ctx",
Some("openvtc"),
None,
Some(del.version.unwrap()),
false,
None,
None,
None,
None,
)
.await
.is_ok()
);
}
#[tokio::test]
async fn namespaces_do_not_bleed_into_each_other() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!("openvtc")).await;
put_value(&ks, &locks, "ctx", "cnm", "k", json!("cnm")).await;
let listed = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
true,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(listed.records.len(), 1);
assert_eq!(listed.records[0].value, Some(json!("openvtc")));
}
#[tokio::test]
async fn contexts_do_not_bleed_into_each_other() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx-a", "openvtc", "k", json!("a")).await;
put_value(&ks, &locks, "ctx-b", "openvtc", "k", json!("b")).await;
let a = list(&ks, "ctx-a", None, None, None, true, None, None, None, None)
.await
.unwrap();
assert_eq!(a.records.len(), 1);
assert_eq!(a.records[0].value, Some(json!("a")));
}
#[tokio::test]
async fn metadata_view_omits_the_value_but_keeps_its_size() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", json!({"a": "bb"})).await;
let listed = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(listed.records[0].value.is_none());
assert_eq!(listed.records[0].value_bytes, Some(10));
}
#[tokio::test]
async fn oversized_value_is_refused_loudly_with_both_numbers() {
let (_d, ks, locks) = open().await;
let big = json!("x".repeat(MAX_VALUE_BYTES as usize + 10));
let err = put(&ks, &locks, "ctx", "openvtc", "k", Some(&big), None, None)
.await
.unwrap_err();
match err {
AppStateError::ValueTooLarge {
limit_bytes,
actual_bytes,
} => {
assert_eq!(limit_bytes, MAX_VALUE_BYTES);
assert!(actual_bytes > limit_bytes);
}
other => panic!("expected ValueTooLarge, got {other:?}"),
}
assert!(
matches!(
get(&ks, "ctx", "openvtc", "k", false).await,
Err(AppStateError::NotFound)
),
"a refused write must leave no record behind"
);
}
#[tokio::test]
async fn a_null_value_is_stored_and_is_not_an_absent_value() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "k", Value::Null).await;
let rec = get(&ks, "ctx", "openvtc", "k", false).await.unwrap();
assert_eq!(rec.value, Some(Value::Null));
assert!(!rec.deleted);
}
#[tokio::test]
async fn invalid_namespace_is_refused() {
let (_d, ks, locks) = open().await;
for bad in ["OpenVTC", "open_vtc", "-lead", "trail-", "a--b", ""] {
let err = put(&ks, &locks, "ctx", bad, "k", Some(&json!(1)), None, None)
.await
.unwrap_err();
assert!(
matches!(err, AppStateError::Validation(_)),
"namespace `{bad}` should be refused, got {err:?}"
);
}
}
#[tokio::test]
async fn independent_batch_applies_the_writes_that_pass() {
let (_d, ks, locks) = open().await;
let a = put_value(&ks, &locks, "ctx", "openvtc", "a", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "b", json!(1)).await;
let writes = vec![
{
let mut w = AppStateWrite::new("a".into());
w.value = Some(json!(2));
w.expected_version = Some(a.version);
w
},
{
let mut w = AppStateWrite::new("b".into());
w.value = Some(json!(2));
w.expected_version = Some(999);
w
},
{
let mut w = AppStateWrite::new("c".into());
w.value = Some(json!(1));
w.expected_version = Some(0);
w
},
];
let (results, high) = put_many(
&ks,
&locks,
"ctx",
"openvtc",
&writes,
PutManyMode::Independent,
)
.await
.expect("an independent batch with a conflict is still a success");
assert_eq!(results[0].outcome, WriteOutcome::Written);
assert_eq!(results[1].outcome, WriteOutcome::Conflict);
assert_eq!(
results[1].current_value,
Some(json!(1)),
"a conflicted write in a batch carries the same current-value payload as a single put"
);
assert_eq!(results[2].outcome, WriteOutcome::Written);
assert!(high >= results[2].version.unwrap());
assert_eq!(
get(&ks, "ctx", "openvtc", "b", false).await.unwrap().value,
Some(json!(1))
);
}
#[tokio::test]
async fn atomic_batch_writes_nothing_when_one_write_fails() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "index", json!({"ids": []})).await;
let counter_before = read_counter(&ks, "ctx", "openvtc").await.unwrap();
let writes = vec![
{
let mut w = AppStateWrite::new("member".into());
w.value = Some(json!({"label": "Cyprus"}));
w.expected_version = Some(0);
w
},
{
let mut w = AppStateWrite::new("index".into());
w.value = Some(json!({"ids": ["cyprus"]}));
w.expected_version = Some(999); w
},
];
let err = put_many(&ks, &locks, "ctx", "openvtc", &writes, PutManyMode::Atomic)
.await
.unwrap_err();
match err {
AppStateError::AtomicBatchRejected(results) => {
assert_eq!(
results[0].outcome,
WriteOutcome::Skipped,
"the write that was never attempted must say so, so a retry \
does not rewrite its create-only precondition"
);
assert_eq!(results[1].outcome, WriteOutcome::Conflict);
}
other => panic!("expected AtomicBatchRejected, got {other:?}"),
}
assert!(
get(&ks, "ctx", "openvtc", "member", false).await.is_err(),
"an atomic batch that did not apply must have written nothing"
);
assert_eq!(
read_counter(&ks, "ctx", "openvtc").await.unwrap(),
counter_before,
"a rejected atomic batch must not consume counter values"
);
}
#[tokio::test]
async fn atomic_batch_applies_when_every_write_passes() {
let (_d, ks, locks) = open().await;
let idx = put_value(&ks, &locks, "ctx", "openvtc", "index", json!({"ids": []})).await;
let writes = vec![
{
let mut w = AppStateWrite::new("member".into());
w.value = Some(json!({"label": "Cyprus"}));
w.expected_version = Some(0);
w
},
{
let mut w = AppStateWrite::new("index".into());
w.value = Some(json!({"ids": ["cyprus"]}));
w.expected_version = Some(idx.version);
w
},
];
let (results, _) = put_many(&ks, &locks, "ctx", "openvtc", &writes, PutManyMode::Atomic)
.await
.unwrap();
assert!(results.iter().all(|r| r.outcome == WriteOutcome::Written));
}
#[tokio::test]
async fn duplicate_keys_in_a_batch_are_refused() {
let (_d, ks, locks) = open().await;
let writes = vec![
{
let mut w = AppStateWrite::new("k".into());
w.value = Some(json!(1));
w
},
{
let mut w = AppStateWrite::new("k".into());
w.value = Some(json!(2));
w
},
];
let err = put_many(
&ks,
&locks,
"ctx",
"openvtc",
&writes,
PutManyMode::Independent,
)
.await
.unwrap_err();
assert!(matches!(err, AppStateError::DuplicateKey(_)), "{err:?}");
}
#[tokio::test]
async fn get_many_accounts_for_every_requested_key() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "a", json!(1)).await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let keys = vec!["a".to_string(), "gone".to_string(), "never".to_string()];
let (records, missing, deferred) =
get_many(&ks, "ctx", "openvtc", &keys, false).await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(missing, vec!["gone", "never"]);
assert!(deferred.is_empty());
let total = records.len() + missing.len() + deferred.len();
assert_eq!(
total,
keys.len(),
"every requested key must be accounted for"
);
}
#[tokio::test]
async fn get_many_defers_past_the_budget_and_still_accounts_for_every_key() {
let (_d, ks, locks) = open().await;
let big = json!("x".repeat(65_000)); let keys: Vec<String> = (0..10).map(|i| format!("k{i}")).collect();
for k in &keys {
put_value(&ks, &locks, "ctx", "openvtc", k, big.clone()).await;
}
let (records, missing, deferred) =
get_many(&ks, "ctx", "openvtc", &keys, false).await.unwrap();
assert!(
!deferred.is_empty(),
"10 x ~64KiB must exceed the {GET_MANY_RESPONSE_BUDGET_BYTES}-byte budget"
);
assert!(!records.is_empty(), "the batch must make forward progress");
assert_eq!(
records.len() + missing.len() + deferred.len(),
keys.len(),
"every requested key must be accounted for exactly once"
);
let returned: Vec<&str> = records.iter().map(|r| r.key.as_str()).collect();
let expected_prefix: Vec<&str> = keys
.iter()
.take(records.len())
.map(String::as_str)
.collect();
assert_eq!(returned, expected_prefix);
let (r2, _m2, d2) = get_many(&ks, "ctx", "openvtc", &deferred, false)
.await
.unwrap();
assert!(
!r2.is_empty(),
"the re-request must return the deferred keys"
);
assert!(d2.len() < deferred.len(), "and must shrink the remainder");
}
#[tokio::test]
async fn get_many_include_deleted_returns_the_tombstone() {
let (_d, ks, locks) = open().await;
put_value(&ks, &locks, "ctx", "openvtc", "gone", json!(1)).await;
delete(&ks, &locks, "ctx", "openvtc", "gone", None)
.await
.unwrap();
let keys = vec!["gone".to_string()];
let (records, missing, _) = get_many(&ks, "ctx", "openvtc", &keys, true).await.unwrap();
assert_eq!(records.len(), 1);
assert!(records[0].deleted);
assert!(missing.is_empty());
}
#[tokio::test]
async fn get_many_refuses_duplicates() {
let (_d, ks, _l) = open().await;
let keys = vec!["a".to_string(), "a".to_string()];
let err = get_many(&ks, "ctx", "openvtc", &keys, false)
.await
.unwrap_err();
assert!(matches!(err, AppStateError::DuplicateKey(_)), "{err:?}");
}
#[tokio::test]
async fn snapshot_paginates_and_the_cursor_resumes() {
let (_d, ks, locks) = open().await;
for i in 0..5 {
put_value(&ks, &locks, "ctx", "openvtc", &format!("k{i}"), json!(i)).await;
}
let first = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
None,
Some(2),
None,
None,
)
.await
.unwrap();
assert_eq!(first.records.len(), 2);
assert!(first.truncated);
let second = list(
&ks,
"ctx",
Some("openvtc"),
None,
None,
false,
None,
Some(2),
first.cursor.as_deref(),
None,
)
.await
.unwrap();
assert_eq!(second.records.len(), 2);
assert_ne!(first.records[0].key, second.records[0].key);
}
#[tokio::test]
async fn a_cursor_cannot_be_replayed_against_a_different_filter() {
let (_d, ks, locks) = open().await;
for i in 0..5 {
put_value(
&ks,
&locks,
"ctx",
"openvtc",
&format!("community/{i}"),
json!(i),
)
.await;
}
let page = list(
&ks,
"ctx",
Some("openvtc"),
Some("community/"),
None,
false,
None,
Some(2),
None,
None,
)
.await
.unwrap();
let err = list(
&ks,
"ctx",
Some("openvtc"),
None, None,
false,
None,
Some(2),
page.cursor.as_deref(),
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppStateError::Validation(_)), "{err:?}");
}
}