use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sqlx::{Pool, Sqlite};
use crate::error::{Error, Result};
use crate::moderation::decay::calculate_strike_state;
use crate::moderation::policy::StrikePolicy;
use crate::server::strike_state::load_action_history;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectStrikeStateCache {
pub subject_did: String,
pub current_strike_count: u32,
pub last_action_at: Option<SystemTime>,
pub last_recompute_at: SystemTime,
}
pub fn cache_is_fresh(
cache: &SubjectStrikeStateCache,
freshness_window: Duration,
now: SystemTime,
) -> bool {
match now.duration_since(cache.last_recompute_at) {
Ok(elapsed) => elapsed < freshness_window,
Err(_) => false,
}
}
pub async fn load_cache(
pool: &Pool<Sqlite>,
subject_did: &str,
) -> Result<Option<SubjectStrikeStateCache>> {
let row = sqlx::query!(
"SELECT subject_did, current_strike_count, last_action_at, last_recompute_at
FROM subject_strike_state WHERE subject_did = ?1",
subject_did,
)
.fetch_optional(pool)
.await?;
let Some(r) = row else {
return Ok(None);
};
let current_strike_count = u32::try_from(r.current_strike_count).map_err(|_| {
Error::Signing(format!(
"subject_strike_state.current_strike_count {} out of u32 range",
r.current_strike_count
))
})?;
Ok(Some(SubjectStrikeStateCache {
subject_did: r.subject_did,
current_strike_count,
last_action_at: r.last_action_at.map(epoch_ms_to_systemtime),
last_recompute_at: epoch_ms_to_systemtime(r.last_recompute_at),
}))
}
pub async fn update_cache(
pool: &Pool<Sqlite>,
subject_did: &str,
current_count: u32,
now: SystemTime,
) -> Result<()> {
let now_ms = systemtime_to_epoch_ms(now);
let count_i64 = current_count as i64;
sqlx::query!(
"INSERT INTO subject_strike_state (subject_did, current_strike_count, last_action_at, last_recompute_at)
VALUES (?1, ?2, NULL, ?3)
ON CONFLICT(subject_did) DO UPDATE SET
current_strike_count = excluded.current_strike_count,
last_recompute_at = excluded.last_recompute_at",
subject_did,
count_i64,
now_ms,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_or_recompute_strike_count(
pool: &Pool<Sqlite>,
subject_did: &str,
policy: &StrikePolicy,
now: SystemTime,
) -> Result<u32> {
let Some(cache) = load_cache(pool, subject_did).await? else {
return Err(Error::StrikeCacheMissing(subject_did.to_string()));
};
let freshness_window = Duration::from_secs(policy.cache_freshness_window_seconds as u64);
if cache_is_fresh(&cache, freshness_window, now) {
return Ok(cache.current_strike_count);
}
let history = load_action_history(pool, subject_did).await?;
let state = calculate_strike_state(&history, policy, now);
let new_count = state.current_count;
if let Err(e) = update_cache(pool, subject_did, new_count, now).await {
tracing::warn!(
subject = %subject_did,
error = %e,
"strike-state cache update failed; returning recomputed count anyway",
);
}
Ok(new_count)
}
fn epoch_ms_to_systemtime(ms: i64) -> SystemTime {
if ms >= 0 {
UNIX_EPOCH + Duration::from_millis(ms as u64)
} else {
UNIX_EPOCH
}
}
fn systemtime_to_epoch_ms(t: SystemTime) -> i64 {
t.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis()
.try_into()
.unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
fn t0() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(2_000_000_000)
}
fn cache_at(last_recompute: SystemTime) -> SubjectStrikeStateCache {
SubjectStrikeStateCache {
subject_did: "did:plc:test".to_string(),
current_strike_count: 0,
last_action_at: None,
last_recompute_at: last_recompute,
}
}
#[test]
fn fresh_within_window() {
let now = t0();
let c = cache_at(now - Duration::from_secs(1800));
assert!(cache_is_fresh(&c, Duration::from_secs(3600), now));
}
#[test]
fn stale_past_window() {
let now = t0();
let c = cache_at(now - Duration::from_secs(7200));
assert!(!cache_is_fresh(&c, Duration::from_secs(3600), now));
}
#[test]
fn exact_boundary_is_stale_strict_less_than() {
let now = t0();
let c = cache_at(now - Duration::from_secs(3600));
assert!(!cache_is_fresh(&c, Duration::from_secs(3600), now));
}
#[test]
fn last_recompute_in_future_is_stale() {
let now = t0();
let c = cache_at(now + Duration::from_secs(60));
assert!(!cache_is_fresh(&c, Duration::from_secs(3600), now));
}
#[test]
fn zero_freshness_window_makes_everything_stale() {
let now = t0();
let c = cache_at(now);
assert!(!cache_is_fresh(&c, Duration::ZERO, now));
}
#[test]
fn epoch_ms_systemtime_round_trip() {
let original_ms: i64 = 1_776_902_400_123;
let st = epoch_ms_to_systemtime(original_ms);
let back = systemtime_to_epoch_ms(st);
assert_eq!(back, original_ms);
}
}