use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
pub type Cursor = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionLimits {
pub max_buffered_outbound: usize,
pub max_frame_bytes: usize,
pub dedup_window: usize,
pub max_checkpoint_bytes: usize,
pub idle_ttl_ms: u64,
}
impl Default for SessionLimits {
fn default() -> Self {
Self {
max_buffered_outbound: 256,
max_frame_bytes: 1 << 20, dedup_window: 256,
max_checkpoint_bytes: 4 << 20, idle_ttl_ms: 5 * 60 * 1000, }
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SessionError {
#[error("session frame too large")]
FrameTooLarge,
#[error("session outbound buffer full")]
BufferFull,
#[error("session is closed")]
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundFrame {
pub cursor: Cursor,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionState {
out_cursor: Cursor,
last_acked: Cursor,
outbound: VecDeque<OutboundFrame>,
dedup: VecDeque<String>,
checkpoint: Option<Vec<u8>>,
closed: Option<String>,
last_active_ms: u64,
}
impl SessionState {
pub fn new(now_ms: u64) -> Self {
Self {
out_cursor: 0,
last_acked: 0,
outbound: VecDeque::new(),
dedup: VecDeque::new(),
checkpoint: None,
closed: None,
last_active_ms: now_ms,
}
}
pub fn out_cursor(&self) -> Cursor {
self.out_cursor
}
pub fn last_acked(&self) -> Cursor {
self.last_acked
}
pub fn close_reason(&self) -> Option<&str> {
self.closed.as_deref()
}
pub fn is_closed(&self) -> bool {
self.closed.is_some()
}
pub fn enqueue_outbound(
&mut self,
payload: Vec<u8>,
limits: &SessionLimits,
now_ms: u64,
) -> Result<Cursor, SessionError> {
if self.closed.is_some() {
return Err(SessionError::Closed);
}
if payload.len() > limits.max_frame_bytes {
return Err(SessionError::FrameTooLarge);
}
if self.outbound.len() >= limits.max_buffered_outbound {
return Err(SessionError::BufferFull);
}
self.out_cursor += 1;
let cursor = self.out_cursor;
self.outbound.push_back(OutboundFrame { cursor, payload });
self.last_active_ms = now_ms;
Ok(cursor)
}
pub fn frames_since(&self, after: Cursor) -> impl Iterator<Item = &OutboundFrame> {
self.outbound.iter().filter(move |f| f.cursor > after)
}
pub fn ack(&mut self, cursor: Cursor, now_ms: u64) {
let target = cursor.min(self.out_cursor);
if target <= self.last_acked {
return; }
self.last_acked = target;
while self.outbound.front().is_some_and(|f| f.cursor <= target) {
self.outbound.pop_front();
}
self.last_active_ms = now_ms;
}
pub fn contains_inbound(&self, key: &str) -> bool {
self.dedup.iter().any(|k| k == key)
}
pub fn record_inbound(
&mut self,
key: &str,
limits: &SessionLimits,
now_ms: u64,
) -> Result<bool, SessionError> {
if self.closed.is_some() {
return Err(SessionError::Closed);
}
if self.dedup.iter().any(|k| k == key) {
return Ok(false); }
self.dedup.push_back(key.to_string());
while self.dedup.len() > limits.dedup_window {
self.dedup.pop_front();
}
self.last_active_ms = now_ms;
Ok(true)
}
pub fn set_checkpoint(&mut self, snapshot: Vec<u8>, now_ms: u64) {
self.checkpoint = Some(snapshot);
self.last_active_ms = now_ms;
}
pub fn resumed(&self) -> Option<&[u8]> {
self.checkpoint.as_deref()
}
pub fn close(&mut self, reason: impl Into<String>, now_ms: u64) {
if self.closed.is_none() {
self.closed = Some(reason.into());
self.outbound.clear();
self.dedup.clear();
self.last_active_ms = now_ms;
}
}
pub fn is_expired(&self, limits: &SessionLimits, now_ms: u64) -> bool {
self.closed.is_some() || now_ms.saturating_sub(self.last_active_ms) > limits.idle_ttl_ms
}
}
#[cfg(test)]
mod tests {
use super::*;
fn limits() -> SessionLimits {
SessionLimits {
max_buffered_outbound: 3,
max_frame_bytes: 8,
dedup_window: 2,
max_checkpoint_bytes: 16,
idle_ttl_ms: 1_000,
}
}
#[test]
fn cursors_are_monotonic_and_1_based() {
let l = limits();
let mut s = SessionState::new(0);
assert_eq!(s.enqueue_outbound(b"a".to_vec(), &l, 1).unwrap(), 1);
assert_eq!(s.enqueue_outbound(b"b".to_vec(), &l, 2).unwrap(), 2);
assert_eq!(s.out_cursor(), 2);
}
#[test]
fn frames_since_yields_ordered_tail_for_resume() {
let l = limits();
let mut s = SessionState::new(0);
for p in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] {
s.enqueue_outbound(p, &l, 1).unwrap();
}
let tail: Vec<_> = s.frames_since(1).map(|f| f.cursor).collect();
assert_eq!(tail, vec![2, 3]);
let all: Vec<_> = s.frames_since(0).map(|f| f.cursor).collect();
assert_eq!(all, vec![1, 2, 3]);
}
#[test]
fn ack_advances_monotonically_and_gcs_the_buffer() {
let l = limits();
let mut s = SessionState::new(0);
for p in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] {
s.enqueue_outbound(p, &l, 1).unwrap();
}
s.ack(2, 2);
assert_eq!(s.last_acked(), 2);
let remaining: Vec<_> = s.frames_since(0).map(|f| f.cursor).collect();
assert_eq!(remaining, vec![3]);
s.ack(1, 3);
assert_eq!(s.last_acked(), 2);
s.ack(99, 4);
assert_eq!(s.last_acked(), 3);
assert_eq!(s.frames_since(0).count(), 0);
}
#[test]
fn inbound_is_deduped_within_the_window() {
let l = limits();
let mut s = SessionState::new(0);
assert!(s.record_inbound("k1", &l, 1).unwrap()); assert!(!s.record_inbound("k1", &l, 2).unwrap()); assert!(s.record_inbound("k2", &l, 3).unwrap()); }
#[test]
fn dedup_window_is_bounded_at_least_once_tail() {
let l = limits();
let mut s = SessionState::new(0);
assert!(s.record_inbound("k1", &l, 1).unwrap());
assert!(s.record_inbound("k2", &l, 2).unwrap());
assert!(s.record_inbound("k3", &l, 3).unwrap()); assert!(s.record_inbound("k1", &l, 4).unwrap()); }
#[test]
fn outbound_buffer_cap_applies_backpressure() {
let l = limits(); let mut s = SessionState::new(0);
for _ in 0..3 {
s.enqueue_outbound(b"x".to_vec(), &l, 1).unwrap();
}
assert_eq!(
s.enqueue_outbound(b"y".to_vec(), &l, 2),
Err(SessionError::BufferFull)
);
assert_eq!(s.out_cursor(), 3);
s.ack(3, 3);
assert_eq!(s.enqueue_outbound(b"y".to_vec(), &l, 4).unwrap(), 4);
}
#[test]
fn frame_size_cap_is_enforced_both_never_consuming_a_cursor() {
let l = limits(); let mut s = SessionState::new(0);
assert_eq!(
s.enqueue_outbound(vec![0u8; 9], &l, 1),
Err(SessionError::FrameTooLarge)
);
assert_eq!(s.out_cursor(), 0);
}
#[test]
fn checkpoint_round_trips() {
let mut s = SessionState::new(0);
assert_eq!(s.resumed(), None);
s.set_checkpoint(b"state-v1".to_vec(), 1);
assert_eq!(s.resumed(), Some(&b"state-v1"[..]));
s.set_checkpoint(b"state-v2".to_vec(), 2);
assert_eq!(s.resumed(), Some(&b"state-v2"[..]));
}
#[test]
fn close_is_idempotent_and_fails_closed() {
let l = limits();
let mut s = SessionState::new(0);
s.enqueue_outbound(b"a".to_vec(), &l, 1).unwrap();
s.close("client gone", 2);
assert!(s.is_closed());
assert_eq!(s.close_reason(), Some("client gone"));
s.close("other", 3);
assert_eq!(s.close_reason(), Some("client gone"));
assert_eq!(s.frames_since(0).count(), 0);
assert_eq!(
s.enqueue_outbound(b"b".to_vec(), &l, 4),
Err(SessionError::Closed)
);
assert_eq!(s.record_inbound("k", &l, 5), Err(SessionError::Closed));
}
#[test]
fn idle_ttl_expiry() {
let l = limits(); let mut s = SessionState::new(0);
s.enqueue_outbound(b"a".to_vec(), &l, 100).unwrap(); assert!(!s.is_expired(&l, 1_000)); assert!(s.is_expired(&l, 1_200)); let mut c = SessionState::new(0);
c.close("done", 100);
assert!(c.is_expired(&l, 101));
}
#[test]
fn state_serde_round_trips_for_kv_persistence() {
let l = limits();
let mut s = SessionState::new(7);
s.enqueue_outbound(b"a".to_vec(), &l, 8).unwrap();
s.record_inbound("k1", &l, 9).unwrap();
s.set_checkpoint(b"cp".to_vec(), 10);
let bytes = serde_json::to_vec(&s).unwrap();
let back: SessionState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(s, back);
}
}