use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use mcp_usage_core::TaskAttribution;
use thiserror::Error;
pub type TaskStoreFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, TaskStoreError>> + Send + 'a>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum TaskStoreError {
#[error("task-attribution backend unavailable")]
BackendUnavailable,
#[error("task-attribution record is invalid")]
InvalidRecord,
}
pub trait TaskAttributionStore: Send + Sync {
fn insert<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
attribution: TaskAttribution,
) -> TaskStoreFuture<'a, ()>;
fn get<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
fn claim<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()>;
}
const DEFAULT_MAX_TASKS: usize = 100_000;
#[derive(Debug, Default)]
struct TaskState {
tasks: HashMap<(String, String), TaskEntry>,
insertion_order: VecDeque<((String, String), u128)>,
next_generation: u128,
}
#[derive(Debug)]
struct TaskEntry {
attribution: TaskAttribution,
generation: u128,
}
pub struct InMemoryTaskStore {
state: Mutex<TaskState>,
max_tasks: usize,
}
impl std::fmt::Debug for InMemoryTaskStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InMemoryTaskStore")
.field("live_tasks", &self.len())
.field("max_tasks", &self.max_tasks)
.finish_non_exhaustive()
}
}
impl Default for InMemoryTaskStore {
fn default() -> Self {
Self::with_capacity(DEFAULT_MAX_TASKS)
}
}
impl InMemoryTaskStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_capacity(max_tasks: usize) -> Self {
Self {
state: Mutex::new(TaskState::default()),
max_tasks,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.tasks
.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn compact_order_if_needed(&self, state: &mut TaskState) {
if state.insertion_order.len() > self.max_tasks.saturating_mul(2) {
let TaskState {
tasks,
insertion_order,
..
} = state;
insertion_order.retain(|(queued, generation)| {
tasks
.get(queued)
.is_some_and(|entry| entry.generation == *generation)
});
}
}
}
impl TaskAttributionStore for InMemoryTaskStore {
fn insert<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
attribution: TaskAttribution,
) -> TaskStoreFuture<'a, ()> {
Box::pin(async move {
if self.max_tasks == 0 {
return Ok(());
}
let key = (tenant_id.to_owned(), task_id.to_owned());
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.tasks.contains_key(&key) {
return Ok(());
}
while state.tasks.len() >= self.max_tasks {
if let Some((oldest, generation)) = state.insertion_order.pop_front() {
if state
.tasks
.get(&oldest)
.is_some_and(|entry| entry.generation == generation)
{
state.tasks.remove(&oldest);
}
} else {
break;
}
}
let generation = state.next_generation;
state.next_generation = state.next_generation.wrapping_add(1);
state.insertion_order.push_back((key.clone(), generation));
state.tasks.insert(
key,
TaskEntry {
attribution,
generation,
},
);
Ok(())
})
}
fn get<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
Box::pin(async move {
Ok(self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.tasks
.get(&(tenant_id.to_owned(), task_id.to_owned()))
.map(|entry| entry.attribution))
})
}
fn claim<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
Box::pin(async move {
let key = (tenant_id.to_owned(), task_id.to_owned());
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let claimed = state.tasks.remove(&key).map(|entry| entry.attribution);
if claimed.is_some() {
self.compact_order_if_needed(&mut state);
}
Ok(claimed)
})
}
fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()> {
Box::pin(async move {
let key = (tenant_id.to_owned(), task_id.to_owned());
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.tasks.remove(&key);
self.compact_order_if_needed(&mut state);
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_usage_core::TaskOriginKind;
const fn attribution(units: u64) -> TaskAttribution {
TaskAttribution::new(TaskOriginKind::ToolsCall, units)
}
#[tokio::test]
async fn capacity_is_bounded_and_task_origins_cannot_be_replaced() {
let store = InMemoryTaskStore::with_capacity(2);
store
.insert("tenant", "one", attribution(10))
.await
.unwrap();
store.insert("tenant", "one", attribution(1)).await.unwrap();
assert_eq!(
store.get("tenant", "one").await.unwrap(),
Some(attribution(10))
);
store
.insert("tenant", "two", attribution(20))
.await
.unwrap();
store
.insert("tenant", "three", attribution(30))
.await
.unwrap();
assert_eq!(store.len(), 2);
assert!(store.get("tenant", "one").await.unwrap().is_none());
assert_eq!(
store.claim("tenant", "two").await.unwrap(),
Some(attribution(20))
);
assert!(store.claim("tenant", "two").await.unwrap().is_none());
assert_eq!(
store.get("tenant", "three").await.unwrap(),
Some(attribution(30))
);
}
#[tokio::test]
async fn zero_capacity_disables_storage() {
let store = InMemoryTaskStore::with_capacity(0);
store
.insert("tenant", "task", attribution(1))
.await
.unwrap();
assert!(store.is_empty());
}
#[tokio::test]
async fn repeated_insert_and_claim_cycles_bound_ordering_metadata() {
let capacity = 4;
let store = InMemoryTaskStore::with_capacity(capacity);
for index in 0..100 {
let task_id = format!("task-{index}");
store
.insert("tenant", &task_id, attribution(1))
.await
.unwrap();
assert!(store.claim("tenant", &task_id).await.unwrap().is_some());
let state = store.state.lock().unwrap();
assert!(state.tasks.len() <= capacity);
assert!(state.insertion_order.len() <= capacity * 2);
}
}
}