use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
#[derive(Debug, thiserror::Error)]
pub enum SessionStoreError {
#[error("session store backend error: {0}")]
Backend(String),
}
#[async_trait]
pub trait SessionStore: Send + Sync {
async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError>;
async fn append_labels(
&self,
session_id: &str,
labels: &[String],
) -> Result<(), SessionStoreError>;
}
pub trait SessionStoreFactory: Send + Sync {
fn kind(&self) -> &str;
fn build(
&self,
config: &serde_yaml::Value,
) -> Result<Arc<dyn SessionStore>, Box<dyn std::error::Error + Send + Sync>>;
}
#[derive(Default)]
pub struct MemorySessionStore {
inner: RwLock<HashMap<String, HashSet<String>>>,
}
impl MemorySessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> HashMap<String, HashSet<String>> {
self.inner.read().unwrap_or_else(|p| p.into_inner()).clone()
}
}
#[async_trait]
impl SessionStore for MemorySessionStore {
async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError> {
let r = self.inner.read().unwrap_or_else(|p| p.into_inner());
Ok(r.get(session_id)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default())
}
async fn append_labels(
&self,
session_id: &str,
labels: &[String],
) -> Result<(), SessionStoreError> {
if labels.is_empty() {
return Ok(());
}
let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner());
let entry = w.entry(session_id.to_string()).or_default();
for l in labels {
entry.insert(l.clone());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[tokio::test]
async fn load_for_unknown_session_is_empty() {
let store = MemorySessionStore::new();
assert!(store.load_labels("nonexistent").await.unwrap().is_empty());
}
#[tokio::test]
async fn append_then_load_roundtrips() {
let store = MemorySessionStore::new();
store
.append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()])
.await
.unwrap();
let mut labels = store.load_labels("sess-1").await.unwrap();
labels.sort();
assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]);
}
#[tokio::test]
async fn append_is_monotonic_dedupes() {
let store = MemorySessionStore::new();
store
.append_labels("sess-1", &["PII".to_string()])
.await
.unwrap();
store
.append_labels("sess-1", &["PII".to_string(), "PII".to_string()])
.await
.unwrap();
let labels = store.load_labels("sess-1").await.unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0], "PII");
}
#[tokio::test]
async fn sessions_are_isolated() {
let store = MemorySessionStore::new();
store.append_labels("a", &["X".to_string()]).await.unwrap();
store.append_labels("b", &["Y".to_string()]).await.unwrap();
assert_eq!(store.load_labels("a").await.unwrap(), vec!["X".to_string()]);
assert_eq!(store.load_labels("b").await.unwrap(), vec!["Y".to_string()]);
}
#[tokio::test]
async fn shared_arc_observes_writes() {
let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let c1 = Arc::clone(&store);
let c2 = Arc::clone(&store);
c1.append_labels("sess", &["Z".to_string()]).await.unwrap();
assert_eq!(c2.load_labels("sess").await.unwrap(), vec!["Z".to_string()]);
}
}