use std::time::Duration;
use crate::durability::settle::append_settled;
use crate::durability::{DurabilityError, ScanCursor, StoredEntry};
use super::codec::DedupRecord;
use super::{DedupCache, DedupEntry};
const CLOCK_SKEW_GRACE_MILLIS: u64 = 1_000;
const SWEEP_SCAN_PAGE_LIMIT: usize = 1024;
const KEY_HASH_DIGITS: &[u8; 16] = b"0123456789abcdef";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DedupSweepReport {
scanned: usize,
expired: usize,
retained: usize,
}
impl DedupSweepReport {
#[must_use]
pub const fn scanned_entries(self) -> usize {
self.scanned
}
#[must_use]
pub const fn expired_entries(self) -> usize {
self.expired
}
#[must_use]
pub const fn retained_entries(self) -> usize {
self.retained
}
}
#[derive(Clone, Debug)]
pub struct DedupSweeper {
cache: DedupCache,
ttl: Duration,
sweep_interval: Duration,
}
impl DedupSweeper {
#[must_use]
pub const fn new(cache: DedupCache, ttl: Duration, sweep_interval: Duration) -> Self {
Self {
cache,
ttl,
sweep_interval,
}
}
#[must_use]
pub const fn ttl(&self) -> Duration {
self.ttl
}
#[must_use]
pub const fn sweep_interval(&self) -> Duration {
self.sweep_interval
}
pub async fn sweep_once(&self, now_millis: u64) -> Result<DedupSweepReport, DurabilityError> {
let ttl_millis = duration_millis_saturating(self.ttl);
let mut expired_entries = 0;
let mut retained_entries = 0;
let mut walk = self.cache.latest_records();
while let Some(batch) = walk.next_batch().await? {
for candidate in batch {
let DedupRecord::Active(entry) = candidate.record else {
continue;
};
if entry.evidence().is_none()
&& is_expired(entry.timestamp_millis(), now_millis, ttl_millis)
{
self.tombstone(&entry, candidate.sequence).await?;
expired_entries += 1;
} else {
retained_entries += 1;
}
}
}
Ok(DedupSweepReport {
scanned: walk.scanned(),
expired: expired_entries,
retained: retained_entries,
})
}
async fn tombstone(&self, entry: &DedupEntry, sequence: u64) -> Result<(), DurabilityError> {
let stream_key = self.cache.stream_key_for(entry.idempotency_key());
let expected_seq = sequence.checked_add(1).ok_or_else(|| {
DurabilityError::ConfigError("dedup sweep sequence overflow".to_owned())
})?;
let tombstone =
DedupRecord::tombstone(entry.idempotency_key().to_owned(), entry.timestamp_millis());
append_settled(
self.cache.store.as_ref(),
&stream_key,
tombstone.serialize()?,
expected_seq,
)
.await?;
if entry.receipt().is_none() {
self.cache
.leave_in_flight(entry.idempotency_key(), sequence)
.await;
}
Ok(())
}
}
impl DedupCache {
pub(super) const fn latest_records(&self) -> LatestRecords<'_> {
LatestRecords {
cache: self,
digit: 0,
cursor: None,
pending: None,
scanned: 0,
}
}
}
pub(super) struct SweepCandidate {
pub(super) record: DedupRecord,
pub(super) sequence: u64,
}
pub(super) struct LatestRecords<'cache> {
cache: &'cache DedupCache,
digit: usize,
cursor: Option<ScanCursor>,
pending: Option<SweepCandidate>,
scanned: usize,
}
impl LatestRecords<'_> {
pub(super) const fn scanned(&self) -> usize {
self.scanned
}
pub(super) async fn next_batch(
&mut self,
) -> Result<Option<Vec<SweepCandidate>>, DurabilityError> {
let Some(digit) = KEY_HASH_DIGITS.get(self.digit) else {
return Ok(None);
};
let prefix = format!("{}{}", self.cache.scan_prefix(), char::from(*digit));
let page = self
.cache
.store
.scan_page(&prefix, self.cursor.take(), SWEEP_SCAN_PAGE_LIMIT)
.await?;
self.scanned = self
.scanned
.checked_add(page.entries.len())
.ok_or_else(|| {
DurabilityError::ConfigError("dedup sweep scan counter overflow".to_owned())
})?;
let mut finished = fold_finished_keys(page.entries, &mut self.pending)?;
if let Some(next) = page.next {
self.cursor = Some(next);
} else {
finished.extend(self.pending.take());
self.digit += 1;
}
Ok(Some(finished))
}
}
fn fold_finished_keys(
entries: Vec<StoredEntry>,
pending: &mut Option<SweepCandidate>,
) -> Result<Vec<SweepCandidate>, DurabilityError> {
let mut finished = Vec::new();
for stored in entries {
let record = DedupRecord::deserialize(&stored.payload)?;
let candidate = SweepCandidate {
record,
sequence: stored.sequence,
};
match pending.take() {
Some(previous)
if previous.record.idempotency_key() == candidate.record.idempotency_key() =>
{
*pending = Some(candidate);
}
Some(previous) => {
finished.push(previous);
*pending = Some(candidate);
}
None => *pending = Some(candidate),
}
}
Ok(finished)
}
const fn is_expired(timestamp_millis: u64, now_millis: u64, ttl_millis: u64) -> bool {
let expiry = timestamp_millis
.saturating_add(ttl_millis)
.saturating_add(CLOCK_SKEW_GRACE_MILLIS);
now_millis > expiry
}
fn duration_millis_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}