use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use plaza::agent::AgentId;
use crate::types::RoomId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ticket<ID: AgentId> {
pub player: ID,
pub room: RoomId,
}
pub trait TicketStore<ID: AgentId>: Send + Sync {
fn issue(&self, player: ID, room: RoomId) -> String;
fn issue_with(&self, token: String, player: ID, room: RoomId);
fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>>;
fn revoke(&self, token: &str) -> bool;
fn outstanding(&self) -> usize;
}
struct Held<ID: AgentId> {
ticket: Ticket<ID>,
issued_at: Instant,
}
struct Inner<ID: AgentId> {
held: HashMap<String, Held<ID>>,
last_swept: Instant,
}
#[derive(Debug)]
pub struct MapTicketRegistry<ID: AgentId> {
inner: Mutex<Inner<ID>>,
next: AtomicU64,
window: Option<Duration>,
}
impl<ID: AgentId> std::fmt::Debug for Inner<ID> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner").field("held", &self.held.len()).finish()
}
}
impl<ID: AgentId> std::fmt::Debug for Held<ID> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Held").field("ticket", &self.ticket).finish()
}
}
impl<ID: AgentId> Default for MapTicketRegistry<ID> {
fn default() -> Self {
Self::new()
}
}
impl<ID: AgentId> MapTicketRegistry<ID> {
pub fn new() -> Self {
Self::build(None)
}
pub fn with_expiry(window: Duration) -> Self {
Self::build(Some(window))
}
fn build(window: Option<Duration>) -> Self {
Self {
inner: Mutex::new(Inner {
held: HashMap::new(),
last_swept: Instant::now(),
}),
next: AtomicU64::new(0),
window,
}
}
pub fn expiry(&self) -> Option<Duration> {
self.window
}
fn lapsed(&self, issued_at: Instant, now: Instant) -> bool {
self.window.is_some_and(|w| now.duration_since(issued_at) >= w)
}
fn issue_with_at(&self, token: String, player: ID, room: RoomId, now: Instant) {
let mut inner = self.inner.lock();
if let Some(window) = self.window
&& now.duration_since(inner.last_swept) >= window
{
inner.held.retain(|_, held| now.duration_since(held.issued_at) < window);
inner.last_swept = now;
}
inner.held.insert(
token,
Held {
ticket: Ticket { player, room },
issued_at: now,
},
);
}
fn redeem_at(&self, token: &str, room: &RoomId, now: Instant) -> Option<Ticket<ID>> {
let mut inner = self.inner.lock();
if inner.held.get(token).is_none_or(|held| held.ticket.room != *room) {
return None;
}
let held = inner.held.remove(token)?;
(!self.lapsed(held.issued_at, now)).then_some(held.ticket)
}
fn outstanding_at(&self, now: Instant) -> usize {
self
.inner
.lock()
.held
.values()
.filter(|held| !self.lapsed(held.issued_at, now))
.count()
}
}
impl<ID: AgentId> TicketStore<ID> for MapTicketRegistry<ID> {
fn issue(&self, player: ID, room: RoomId) -> String {
let token = format!("t{}", self.next.fetch_add(1, Ordering::Relaxed));
self.issue_with(token.clone(), player, room);
token
}
fn issue_with(&self, token: String, player: ID, room: RoomId) {
self.issue_with_at(token, player, room, Instant::now());
}
fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>> {
self.redeem_at(token, room, Instant::now())
}
fn revoke(&self, token: &str) -> bool {
self.inner.lock().held.remove(token).is_some()
}
fn outstanding(&self) -> usize {
self.outstanding_at(Instant::now())
}
}
#[cfg(feature = "cache")]
mod cached {
use std::sync::Arc;
use super::*;
use fibre_cache::{Cache, CacheBuilder};
pub struct CachedTicketRegistry<ID: AgentId> {
held: Cache<String, Ticket<ID>>,
next: AtomicU64,
}
impl<ID: AgentId> std::fmt::Debug for CachedTicketRegistry<ID> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedTicketRegistry").finish_non_exhaustive()
}
}
impl<ID: AgentId> CachedTicketRegistry<ID> {
pub fn with_expiry(window: Duration) -> Self {
Self {
held: CacheBuilder::default()
.time_to_live(window)
.build()
.expect("an unbounded cache with a ttl and no loader is always buildable"),
next: AtomicU64::new(0),
}
}
pub fn run_maintenance(&self) {
self.held.run_maintenance();
}
}
impl<ID: AgentId> TicketStore<ID> for CachedTicketRegistry<ID> {
fn issue(&self, player: ID, room: RoomId) -> String {
let token = format!("t{}", self.next.fetch_add(1, Ordering::Relaxed));
self.issue_with(token.clone(), player, room);
token
}
fn issue_with(&self, token: String, player: ID, room: RoomId) {
self.held.insert(token, Ticket { player, room }, 1);
}
fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>> {
if self.held.fetch(token)?.room != *room {
return None;
}
let spent: Arc<Ticket<ID>> = self.held.remove(token)?;
Some(Arc::try_unwrap(spent).unwrap_or_else(|shared| (*shared).clone()))
}
fn revoke(&self, token: &str) -> bool {
self.held.invalidate(token)
}
fn outstanding(&self) -> usize {
self.held.iter().count()
}
}
}
#[cfg(feature = "cache")]
pub use cached::CachedTicketRegistry;
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn a_ticket_resolves_to_who_it_was_issued_for() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
let ticket = tickets.redeem(&token, &room).expect("issued");
assert_eq!(ticket.player, 7);
assert_eq!(ticket.room, room);
}
#[test]
fn a_ticket_spends_once() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
assert!(tickets.redeem(&token, &room).is_some());
assert!(tickets.redeem(&token, &room).is_none(), "replay is refused");
assert_eq!(tickets.outstanding(), 0);
}
#[test]
fn an_unissued_token_resolves_to_nobody() {
let tickets: MapTicketRegistry<u32> = MapTicketRegistry::new();
assert!(tickets.redeem("t999", &Uuid::new_v4()).is_none());
}
#[test]
fn tickets_are_distinct_per_issue() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
assert_ne!(tickets.issue(1u32, room), tickets.issue(1u32, room));
}
#[test]
fn a_caller_can_supply_its_own_token() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
tickets.issue_with("signed.jwt.value".to_string(), 7u32, room);
assert_eq!(tickets.redeem("signed.jwt.value", &room).unwrap().player, 7);
}
#[test]
fn a_revoked_ticket_cannot_be_redeemed() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
assert!(tickets.revoke(&token));
assert!(tickets.redeem(&token, &room).is_none());
}
#[test]
fn outstanding_tracks_what_was_issued_and_not_dialled() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
let a = tickets.issue(1u32, room);
tickets.issue(2u32, room);
tickets.redeem(&a, &room);
assert_eq!(tickets.outstanding(), 1);
}
#[test]
fn a_ticket_presented_at_the_wrong_room_survives_being_refused() {
let tickets = MapTicketRegistry::new();
let mine = Uuid::new_v4();
let token = tickets.issue(7u32, mine);
assert!(tickets.redeem(&token, &Uuid::new_v4()).is_none(), "wrong room is refused");
assert_eq!(tickets.outstanding(), 1, "and the ticket is still there");
assert_eq!(tickets.redeem(&token, &mine).unwrap().player, 7);
}
#[test]
fn a_registry_without_a_window_never_expires() {
let tickets = MapTicketRegistry::new();
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
let ages_later = Instant::now() + Duration::from_secs(86_400);
assert!(tickets.redeem_at(&token, &room, ages_later).is_some(), "today's behaviour");
}
#[test]
fn a_ticket_past_its_window_is_refused() {
let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
let now = Instant::now();
assert!(tickets.redeem_at(&token, &room, now + Duration::from_secs(29)).is_some());
let token = tickets.issue(7u32, room);
assert!(tickets.redeem_at(&token, &room, now + Duration::from_secs(31)).is_none());
}
#[test]
fn a_refused_ticket_is_dropped_rather_than_left_behind() {
let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
let expired = Instant::now() + Duration::from_secs(31);
assert!(tickets.redeem_at(&token, &room, expired).is_none());
assert_eq!(tickets.inner.lock().held.len(), 0, "refusing also collects");
}
#[test]
fn issuing_sweeps_what_the_window_has_passed() {
let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let start = Instant::now();
for player in 0..5u32 {
tickets.issue_with_at(format!("abandoned{player}"), player, room, start);
}
assert_eq!(tickets.inner.lock().held.len(), 5);
tickets.issue_with_at("fresh".to_string(), 9, room, start + Duration::from_secs(31));
assert_eq!(
tickets.inner.lock().held.len(),
1,
"the abandoned five go, the one being issued stays"
);
}
#[test]
fn a_sweep_runs_at_most_once_per_window() {
let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let start = Instant::now();
tickets.issue_with_at("old".to_string(), 1u32, room, start);
tickets.issue_with_at("a".to_string(), 2, room, start + Duration::from_secs(10));
assert_eq!(tickets.inner.lock().held.len(), 2, "too soon to sweep");
tickets.issue_with_at("b".to_string(), 3, room, start + Duration::from_secs(31));
assert_eq!(tickets.inner.lock().held.len(), 2, "old swept, a and b remain minus old");
}
#[test]
fn outstanding_does_not_count_the_expired() {
let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let start = Instant::now();
tickets.issue_with_at("stale".to_string(), 1u32, room, start);
tickets.issue_with_at("live".to_string(), 2, room, start + Duration::from_secs(25));
assert_eq!(tickets.outstanding_at(start + Duration::from_secs(31)), 1);
}
#[test]
fn a_store_is_usable_behind_a_trait_object() {
let tickets: std::sync::Arc<dyn TicketStore<u32>> = std::sync::Arc::new(MapTicketRegistry::new());
let room = Uuid::new_v4();
let token = tickets.issue(7, room);
assert_eq!(tickets.redeem(&token, &room).unwrap().player, 7);
}
#[cfg(feature = "cache")]
mod cached {
use super::*;
#[test]
fn a_cached_ticket_resolves_to_who_it_was_issued_for() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
let ticket = tickets.redeem(&token, &room).expect("issued");
assert_eq!(ticket.player, 7);
assert_eq!(ticket.room, room);
}
#[test]
fn a_cached_ticket_spends_once() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
assert!(tickets.redeem(&token, &room).is_some());
assert!(tickets.redeem(&token, &room).is_none(), "replay is refused");
}
#[test]
fn a_cached_ticket_can_be_revoked() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
assert!(tickets.revoke(&token));
assert!(tickets.redeem(&token, &room).is_none());
}
#[test]
fn a_cached_caller_can_supply_its_own_token() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
let room = Uuid::new_v4();
tickets.issue_with("signed.jwt.value".to_string(), 7u32, room);
assert_eq!(tickets.redeem("signed.jwt.value", &room).unwrap().player, 7);
}
#[test]
fn a_cached_ticket_at_the_wrong_room_survives_being_refused() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
let mine = Uuid::new_v4();
let token = tickets.issue(7u32, mine);
assert!(tickets.redeem(&token, &Uuid::new_v4()).is_none(), "wrong room is refused");
assert_eq!(tickets.outstanding(), 1, "and the ticket is still there");
assert_eq!(tickets.redeem(&token, &mine).unwrap().player, 7);
}
#[test]
fn a_cached_ticket_past_its_window_is_refused() {
let tickets = CachedTicketRegistry::with_expiry(Duration::from_millis(20));
let room = Uuid::new_v4();
let token = tickets.issue(7u32, room);
std::thread::sleep(Duration::from_millis(1300));
tickets.run_maintenance();
assert!(tickets.redeem(&token, &room).is_none());
assert_eq!(tickets.outstanding(), 0);
}
#[test]
fn a_cached_store_is_usable_behind_a_trait_object() {
let tickets: std::sync::Arc<dyn TicketStore<u32>> =
std::sync::Arc::new(CachedTicketRegistry::with_expiry(Duration::from_secs(30)));
let room = Uuid::new_v4();
let token = tickets.issue(7, room);
assert_eq!(tickets.redeem(&token, &room).unwrap().player, 7);
}
}
}