use crate::error::{BoxError, BoxFuture};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::Instant;
pub trait DedupStore: Send + Sync + std::fmt::Debug + 'static {
fn claim<'a>(
&'a self,
key: &'a str,
) -> BoxFuture<'a, std::result::Result<Claim, Box<dyn std::error::Error + Send + Sync>>>;
fn accept<'a>(
&'a self,
key: &'a str,
token: &'a ClaimToken,
) -> BoxFuture<'a, std::result::Result<(), Box<dyn std::error::Error + Send + Sync>>>;
fn abandon<'a>(
&'a self,
key: &'a str,
token: &'a ClaimToken,
) -> BoxFuture<'a, std::result::Result<(), Box<dyn std::error::Error + Send + Sync>>>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Claim {
Claimed(ClaimToken),
Duplicate,
InFlight,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ClaimToken(String);
impl ClaimToken {
pub fn new(lease: impl Into<String>) -> ClaimToken {
ClaimToken(lease.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Default)]
pub struct MemoryDedup {
entries: Mutex<HashMap<String, Entry>>,
}
#[derive(Debug)]
enum Entry {
Held { token: ClaimToken, until: Instant },
Accepted { until: Instant },
}
const LEASE: Duration = Duration::from_secs(5 * 60);
const RETENTION: Duration = MIN_RETENTION;
pub(crate) const MIN_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
impl MemoryDedup {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
fn claim_now(&self, key: &str) -> std::result::Result<Claim, BoxError> {
let now = Instant::now();
let mut entries = self.lock();
entries.retain(|_, entry| match entry {
Entry::Held { until, .. } | Entry::Accepted { until } => *until > now,
});
match entries.get(key) {
Some(Entry::Accepted { .. }) => Ok(Claim::Duplicate),
Some(Entry::Held { .. }) => Ok(Claim::InFlight),
None => {
let token = ClaimToken(fresh_token()?);
entries.insert(
key.to_string(),
Entry::Held {
token: token.clone(),
until: now + LEASE,
},
);
Ok(Claim::Claimed(token))
}
}
}
fn settle(
&self,
key: &str,
token: &ClaimToken,
accept: bool,
) -> std::result::Result<(), BoxError> {
let now = Instant::now();
let mut entries = self.lock();
match entries.get(key) {
Some(Entry::Held { token: held, until }) if held == token && *until > now => {
if accept {
entries.insert(
key.to_string(),
Entry::Accepted {
until: now + RETENTION,
},
);
} else {
entries.remove(key);
}
Ok(())
}
_ => Err("the claim is not held under this token (lease expired or settled)".into()),
}
}
}
fn fresh_token() -> std::result::Result<String, BoxError> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|err| format!("no randomness for a claim token: {err}"))?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
impl DedupStore for MemoryDedup {
fn claim<'a>(&'a self, key: &'a str) -> BoxFuture<'a, std::result::Result<Claim, BoxError>> {
Box::pin(std::future::ready(self.claim_now(key)))
}
fn accept<'a>(
&'a self,
key: &'a str,
token: &'a ClaimToken,
) -> BoxFuture<'a, std::result::Result<(), BoxError>> {
Box::pin(std::future::ready(self.settle(key, token, true)))
}
fn abandon<'a>(
&'a self,
key: &'a str,
token: &'a ClaimToken,
) -> BoxFuture<'a, std::result::Result<(), BoxError>> {
Box::pin(std::future::ready(self.settle(key, token, false)))
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
pub(crate) enum DedupProtocol {
As4,
As2,
}
#[cfg_attr(not(any(feature = "as2", feature = "as4")), allow(dead_code))]
pub(crate) fn dedup_key(
protocol: DedupProtocol,
sender_type: &str,
sender_id: &str,
message_id: &str,
) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"asx-dedup-v1");
for part in [
match protocol {
DedupProtocol::As4 => "as4",
DedupProtocol::As2 => "as2",
},
sender_type,
sender_id,
message_id,
] {
hasher.update([0u8]);
hasher.update(part.as_bytes());
}
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[doc(hidden)]
pub fn __dedup_key(protocol: &str, sender_type: &str, sender_id: &str, message_id: &str) -> String {
let protocol = if protocol == "as2" {
DedupProtocol::As2
} else {
DedupProtocol::As4
};
dedup_key(protocol, sender_type, sender_id, message_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_vector_is_pinned() {
let key = dedup_key(
DedupProtocol::As4,
"urn:oasis:names:tc:ebcore:partyid-type:unregistered:BDEW",
"9900000000002",
"abc@example.org",
);
assert_eq!(key.len(), 64);
assert!(
key.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_eq!(
key,
"761dc421ef8588b1802121c3032ce539af1a6c8c3cfb1e21c6fdf1674f1398dd"
);
}
#[test]
fn senders_do_not_collide() {
let a = dedup_key(DedupProtocol::As4, "t", "sender-a", "m@x");
let b = dedup_key(DedupProtocol::As4, "t", "sender-b", "m@x");
let c = dedup_key(DedupProtocol::As2, "t", "sender-a", "m@x");
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(
dedup_key(DedupProtocol::As4, "ab", "c", "m"),
dedup_key(DedupProtocol::As4, "a", "bc", "m")
);
}
#[tokio::test(start_paused = true)]
async fn stale_holder_cannot_settle() {
let store = MemoryDedup::default();
let Claim::Claimed(first) = store.claim("k").await.unwrap() else {
panic!("first claim")
};
assert_eq!(store.claim("k").await.unwrap(), Claim::InFlight);
tokio::time::advance(LEASE + Duration::from_secs(1)).await;
let Claim::Claimed(second) = store.claim("k").await.unwrap() else {
panic!("re-claim after lease expiry")
};
assert_ne!(first, second);
assert!(store.accept("k", &first).await.is_err());
assert!(store.abandon("k", &first).await.is_err());
store.accept("k", &second).await.unwrap();
assert_eq!(store.claim("k").await.unwrap(), Claim::Duplicate);
}
#[tokio::test]
async fn abandon_releases() {
let store = MemoryDedup::default();
let Claim::Claimed(token) = store.claim("k").await.unwrap() else {
panic!()
};
store.abandon("k", &token).await.unwrap();
assert!(matches!(store.claim("k").await.unwrap(), Claim::Claimed(_)));
}
}