use std::time::Duration;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RetentionPolicy {
idle_contexts_after: Option<Duration>,
idle_user_state_after: Option<Duration>,
}
impl RetentionPolicy {
pub const fn keep_everything() -> Self {
Self {
idle_contexts_after: None,
idle_user_state_after: None,
}
}
#[must_use]
pub const fn delete_contexts_idle_for(mut self, idle: Duration) -> Self {
self.idle_contexts_after = Some(idle);
self
}
#[must_use]
pub const fn delete_user_state_idle_for(mut self, idle: Duration) -> Self {
self.idle_user_state_after = Some(idle);
self
}
pub const fn idle_contexts_after(&self) -> Option<Duration> {
self.idle_contexts_after
}
pub const fn idle_user_state_after(&self) -> Option<Duration> {
self.idle_user_state_after
}
pub fn context_cutoff(&self, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
cutoff(self.idle_contexts_after, now)
}
pub fn user_state_cutoff(&self, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
cutoff(self.idle_user_state_after, now)
}
pub const fn is_noop(&self) -> bool {
self.idle_contexts_after.is_none() && self.idle_user_state_after.is_none()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReadRefresh {
after: Option<Duration>,
}
impl ReadRefresh {
pub const fn never() -> Self {
Self { after: None }
}
#[must_use]
pub const fn after(after: Duration) -> Self {
Self { after: Some(after) }
}
pub fn halfway_through(policy: &RetentionPolicy) -> Self {
Self {
after: policy.idle_user_state_after().map(|window| window / 2),
}
}
pub const fn after_window(&self) -> Option<Duration> {
self.after
}
pub fn cutoff(&self, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
cutoff(self.after, now)
}
pub fn due(&self, last_write: DateTime<Utc>, now: DateTime<Utc>) -> bool {
self.cutoff(now).is_some_and(|cutoff| last_write <= cutoff)
}
}
fn cutoff(window: Option<Duration>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
let window = chrono::TimeDelta::from_std(window?).ok()?;
now.checked_sub_signed(window)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Swept {
pub contexts: u64,
pub tasks: u64,
pub messages: u64,
pub digests: u64,
pub state_keys: u64,
}
impl Swept {
pub const fn is_empty(&self) -> bool {
self.contexts == 0
&& self.tasks == 0
&& self.messages == 0
&& self.digests == 0
&& self.state_keys == 0
}
}
impl std::ops::AddAssign for Swept {
fn add_assign(&mut self, other: Self) {
self.contexts += other.contexts;
self.tasks += other.tasks;
self.messages += other.messages;
self.digests += other.digests;
self.state_keys += other.state_keys;
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
#[test]
fn the_default_policy_deletes_nothing() {
let policy = RetentionPolicy::default();
assert_eq!(policy, RetentionPolicy::keep_everything());
assert!(policy.is_noop());
assert_eq!(policy.idle_contexts_after(), None);
assert_eq!(policy.idle_user_state_after(), None);
}
#[test]
fn each_knob_is_set_independently() {
let week = Duration::from_secs(7 * 24 * 60 * 60);
let policy = RetentionPolicy::keep_everything().delete_contexts_idle_for(week);
assert!(!policy.is_noop());
assert_eq!(policy.idle_contexts_after(), Some(week));
assert_eq!(policy.idle_user_state_after(), None);
}
#[test]
fn a_sweep_that_deleted_one_row_is_not_empty() {
assert!(Swept::default().is_empty());
assert!(
!Swept {
state_keys: 1,
..Default::default()
}
.is_empty()
);
}
#[test]
fn the_cutoff_is_the_window_before_now() {
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let day = Duration::from_secs(24 * 60 * 60);
let policy = RetentionPolicy::keep_everything().delete_contexts_idle_for(day);
assert_eq!(
policy.context_cutoff(now),
DateTime::<Utc>::from_timestamp(1_700_000_000 - 86_400, 0)
);
assert_eq!(policy.user_state_cutoff(now), None);
}
#[test]
fn a_read_refreshes_nothing_by_default() {
let refresh = ReadRefresh::default();
assert_eq!(refresh, ReadRefresh::never());
assert_eq!(refresh.after_window(), None);
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let ancient = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
assert_eq!(refresh.cutoff(now), None);
assert!(
!refresh.due(ancient, now),
"nothing is old enough when nothing is refreshed"
);
}
#[test]
fn a_bag_is_due_only_once_it_is_older_than_the_window() {
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let day = Duration::from_secs(24 * 60 * 60);
let refresh = ReadRefresh::after(day);
let cutoff = refresh.cutoff(now).unwrap();
assert_eq!(
cutoff,
DateTime::<Utc>::from_timestamp(1_700_000_000 - 86_400, 0).unwrap()
);
assert!(refresh.due(cutoff - TimeDelta::seconds(1), now));
assert!(refresh.due(cutoff, now), "the boundary refreshes");
assert!(
!refresh.due(cutoff + TimeDelta::seconds(1), now),
"a bag written since the cutoff is still fresh"
);
}
#[test]
fn the_refresh_window_is_half_the_policys() {
let week = Duration::from_secs(7 * 24 * 60 * 60);
let policy = RetentionPolicy::keep_everything().delete_user_state_idle_for(week);
assert_eq!(
ReadRefresh::halfway_through(&policy).after_window(),
Some(week / 2)
);
assert_eq!(
ReadRefresh::halfway_through(&RetentionPolicy::keep_everything()),
ReadRefresh::never()
);
let contexts_only = RetentionPolicy::keep_everything().delete_contexts_idle_for(week);
assert_eq!(
ReadRefresh::halfway_through(&contexts_only),
ReadRefresh::never()
);
}
#[test]
fn an_unrepresentable_refresh_window_refreshes_nothing() {
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
assert_eq!(ReadRefresh::after(Duration::MAX).cutoff(now), None);
}
#[test]
fn an_unrepresentable_window_keeps_everything() {
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let policy = RetentionPolicy::keep_everything().delete_contexts_idle_for(Duration::MAX);
assert_eq!(policy.context_cutoff(now), None);
}
}