use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
const DEFAULT_TTL_SECS: u64 = 300;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "mode")]
#[non_exhaustive]
pub enum StickyPolicy {
#[default]
Disabled,
Domain {
#[serde(with = "serde_duration_secs")]
ttl: Duration,
},
}
impl StickyPolicy {
pub const fn domain(ttl: Duration) -> Self {
Self::Domain { ttl }
}
pub const fn domain_default() -> Self {
Self::Domain {
ttl: Duration::from_secs(DEFAULT_TTL_SECS),
}
}
pub const fn is_disabled(&self) -> bool {
matches!(self, Self::Disabled)
}
}
#[derive(Debug, Clone)]
struct ProxySession {
proxy_id: Uuid,
bound_at: Instant,
ttl: Duration,
}
impl ProxySession {
fn is_expired(&self) -> bool {
self.bound_at.elapsed() >= self.ttl
}
}
#[derive(Debug, Clone)]
pub struct SessionMap {
inner: Arc<RwLock<HashMap<String, ProxySession>>>,
}
impl Default for SessionMap {
fn default() -> Self {
Self::new()
}
}
impl SessionMap {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn lookup(&self, key: &str) -> Option<Uuid> {
let guard = self.inner.try_read().ok()?;
guard
.get(key)
.filter(|s| !s.is_expired())
.map(|s| s.proxy_id)
}
pub fn bind(&self, key: &str, proxy_id: Uuid, ttl: Duration) {
let session = ProxySession {
proxy_id,
bound_at: Instant::now(),
ttl,
};
if let Ok(mut guard) = self.inner.try_write() {
guard.insert(key.to_string(), session);
}
}
pub fn purge_expired(&self) -> usize {
let Ok(mut guard) = self.inner.try_write() else {
return 0;
};
let before = guard.len();
guard.retain(|_, s| !s.is_expired());
before - guard.len()
}
pub fn unbind(&self, key: &str) {
if let Ok(mut guard) = self.inner.try_write() {
guard.remove(key);
}
}
pub fn active_count(&self) -> usize {
let Ok(guard) = self.inner.try_read() else {
return 0;
};
guard.values().filter(|s| !s.is_expired()).count()
}
}
mod serde_duration_secs {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
d.as_secs().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(d)?))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn same_domain_returns_same_proxy() {
let map = SessionMap::new();
let id = Uuid::new_v4();
map.bind("example.com", id, Duration::from_mins(1));
assert_eq!(map.lookup("example.com"), Some(id));
assert_eq!(map.lookup("example.com"), Some(id));
}
#[test]
fn different_domains_independent() {
let map = SessionMap::new();
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
map.bind("a.com", id_a, Duration::from_mins(1));
map.bind("b.com", id_b, Duration::from_mins(1));
assert_eq!(map.lookup("a.com"), Some(id_a));
assert_eq!(map.lookup("b.com"), Some(id_b));
}
#[test]
fn expired_session_returns_none() {
let map = SessionMap::new();
let id = Uuid::new_v4();
map.bind("example.com", id, Duration::ZERO);
std::thread::sleep(Duration::from_millis(1));
assert_eq!(map.lookup("example.com"), None);
}
#[test]
fn purge_removes_expired() {
let map = SessionMap::new();
map.bind("expired.com", Uuid::new_v4(), Duration::ZERO);
map.bind("active.com", Uuid::new_v4(), Duration::from_mins(5));
std::thread::sleep(Duration::from_millis(1));
let removed = map.purge_expired();
assert_eq!(removed, 1);
assert_eq!(map.active_count(), 1);
}
#[test]
fn unbind_removes_session() {
let map = SessionMap::new();
map.bind("example.com", Uuid::new_v4(), Duration::from_mins(1));
map.unbind("example.com");
assert_eq!(map.lookup("example.com"), None);
}
#[test]
fn rebind_overwrites_previous() {
let map = SessionMap::new();
let old_id = Uuid::new_v4();
let new_id = Uuid::new_v4();
map.bind("example.com", old_id, Duration::from_mins(1));
map.bind("example.com", new_id, Duration::from_mins(1));
assert_eq!(map.lookup("example.com"), Some(new_id));
}
#[test]
fn policy_domain_default_ttl() {
let policy = StickyPolicy::domain_default();
assert!(matches!(policy, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(5)));
}
#[test]
fn policy_disabled_by_default() {
let policy = StickyPolicy::default();
assert!(policy.is_disabled());
}
#[test]
fn policy_serde_roundtrip() -> std::result::Result<(), Box<dyn std::error::Error>> {
let policy = StickyPolicy::domain(Duration::from_mins(2));
let json = serde_json::to_string(&policy)?;
let back: StickyPolicy = serde_json::from_str(&json)?;
assert!(matches!(back, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(2)));
Ok(())
}
}