use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use dashmap::DashMap;
use pjson_rs_domain::value_objects::SessionId;
use tokio::sync::{Mutex, OnceCell};
use crate::{
Error, Result,
compression::zstd::{MAX_DICT_SIZE, N_TRAIN, ZstdDictCompressor, ZstdDictionary},
domain::ports::dictionary_store::{DictionaryFuture, DictionaryStore},
security::CompressionBombDetector,
};
const MAX_TRAINING_SAMPLE_SIZE: usize = 1024 * 1024;
const TOTAL_CORPUS_BYTE_BUDGET: usize = 128 * 1024 * 1024;
const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
const SESSION_CLEANUP_INTERVAL: Duration = Duration::from_secs(5 * 60);
struct SessionDictState {
corpus: Mutex<Vec<Vec<u8>>>,
dict: OnceCell<Arc<ZstdDictionary>>,
last_access: AtomicU64,
pending_corpus_bytes: AtomicUsize,
}
impl SessionDictState {
fn new() -> Self {
Self {
corpus: Mutex::new(Vec::new()),
dict: OnceCell::new(),
last_access: AtomicU64::new(now_millis()),
pending_corpus_bytes: AtomicUsize::new(0),
}
}
}
fn release_corpus_bytes(corpus_bytes_in_flight: &AtomicUsize, amount: usize) {
if amount == 0 {
return;
}
let mut current = corpus_bytes_in_flight.load(Ordering::Relaxed);
loop {
let new = current.saturating_sub(amount);
match corpus_bytes_in_flight.compare_exchange_weak(
current,
new,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
struct CorpusBudgetReservation {
reserved: usize,
corpus_bytes_in_flight: Arc<AtomicUsize>,
}
impl Drop for CorpusBudgetReservation {
fn drop(&mut self) {
release_corpus_bytes(&self.corpus_bytes_in_flight, self.reserved);
}
}
fn now_millis() -> u64 {
static START: OnceLock<Instant> = OnceLock::new();
let start = *START.get_or_init(Instant::now);
start.elapsed().as_millis() as u64
}
fn evict_expired_sessions(
sessions: &DashMap<SessionId, Arc<SessionDictState>>,
ttl: Duration,
corpus_bytes_in_flight: &AtomicUsize,
) {
let now = now_millis();
let ttl_millis = ttl.as_millis() as u64;
sessions.retain(|_, state| {
now.saturating_sub(state.last_access.load(Ordering::Relaxed)) < ttl_millis
});
let live_total: usize = sessions
.iter()
.map(|entry| entry.value().pending_corpus_bytes.load(Ordering::Relaxed))
.sum();
corpus_bytes_in_flight.store(live_total, Ordering::Relaxed);
}
fn spawn_session_cleanup_task(
sessions: &Arc<DashMap<SessionId, Arc<SessionDictState>>>,
corpus_bytes_in_flight: &Arc<AtomicUsize>,
) {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
tracing::warn!(
"InMemoryDictionaryStore: no Tokio runtime available; periodic session eviction \
not started"
);
return;
};
let sessions_weak = Arc::downgrade(sessions);
let bytes_weak = Arc::downgrade(corpus_bytes_in_flight);
handle.spawn(async move {
let mut interval = tokio::time::interval(SESSION_CLEANUP_INTERVAL);
loop {
interval.tick().await;
let (Some(sessions), Some(bytes)) = (sessions_weak.upgrade(), bytes_weak.upgrade())
else {
break;
};
evict_expired_sessions(&sessions, SESSION_TTL, &bytes);
tracing::debug!("InMemoryDictionaryStore: session cleanup pass completed");
}
});
}
pub struct InMemoryDictionaryStore {
sessions: Arc<DashMap<SessionId, Arc<SessionDictState>>>,
bomb_detector: Arc<CompressionBombDetector>,
target_dict_size: usize,
corpus_bytes_in_flight: Arc<AtomicUsize>,
}
impl InMemoryDictionaryStore {
pub fn new(bomb_detector: Arc<CompressionBombDetector>, target_dict_size: usize) -> Self {
let sessions = Arc::new(DashMap::new());
let corpus_bytes_in_flight = Arc::new(AtomicUsize::new(0));
spawn_session_cleanup_task(&sessions, &corpus_bytes_in_flight);
Self {
sessions,
bomb_detector,
target_dict_size: target_dict_size.min(MAX_DICT_SIZE),
corpus_bytes_in_flight,
}
}
pub fn cleanup_expired(&self, ttl: Duration) {
evict_expired_sessions(&self.sessions, ttl, &self.corpus_bytes_in_flight);
}
pub fn register(&self, session_id: SessionId, dict: ZstdDictionary) -> Result<()> {
self.bomb_detector
.validate_pre_decompression(dict.len())
.map_err(|e| {
Error::CompressionError(format!("dictionary rejected by bomb detector: {e}"))
})?;
let state = self.session_state(session_id);
let _ = state.dict.set(Arc::new(dict));
Ok(())
}
fn session_state(&self, session_id: SessionId) -> Arc<SessionDictState> {
let state = self
.sessions
.entry(session_id)
.or_insert_with(|| Arc::new(SessionDictState::new()))
.clone();
state.last_access.store(now_millis(), Ordering::Relaxed);
state
}
}
impl DictionaryStore for InMemoryDictionaryStore {
fn get_dictionary<'a>(
&'a self,
session_id: SessionId,
) -> DictionaryFuture<'a, Option<Arc<ZstdDictionary>>> {
Box::pin(async move {
Ok(self.sessions.get(&session_id).and_then(|s| {
let dict = s.dict.get().cloned();
if dict.is_some() {
s.last_access.store(now_millis(), Ordering::Relaxed);
}
dict
}))
})
}
fn train_if_ready<'a>(
&'a self,
session_id: SessionId,
sample: Vec<u8>,
) -> DictionaryFuture<'a, ()> {
Box::pin(async move {
let state = self.session_state(session_id);
if state.dict.initialized() {
return Ok(());
}
let (snapshot, reserved_bytes) = {
let mut guard = state.corpus.lock().await;
if guard.len() < N_TRAIN && sample.len() <= MAX_TRAINING_SAMPLE_SIZE {
let sample_len = sample.len();
let global_reserved = self
.corpus_bytes_in_flight
.fetch_add(sample_len, Ordering::Relaxed)
+ sample_len;
if global_reserved <= TOTAL_CORPUS_BYTE_BUDGET {
state
.pending_corpus_bytes
.fetch_add(sample_len, Ordering::Relaxed);
guard.push(sample);
} else {
release_corpus_bytes(&self.corpus_bytes_in_flight, sample_len);
tracing::debug!(
sample_len,
budget = TOTAL_CORPUS_BYTE_BUDGET,
"InMemoryDictionaryStore: skipping training sample, \
corpus byte budget exhausted"
);
}
}
if guard.len() < N_TRAIN {
return Ok(());
}
let reserved_bytes = state.pending_corpus_bytes.swap(0, Ordering::Relaxed);
(std::mem::take(&mut *guard), reserved_bytes)
};
let _reservation_guard = CorpusBudgetReservation {
reserved: reserved_bytes,
corpus_bytes_in_flight: self.corpus_bytes_in_flight.clone(),
};
let target = self.target_dict_size;
let bomb_detector = self.bomb_detector.clone();
state
.dict
.get_or_try_init(|| async move {
let dict = tokio::task::spawn_blocking(move || {
ZstdDictCompressor::train(&snapshot, target)
})
.await
.map_err(|e| {
Error::CompressionError(format!("zstd: train join error: {e}"))
})??;
bomb_detector
.validate_pre_decompression(dict.len())
.map_err(|e| {
Error::CompressionError(format!(
"trained dict rejected by bomb detector: {e}"
))
})?;
Ok::<_, Error>(Arc::new(dict))
})
.await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pjson_rs_domain::value_objects::SessionId;
fn make_store() -> InMemoryDictionaryStore {
InMemoryDictionaryStore::new(Arc::new(CompressionBombDetector::default()), 64 * 1024)
}
fn make_samples(count: usize) -> Vec<Vec<u8>> {
(0..count)
.map(|i| format!(r#"{{"id":{i},"name":"item","value":{}}}"#, i * 10).into_bytes())
.collect()
}
#[tokio::test]
async fn test_get_dictionary_returns_none_before_training() {
let store = make_store();
let sid = SessionId::new();
let result = store.get_dictionary(sid).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_train_if_ready_below_threshold_stays_none() {
let store = make_store();
let sid = SessionId::new();
for i in 0..(N_TRAIN - 1) {
let sample = format!(r#"{{"i":{i}}}"#).into_bytes();
store.train_if_ready(sid, sample).await.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_none(),
"should still be None before N_TRAIN samples"
);
}
#[tokio::test]
async fn test_train_if_ready_fires_after_threshold() {
let store = make_store();
let sid = SessionId::new();
let samples = make_samples(N_TRAIN);
for sample in samples {
store.train_if_ready(sid, sample).await.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_some(),
"dictionary should be Some after N_TRAIN samples"
);
}
#[tokio::test]
async fn test_register_then_get_returns_dict() {
let store = make_store();
let sid = SessionId::new();
let samples = make_samples(N_TRAIN);
let dict = ZstdDictCompressor::train(&samples, MAX_DICT_SIZE).unwrap();
store.register(sid, dict).unwrap();
let result = store.get_dictionary(sid).await.unwrap();
assert!(result.is_some());
}
#[tokio::test]
async fn test_concurrent_train_if_ready_produces_exactly_one_dict() {
use futures::future::try_join_all;
let store = Arc::new(make_store());
let sid = SessionId::new();
let samples = make_samples(N_TRAIN * 2);
let futs: Vec<_> = samples
.into_iter()
.map(|sample| {
let store = store.clone();
tokio::spawn(async move { store.train_if_ready(sid, sample).await })
})
.collect();
let results = try_join_all(futs).await.unwrap();
for r in results {
r.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(result.is_some(), "exactly one dictionary should be trained");
}
#[tokio::test]
async fn test_train_if_ready_bomb_detector_rejects_trained_dict() {
use crate::security::CompressionBombConfig;
let config = CompressionBombConfig {
max_compressed_size: 100,
..Default::default()
};
let store = InMemoryDictionaryStore::new(
Arc::new(CompressionBombDetector::new(config)),
MAX_DICT_SIZE,
);
let sid = SessionId::new();
let samples = make_samples(N_TRAIN);
let mut training_error_seen = false;
for sample in samples {
let result = store.train_if_ready(sid, sample).await;
if result.is_err() {
training_error_seen = true;
break;
}
}
assert!(
training_error_seen,
"expected bomb detector to reject the trained dict"
);
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_none(),
"bomb detector should have prevented dict from being stored"
);
}
#[tokio::test]
async fn test_register_rejects_oversized_dict_via_bomb_detector() {
use crate::security::CompressionBombConfig;
let config = CompressionBombConfig {
max_compressed_size: 10, ..Default::default()
};
let store = InMemoryDictionaryStore::new(
Arc::new(CompressionBombDetector::new(config)),
MAX_DICT_SIZE,
);
let sid = SessionId::new();
let samples = make_samples(N_TRAIN);
let dict = ZstdDictCompressor::train(&samples, MAX_DICT_SIZE).unwrap();
let result = store.register(sid, dict);
assert!(result.is_err(), "bomb detector must reject oversized dict");
}
#[tokio::test]
async fn test_cleanup_expired_evicts_idle_sessions() {
let store = make_store();
let sid = SessionId::new();
store.train_if_ready(sid, b"sample".to_vec()).await.unwrap();
assert_eq!(store.sessions.len(), 1);
store.cleanup_expired(Duration::from_secs(0));
assert_eq!(
store.sessions.len(),
0,
"session idle past the TTL should be evicted"
);
}
#[tokio::test]
async fn test_cleanup_expired_preserves_fresh_sessions() {
let store = make_store();
let sid = SessionId::new();
store.train_if_ready(sid, b"sample".to_vec()).await.unwrap();
store.cleanup_expired(Duration::from_secs(3600));
assert_eq!(
store.sessions.len(),
1,
"session accessed within the TTL window must survive cleanup"
);
}
#[tokio::test]
async fn test_oversized_sample_is_skipped_not_rejected() {
let store = make_store();
let sid = SessionId::new();
let oversized = vec![0u8; MAX_TRAINING_SAMPLE_SIZE + 1];
let result = store.train_if_ready(sid, oversized).await;
assert!(
result.is_ok(),
"oversized sample must be skipped, not error the request"
);
for sample in make_samples(N_TRAIN) {
store.train_if_ready(sid, sample).await.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_some(),
"training should succeed once N_TRAIN valid samples arrive"
);
}
#[tokio::test]
async fn test_corpus_cleared_after_training_completes() {
let store = make_store();
let sid = SessionId::new();
for sample in make_samples(N_TRAIN) {
store.train_if_ready(sid, sample).await.unwrap();
}
let state = store.sessions.get(&sid).unwrap().value().clone();
assert!(state.dict.initialized());
assert!(
state.corpus.lock().await.is_empty(),
"corpus must be cleared once the dictionary is trained"
);
}
#[tokio::test]
async fn test_sample_at_exact_cap_is_accepted() {
let store = make_store();
let sid = SessionId::new();
let exact_cap_sample = vec![0u8; MAX_TRAINING_SAMPLE_SIZE];
store.train_if_ready(sid, exact_cap_sample).await.unwrap();
for sample in make_samples(N_TRAIN - 1) {
store.train_if_ready(sid, sample).await.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_some(),
"a sample of exactly MAX_TRAINING_SAMPLE_SIZE must be accepted, not skipped"
);
}
#[tokio::test]
async fn test_global_corpus_budget_rejects_samples_once_exhausted() {
let store = make_store();
store
.corpus_bytes_in_flight
.store(TOTAL_CORPUS_BYTE_BUDGET, Ordering::Relaxed);
let sid = SessionId::new();
for _ in 0..N_TRAIN {
store.train_if_ready(sid, b"sample".to_vec()).await.unwrap();
}
let result = store.get_dictionary(sid).await.unwrap();
assert!(
result.is_none(),
"no sample should have been admitted once the global budget was exhausted"
);
assert_eq!(
store.corpus_bytes_in_flight.load(Ordering::Relaxed),
TOTAL_CORPUS_BYTE_BUDGET,
"budget accounting must not grow past what was already reserved"
);
}
#[tokio::test]
async fn test_ttl_eviction_releases_pending_corpus_bytes() {
let store = make_store();
let sid = SessionId::new();
store.train_if_ready(sid, vec![0u8; 1024]).await.unwrap();
assert!(store.corpus_bytes_in_flight.load(Ordering::Relaxed) > 0);
store.cleanup_expired(Duration::from_secs(0));
assert_eq!(
store.corpus_bytes_in_flight.load(Ordering::Relaxed),
0,
"evicting a session with pending samples must release its budget reservation"
);
}
#[tokio::test]
async fn test_training_completion_releases_pending_corpus_bytes() {
let store = make_store();
let sid = SessionId::new();
for sample in make_samples(N_TRAIN) {
store.train_if_ready(sid, sample).await.unwrap();
}
assert_eq!(
store.corpus_bytes_in_flight.load(Ordering::Relaxed),
0,
"reservation must be released once the corpus is snapshotted for training"
);
}
#[tokio::test]
async fn test_get_dictionary_read_bumps_last_access() {
let store = make_store();
let sid = SessionId::new();
for sample in make_samples(N_TRAIN) {
store.train_if_ready(sid, sample).await.unwrap();
}
let state = store.sessions.get(&sid).unwrap().value().clone();
state.last_access.store(0, Ordering::Relaxed);
store.get_dictionary(sid).await.unwrap();
assert!(
state.last_access.load(Ordering::Relaxed) > 0,
"get_dictionary must refresh last_access so an actively-served \
session is not evicted out from under its readers"
);
}
#[tokio::test]
async fn test_get_dictionary_poll_on_untrained_session_does_not_bump_last_access() {
let store = make_store();
let sid = SessionId::new();
store.train_if_ready(sid, vec![0u8; 1024]).await.unwrap();
let state = store.sessions.get(&sid).unwrap().value().clone();
state.last_access.store(0, Ordering::Relaxed);
let result = store.get_dictionary(sid).await.unwrap();
assert!(result.is_none());
assert_eq!(
state.last_access.load(Ordering::Relaxed),
0,
"polling a still-training session must not refresh last_access — \
otherwise a client could keep its pending budget reservation \
alive forever just by polling"
);
}
#[tokio::test]
async fn test_training_failure_still_releases_pending_corpus_bytes() {
use crate::security::CompressionBombConfig;
let config = CompressionBombConfig {
max_compressed_size: 100,
..Default::default()
};
let store = InMemoryDictionaryStore::new(
Arc::new(CompressionBombDetector::new(config)),
MAX_DICT_SIZE,
);
let sid = SessionId::new();
for sample in make_samples(N_TRAIN) {
let _ = store.train_if_ready(sid, sample).await;
}
assert_eq!(
store.corpus_bytes_in_flight.load(Ordering::Relaxed),
0,
"reservation must be released even when training itself fails, \
not only on the success path"
);
}
#[test]
fn test_release_corpus_bytes_saturates_instead_of_wrapping() {
let counter = AtomicUsize::new(5);
release_corpus_bytes(&counter, 100);
assert_eq!(counter.load(Ordering::Relaxed), 0);
}
#[test]
fn test_corpus_budget_reservation_releases_on_drop() {
let corpus_bytes_in_flight = Arc::new(AtomicUsize::new(500));
let guard = CorpusBudgetReservation {
reserved: 200,
corpus_bytes_in_flight: corpus_bytes_in_flight.clone(),
};
drop(guard);
assert_eq!(corpus_bytes_in_flight.load(Ordering::Relaxed), 300);
}
#[test]
fn test_guard_release_amount_is_independent_of_concurrent_pending_bytes_changes() {
let corpus_bytes_in_flight = Arc::new(AtomicUsize::new(1000));
let state = Arc::new(SessionDictState::new());
let guard = CorpusBudgetReservation {
reserved: 300,
corpus_bytes_in_flight: corpus_bytes_in_flight.clone(),
};
state.pending_corpus_bytes.fetch_add(150, Ordering::Relaxed);
corpus_bytes_in_flight.fetch_add(150, Ordering::Relaxed);
drop(guard);
assert_eq!(
corpus_bytes_in_flight.load(Ordering::Relaxed),
850, "the guard must release only its own captured reservation, not \
whatever the session's live pending_corpus_bytes happens to \
hold at drop time"
);
assert_eq!(
state.pending_corpus_bytes.load(Ordering::Relaxed),
150,
"the concurrent reservation must survive this guard's drop untouched"
);
}
}