use crate::TaskId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResultTtlConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
default_ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
per_task_ttl_secs: HashMap<String, u64>,
}
impl ResultTtlConfig {
#[must_use]
pub fn new() -> Self {
Self {
default_ttl_secs: None,
per_task_ttl_secs: HashMap::new(),
}
}
#[must_use]
pub fn with_default(default_ttl: Duration) -> Self {
Self {
default_ttl_secs: Some(default_ttl.as_secs()),
per_task_ttl_secs: HashMap::new(),
}
}
#[must_use]
pub fn set_default(mut self, default_ttl: Duration) -> Self {
self.default_ttl_secs = Some(default_ttl.as_secs());
self
}
#[must_use]
pub fn without_default(mut self) -> Self {
self.default_ttl_secs = None;
self
}
#[must_use]
pub fn with_task_ttl(mut self, task_name: impl Into<String>, ttl: Duration) -> Self {
self.per_task_ttl_secs
.insert(task_name.into(), ttl.as_secs());
self
}
pub fn insert_task_ttl(&mut self, task_name: impl Into<String>, ttl: Duration) {
self.per_task_ttl_secs
.insert(task_name.into(), ttl.as_secs());
}
pub fn remove_task_ttl(&mut self, task_name: &str) -> Option<Duration> {
self.per_task_ttl_secs
.remove(task_name)
.map(Duration::from_secs)
}
#[must_use]
pub fn default_ttl(&self) -> Option<Duration> {
self.default_ttl_secs.map(Duration::from_secs)
}
#[must_use]
pub fn task_override(&self, task_name: &str) -> Option<Duration> {
self.per_task_ttl_secs
.get(task_name)
.copied()
.map(Duration::from_secs)
}
#[must_use]
pub fn has_override(&self, task_name: &str) -> bool {
self.per_task_ttl_secs.contains_key(task_name)
}
#[must_use]
pub fn override_count(&self) -> usize {
self.per_task_ttl_secs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.default_ttl_secs.is_none() && self.per_task_ttl_secs.is_empty()
}
#[must_use]
pub fn ttl_for(&self, task_name: &str) -> Option<Duration> {
self.task_override(task_name).or_else(|| self.default_ttl())
}
#[must_use]
pub fn ttl_for_or(&self, task_name: &str, fallback: Duration) -> Duration {
self.ttl_for(task_name).unwrap_or(fallback)
}
#[must_use]
pub fn expires_at(&self, task_name: &str, created_at: DateTime<Utc>) -> Option<DateTime<Utc>> {
let ttl = self.ttl_for(task_name)?;
chrono::Duration::from_std(ttl)
.ok()
.map(|delta| created_at + delta)
}
#[must_use]
pub fn expires_from_now(&self, task_name: &str) -> Option<DateTime<Utc>> {
self.expires_at(task_name, Utc::now())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedResultTtl {
pub task_id: TaskId,
pub task_name: String,
pub ttl: Option<Duration>,
}
impl ResolvedResultTtl {
#[must_use]
pub fn resolve(
config: &ResultTtlConfig,
task_id: TaskId,
task_name: impl Into<String>,
) -> Self {
let task_name = task_name.into();
let ttl = config.ttl_for(&task_name);
Self {
task_id,
task_name,
ttl,
}
}
#[inline]
#[must_use]
pub const fn never_expires(&self) -> bool {
self.ttl.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn empty_config_returns_none() {
let config = ResultTtlConfig::new();
assert!(config.is_empty());
assert_eq!(config.ttl_for("anything"), None);
assert_eq!(config.default_ttl(), None);
assert_eq!(config.override_count(), 0);
}
#[test]
fn default_fallback_used_for_unknown_task() {
let config = ResultTtlConfig::with_default(Duration::from_secs(3600));
assert_eq!(
config.ttl_for("unknown.task"),
Some(Duration::from_secs(3600))
);
assert!(!config.has_override("unknown.task"));
}
#[test]
fn per_task_override_wins_over_default() {
let config = ResultTtlConfig::with_default(Duration::from_secs(60))
.with_task_ttl("reports.generate", Duration::from_secs(86_400));
assert!(config.has_override("reports.generate"));
assert_eq!(
config.ttl_for("reports.generate"),
Some(Duration::from_secs(86_400))
);
assert_eq!(config.ttl_for("other"), Some(Duration::from_secs(60)));
}
#[test]
fn no_default_means_never_expires() {
let config = ResultTtlConfig::new().with_task_ttl("a", Duration::from_secs(10));
assert_eq!(config.ttl_for("a"), Some(Duration::from_secs(10)));
assert_eq!(config.ttl_for("b"), None);
}
#[test]
fn ttl_for_or_fallback() {
let config = ResultTtlConfig::new();
assert_eq!(
config.ttl_for_or("x", Duration::from_secs(5)),
Duration::from_secs(5)
);
let config = config.with_task_ttl("x", Duration::from_secs(99));
assert_eq!(
config.ttl_for_or("x", Duration::from_secs(5)),
Duration::from_secs(99)
);
}
#[test]
fn insert_and_remove_override() {
let mut config = ResultTtlConfig::with_default(Duration::from_secs(30));
config.insert_task_ttl("task.a", Duration::from_secs(120));
assert_eq!(
config.task_override("task.a"),
Some(Duration::from_secs(120))
);
let removed = config.remove_task_ttl("task.a");
assert_eq!(removed, Some(Duration::from_secs(120)));
assert_eq!(config.ttl_for("task.a"), Some(Duration::from_secs(30)));
assert!(config.remove_task_ttl("missing").is_none());
}
#[test]
fn set_and_without_default() {
let config = ResultTtlConfig::new().set_default(Duration::from_secs(7));
assert_eq!(config.default_ttl(), Some(Duration::from_secs(7)));
let config = config.without_default();
assert_eq!(config.default_ttl(), None);
}
#[test]
fn expires_at_computation() {
let config = ResultTtlConfig::with_default(Duration::from_secs(3600));
let created = Utc::now();
let expires = config.expires_at("task", created).unwrap();
let delta = (expires - created).num_seconds();
assert_eq!(delta, 3600);
let none_config = ResultTtlConfig::new();
assert!(none_config.expires_at("task", created).is_none());
assert!(config.expires_from_now("task").is_some());
}
#[test]
fn serde_round_trip() {
let config = ResultTtlConfig::with_default(Duration::from_secs(100))
.with_task_ttl("a", Duration::from_secs(200))
.with_task_ttl("b", Duration::from_secs(300));
let json = serde_json::to_string(&config).unwrap();
let back: ResultTtlConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.ttl_for("a"), Some(Duration::from_secs(200)));
assert_eq!(back.ttl_for("b"), Some(Duration::from_secs(300)));
assert_eq!(back.ttl_for("c"), Some(Duration::from_secs(100)));
}
#[test]
fn resolved_ttl() {
let config = ResultTtlConfig::with_default(Duration::from_secs(60))
.with_task_ttl("big", Duration::from_secs(1000));
let id = Uuid::new_v4();
let resolved = ResolvedResultTtl::resolve(&config, id, "big");
assert_eq!(resolved.task_id, id);
assert_eq!(resolved.ttl, Some(Duration::from_secs(1000)));
assert!(!resolved.never_expires());
let resolved = ResolvedResultTtl::resolve(&config, id, "small");
assert_eq!(resolved.ttl, Some(Duration::from_secs(60)));
let empty = ResultTtlConfig::new();
let resolved = ResolvedResultTtl::resolve(&empty, id, "x");
assert!(resolved.never_expires());
}
}