use std::sync::LazyLock;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::{RejectReason, TrustTask};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const KEY_PREFIX: &[u8] = b"accepted:";
const FALLBACK_RETENTION_SECS: i64 = 11 * 60;
const STRIPES: usize = 64;
static STRIPE_LOCKS: LazyLock<Vec<tokio::sync::Mutex<()>>> =
LazyLock::new(|| (0..STRIPES).map(|_| tokio::sync::Mutex::new(())).collect());
fn stripe_for(id: &str) -> &'static tokio::sync::Mutex<()> {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in id.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
&STRIPE_LOCKS[(h % STRIPES as u64) as usize]
}
fn key(id: &str) -> Vec<u8> {
let mut k = KEY_PREFIX.to_vec();
k.extend_from_slice(id.as_bytes());
k
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Record {
digest: String,
retain_until: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
response: Option<Value>,
completed: bool,
}
impl Record {
fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
self.retain_until <= now
}
}
#[derive(Clone)]
pub(crate) struct AcceptedIds {
ks: KeyspaceHandle,
}
pub(crate) enum Acceptance {
Fresh(AcceptedClaim),
Duplicate {
prior_response: Option<Value>,
in_flight: bool,
},
Conflict,
}
#[derive(Debug)]
pub(crate) enum AcceptedIdsError {
NotDigestible(String),
Unavailable,
}
impl AcceptedIdsError {
pub(crate) fn reject_reason(&self) -> RejectReason {
match self {
Self::NotDigestible(reason) => RejectReason::InternalError {
reason: format!(
"cannot canonicalise the document to key its replay record: {reason}"
),
},
Self::Unavailable => RejectReason::Unavailable { retry_after: None },
}
}
}
impl AcceptedIds {
pub(crate) fn new(ks: KeyspaceHandle) -> Self {
Self { ks }
}
pub(crate) async fn claim(
&self,
doc: &TrustTask<Value>,
retain_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Result<Acceptance, AcceptedIdsError> {
let digest = trust_tasks_rs::document_digest(doc)
.map_err(|e| AcceptedIdsError::NotDigestible(e.to_string()))?;
let id = doc.id.clone();
let retain_until =
retain_until.unwrap_or_else(|| now + TimeDelta::seconds(FALLBACK_RETENTION_SECS));
self.claim_inner(&id, digest.as_str(), retain_until, now)
.await
.map_err(|e| {
tracing::error!(error = %e, %id, "accepted-id record unavailable");
AcceptedIdsError::Unavailable
})
}
async fn claim_inner(
&self,
id: &str,
digest: &str,
retain_until: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<Acceptance, AppError> {
let _guard = stripe_for(id).lock().await;
let k = key(id);
if let Some(existing) = self.ks.get::<Record>(k.clone()).await? {
if !existing.is_expired_at(now) {
return Ok(if existing.digest == digest {
Acceptance::Duplicate {
prior_response: existing.response,
in_flight: !existing.completed,
}
} else {
Acceptance::Conflict
});
}
self.ks.remove(k.clone()).await?;
}
let record = Record {
digest: digest.to_string(),
retain_until,
response: None,
completed: false,
};
if !self.ks.insert_if_absent(k, &record).await? {
return Err(AppError::Internal(format!(
"accepted-id row for {id} appeared while its claim was held"
)));
}
Ok(Acceptance::Fresh(AcceptedClaim {
ids: self.clone(),
id: id.to_string(),
digest: digest.to_string(),
settled: false,
}))
}
async fn complete(
&self,
id: &str,
digest: &str,
response: Option<&Value>,
) -> Result<(), AppError> {
let _guard = stripe_for(id).lock().await;
let k = key(id);
let Some(mut record) = self.ks.get::<Record>(k.clone()).await? else {
return Ok(());
};
if record.digest != digest {
return Ok(());
}
record.response = response.cloned();
record.completed = true;
self.ks.insert(k, &record).await
}
async fn release(&self, id: &str, digest: &str) -> Result<(), AppError> {
let _guard = stripe_for(id).lock().await;
let k = key(id);
let Some(record) = self.ks.get::<Record>(k.clone()).await? else {
return Ok(());
};
if record.digest == digest && !record.completed {
self.ks.remove(k).await?;
}
Ok(())
}
}
#[must_use = "a claim that is neither completed nor released burns its document id until the \
record expires"]
pub(crate) struct AcceptedClaim {
ids: AcceptedIds,
id: String,
digest: String,
settled: bool,
}
impl AcceptedClaim {
pub(crate) async fn completed(mut self, response: Option<&Value>) {
self.settled = true;
if let Err(e) = self.ids.complete(&self.id, &self.digest, response).await {
tracing::warn!(error = %e, id = %self.id, "accepted-id record: response not recorded");
}
}
pub(crate) async fn release(mut self) {
self.settled = true;
if let Err(e) = self.ids.release(&self.id, &self.digest).await {
tracing::warn!(error = %e, id = %self.id, "accepted-id record: claim not released");
}
}
}
impl Drop for AcceptedClaim {
fn drop(&mut self) {
if !self.settled {
tracing::warn!(
id = %self.id,
"accepted-id claim dropped without being completed or released; the document id \
stays spent until its record expires"
);
}
}
}
pub(crate) async fn sweep_expired(
ks: &KeyspaceHandle,
now: DateTime<Utc>,
) -> Result<usize, AppError> {
let rows = ks.prefix_iter_raw(KEY_PREFIX.to_vec()).await?;
let mut purged = 0usize;
let mut unreadable = 0usize;
for (k, v) in rows {
match serde_json::from_slice::<Record>(&v) {
Ok(record) if record.is_expired_at(now) => {
ks.remove(k).await?;
purged += 1;
}
Ok(_) => {}
Err(_) => unreadable += 1,
}
}
if unreadable > 0 {
tracing::warn!(
unreadable,
"accepted-id sweep: rows this build cannot parse were left in place"
);
}
Ok(purged)
}
#[cfg(test)]
mod tests {
use super::*;
use trust_tasks_rs::TypeUri;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn ids() -> (AcceptedIds, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store
.keyspace(crate::store::keyspaces::ACCEPTED_IDS)
.unwrap();
(AcceptedIds::new(ks.clone()), ks, dir)
}
fn doc(id: &str, payload: Value) -> TrustTask<Value> {
TrustTask::new(
id,
TypeUri::canonical("vtc/join-requests/status", 0, 1).unwrap(),
payload,
)
}
#[test]
fn fallback_retention_matches_the_acceptance_window() {
let policy = crate::trust_tasks::freshness_policy();
let window = policy.max_age.expect("the window is set") + policy.skew;
assert_eq!(
TimeDelta::seconds(FALLBACK_RETENTION_SECS),
window,
"FALLBACK_RETENTION_SECS must track freshness_policy()'s window"
);
}
#[tokio::test]
async fn vti_ops_025_a_replayed_id_is_a_duplicate_carrying_the_recorded_outcome() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({ "requestId": "r" }));
let until = Some(now + TimeDelta::minutes(11));
let Ok(Acceptance::Fresh(claim)) = ids.claim(&d, until, now).await else {
panic!("the first claim is fresh");
};
claim
.completed(Some(&serde_json::json!({ "status": "pending" })))
.await;
match ids.claim(&d, until, now).await {
Ok(Acceptance::Duplicate {
prior_response,
in_flight,
}) => {
assert!(!in_flight, "the first execution finished");
assert_eq!(
prior_response,
Some(serde_json::json!({ "status": "pending" })),
"a redelivery is answered with the result of the original"
);
}
_ => panic!("a replayed id must not be claimable a second time"),
}
}
#[tokio::test]
async fn vti_ops_025_a_duplicate_of_an_unfinished_execution_is_in_flight() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let until = Some(now + TimeDelta::minutes(11));
let Ok(Acceptance::Fresh(claim)) = ids.claim(&d, until, now).await else {
panic!("fresh");
};
match ids.claim(&d, until, now).await {
Ok(Acceptance::Duplicate {
prior_response,
in_flight,
}) => {
assert!(in_flight);
assert!(prior_response.is_none());
}
_ => panic!("expected an in-flight duplicate"),
}
claim.release().await;
}
#[tokio::test]
async fn vti_ops_025_a_different_document_under_a_spent_id_is_a_conflict() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let until = Some(now + TimeDelta::minutes(11));
let Ok(Acceptance::Fresh(claim)) = ids
.claim(&doc("req-1", serde_json::json!({ "a": 1 })), until, now)
.await
else {
panic!("fresh");
};
claim.completed(None).await;
let other = doc("req-1", serde_json::json!({ "a": 2 }));
assert!(
matches!(
ids.claim(&other, until, now).await,
Ok(Acceptance::Conflict)
),
"a different document under a spent id is a conflict"
);
}
#[tokio::test]
async fn a_released_claim_frees_the_id() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let until = Some(now + TimeDelta::minutes(11));
let Ok(Acceptance::Fresh(claim)) = ids.claim(&d, until, now).await else {
panic!("fresh");
};
claim.release().await;
assert!(
matches!(ids.claim(&d, until, now).await, Ok(Acceptance::Fresh(_))),
"a released id is claimable again"
);
}
#[tokio::test]
async fn vti_ops_026_an_expired_record_is_treated_as_absent() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let Ok(Acceptance::Fresh(claim)) =
ids.claim(&d, Some(now + TimeDelta::minutes(11)), now).await
else {
panic!("fresh");
};
claim.completed(None).await;
let later = now + TimeDelta::minutes(12);
assert!(
matches!(
ids.claim(&d, Some(later + TimeDelta::minutes(11)), later)
.await,
Ok(Acceptance::Fresh(_))
),
"past its retention deadline the record no longer refuses anything"
);
}
#[tokio::test]
async fn vti_ops_026_the_sweep_purges_expired_records_and_keeps_live_ones() {
let (ids, ks, _dir) = ids().await;
let now = Utc::now();
let stale = doc("stale", serde_json::json!({}));
let live = doc("live", serde_json::json!({}));
for (d, until) in [
(&stale, now + TimeDelta::minutes(1)),
(&live, now + TimeDelta::minutes(11)),
] {
let Ok(Acceptance::Fresh(claim)) = ids.claim(d, Some(until), now).await else {
panic!("fresh");
};
claim.completed(None).await;
}
let purged = sweep_expired(&ks, now + TimeDelta::minutes(5))
.await
.unwrap();
assert_eq!(purged, 1, "only the expired record is purged");
let remaining = ks.prefix_iter_raw(KEY_PREFIX.to_vec()).await.unwrap();
assert_eq!(remaining.len(), 1, "the live record survives the sweep");
assert_eq!(remaining[0].0, key("live"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn vti_ops_025_concurrent_presentations_of_one_id_yield_one_claim() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let until = Some(now + TimeDelta::minutes(11));
let mut set = tokio::task::JoinSet::new();
for _ in 0..8 {
let ids = ids.clone();
let d = d.clone();
set.spawn(async move {
matches!(ids.claim(&d, until, now).await, Ok(Acceptance::Fresh(_)))
});
}
let fresh = set.join_all().await.into_iter().filter(|won| *won).count();
assert_eq!(fresh, 1, "exactly one concurrent claim may execute");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn vti_ops_025_concurrent_claims_over_an_expired_record_yield_one_claim() {
let (ids, _ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let Ok(Acceptance::Fresh(claim)) =
ids.claim(&d, Some(now + TimeDelta::minutes(1)), now).await
else {
panic!("fresh");
};
claim.completed(None).await;
let later = now + TimeDelta::minutes(5);
let until = Some(later + TimeDelta::minutes(11));
let mut set = tokio::task::JoinSet::new();
for _ in 0..8 {
let ids = ids.clone();
let d = d.clone();
set.spawn(async move {
matches!(ids.claim(&d, until, later).await, Ok(Acceptance::Fresh(_)))
});
}
let fresh = set.join_all().await.into_iter().filter(|won| *won).count();
assert_eq!(fresh, 1, "exactly one claim may replace an expired record");
}
#[tokio::test]
async fn vti_ops_027_a_second_binding_sees_what_the_first_accepted() {
let (dispatcher, ks, _dir) = ids().await;
let rest_route = AcceptedIds::new(ks);
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let until = Some(now + TimeDelta::minutes(11));
let Ok(Acceptance::Fresh(claim)) = dispatcher.claim(&d, until, now).await else {
panic!("fresh");
};
claim
.completed(Some(&serde_json::json!({ "ok": true })))
.await;
match rest_route.claim(&d, until, now).await {
Ok(Acceptance::Duplicate { prior_response, .. }) => assert_eq!(
prior_response,
Some(serde_json::json!({ "ok": true })),
"the second binding answers with the first binding's result"
),
_ => panic!("a document accepted on one binding must not be acceptable on another"),
}
}
#[tokio::test]
async fn an_unbounded_claim_is_bounded_here() {
let (ids, ks, _dir) = ids().await;
let now = Utc::now();
let d = doc("req-1", serde_json::json!({}));
let Ok(Acceptance::Fresh(claim)) = ids.claim(&d, None, now).await else {
panic!("fresh");
};
claim.completed(None).await;
let stored: Record = ks.get(key("req-1")).await.unwrap().expect("a row");
assert_eq!(
stored.retain_until,
now + TimeDelta::seconds(FALLBACK_RETENTION_SECS)
);
}
}