use std::collections::HashMap;
use parking_lot::Mutex;
use crate::session::session::SessionId;
pub const MAX_TOPICS_PER_SESSION: usize = 1024;
#[derive(Default)]
pub struct OffsetTracker {
inner: Mutex<HashMap<SessionId, HashMap<String, i64>>>,
}
impl OffsetTracker {
pub fn new() -> Self {
Self::default()
}
pub fn record(&self, session: &SessionId, topic: &str, offset: i64) {
let mut g = self.inner.lock();
let topics = g.entry(session.clone()).or_default();
if topics.len() >= MAX_TOPICS_PER_SESSION && !topics.contains_key(topic) {
if let Some(oldest) = topics
.iter()
.min_by_key(|(_, v)| *v)
.map(|(k, _)| k.clone())
{
topics.remove(&oldest);
}
}
topics.insert(topic.to_string(), offset);
}
pub fn get(&self, session: &SessionId, topic: &str) -> Option<i64> {
self.inner
.lock()
.get(session)
.and_then(|m| m.get(topic).copied())
}
pub fn snapshot(&self, session: &SessionId) -> HashMap<String, i64> {
self.inner.lock().get(session).cloned().unwrap_or_default()
}
pub fn forget(&self, session: &SessionId) {
self.inner.lock().remove(session);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeDecision {
FullResume,
PartialResume,
Replaying,
SnapshotRequired,
ColdStart,
Rejected,
}
pub fn decide(
last_offsets: &HashMap<String, i64>,
topic_offsets: &HashMap<String, i64>,
) -> ResumeDecision {
if last_offsets.is_empty() {
return ResumeDecision::ColdStart;
}
let mut all_within = true;
let mut any_behind = false;
for (topic, last) in last_offsets {
match topic_offsets.get(topic) {
None => {
return ResumeDecision::SnapshotRequired;
}
Some(head) => {
if *last > *head {
return ResumeDecision::Rejected;
}
if *last < *head {
any_behind = true;
}
if *last < head.saturating_sub(1) {
all_within = false;
}
}
}
}
if any_behind && all_within {
ResumeDecision::Replaying
} else if any_behind {
ResumeDecision::PartialResume
} else {
ResumeDecision::FullResume
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_and_read() {
let t = OffsetTracker::new();
let s = SessionId::new();
t.record(&s, "room/1", 10);
t.record(&s, "room/2", 5);
assert_eq!(t.get(&s, "room/1"), Some(10));
let snap = t.snapshot(&s);
assert_eq!(snap.get("room/1"), Some(&10));
assert_eq!(snap.get("room/2"), Some(&5));
t.forget(&s);
assert!(t.snapshot(&s).is_empty());
}
#[test]
fn decide_cold_start_when_empty() {
let d = decide(&HashMap::new(), &HashMap::new());
assert_eq!(d, ResumeDecision::ColdStart);
}
#[test]
fn decide_rejected_when_client_ahead() {
let mut last = HashMap::new();
last.insert("t".to_string(), 100);
let mut head = HashMap::new();
head.insert("t".to_string(), 50);
assert_eq!(decide(&last, &head), ResumeDecision::Rejected);
}
#[test]
fn decide_replaying_when_slightly_behind() {
let mut last = HashMap::new();
last.insert("t".to_string(), 9);
let mut head = HashMap::new();
head.insert("t".to_string(), 10);
assert_eq!(decide(&last, &head), ResumeDecision::Replaying);
}
#[test]
fn decide_partial_when_far_behind() {
let mut last = HashMap::new();
last.insert("t".to_string(), 1);
let mut head = HashMap::new();
head.insert("t".to_string(), 100);
assert_eq!(decide(&last, &head), ResumeDecision::PartialResume);
}
#[test]
fn decide_snapshot_when_topic_missing() {
let mut last = HashMap::new();
last.insert("t".to_string(), 1);
let head = HashMap::new();
assert_eq!(decide(&last, &head), ResumeDecision::SnapshotRequired);
}
}