1use std::collections::{HashMap, VecDeque};
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Mutex;
7
8use mcp_usage_core::TaskAttribution;
9use thiserror::Error;
10
11pub type TaskStoreFuture<'a, T> =
13 Pin<Box<dyn Future<Output = Result<T, TaskStoreError>> + Send + 'a>>;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
17pub enum TaskStoreError {
18 #[error("task-attribution backend unavailable")]
20 BackendUnavailable,
21 #[error("task-attribution record is invalid")]
23 InvalidRecord,
24}
25
26pub trait TaskAttributionStore: Send + Sync {
28 fn insert<'a>(
30 &'a self,
31 tenant_id: &'a str,
32 task_id: &'a str,
33 attribution: TaskAttribution,
34 ) -> TaskStoreFuture<'a, ()>;
35 fn get<'a>(
37 &'a self,
38 tenant_id: &'a str,
39 task_id: &'a str,
40 ) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
41 fn claim<'a>(
47 &'a self,
48 tenant_id: &'a str,
49 task_id: &'a str,
50 ) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
51 fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()>;
53}
54
55const DEFAULT_MAX_TASKS: usize = 100_000;
56
57#[derive(Debug, Default)]
58struct TaskState {
59 tasks: HashMap<(String, String), TaskEntry>,
60 insertion_order: VecDeque<((String, String), u128)>,
61 next_generation: u128,
62}
63
64#[derive(Debug)]
65struct TaskEntry {
66 attribution: TaskAttribution,
67 generation: u128,
68}
69
70pub struct InMemoryTaskStore {
72 state: Mutex<TaskState>,
73 max_tasks: usize,
74}
75
76impl std::fmt::Debug for InMemoryTaskStore {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("InMemoryTaskStore")
79 .field("live_tasks", &self.len())
80 .field("max_tasks", &self.max_tasks)
81 .finish_non_exhaustive()
82 }
83}
84
85impl Default for InMemoryTaskStore {
86 fn default() -> Self {
87 Self::with_capacity(DEFAULT_MAX_TASKS)
88 }
89}
90
91impl InMemoryTaskStore {
92 #[must_use]
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 #[must_use]
103 pub fn with_capacity(max_tasks: usize) -> Self {
104 Self {
105 state: Mutex::new(TaskState::default()),
106 max_tasks,
107 }
108 }
109
110 #[must_use]
112 pub fn len(&self) -> usize {
113 self.state
114 .lock()
115 .unwrap_or_else(std::sync::PoisonError::into_inner)
116 .tasks
117 .len()
118 }
119
120 #[must_use]
122 pub fn is_empty(&self) -> bool {
123 self.len() == 0
124 }
125
126 fn compact_order_if_needed(&self, state: &mut TaskState) {
127 if state.insertion_order.len() > self.max_tasks.saturating_mul(2) {
128 let TaskState {
129 tasks,
130 insertion_order,
131 ..
132 } = state;
133 insertion_order.retain(|(queued, generation)| {
134 tasks
135 .get(queued)
136 .is_some_and(|entry| entry.generation == *generation)
137 });
138 }
139 }
140}
141
142impl TaskAttributionStore for InMemoryTaskStore {
143 fn insert<'a>(
144 &'a self,
145 tenant_id: &'a str,
146 task_id: &'a str,
147 attribution: TaskAttribution,
148 ) -> TaskStoreFuture<'a, ()> {
149 Box::pin(async move {
150 if self.max_tasks == 0 {
151 return Ok(());
152 }
153 let key = (tenant_id.to_owned(), task_id.to_owned());
154 let mut state = self
155 .state
156 .lock()
157 .unwrap_or_else(std::sync::PoisonError::into_inner);
158 if state.tasks.contains_key(&key) {
161 return Ok(());
162 }
163 while state.tasks.len() >= self.max_tasks {
164 if let Some((oldest, generation)) = state.insertion_order.pop_front() {
165 if state
166 .tasks
167 .get(&oldest)
168 .is_some_and(|entry| entry.generation == generation)
169 {
170 state.tasks.remove(&oldest);
171 }
172 } else {
173 break;
174 }
175 }
176 let generation = state.next_generation;
177 state.next_generation = state.next_generation.wrapping_add(1);
178 state.insertion_order.push_back((key.clone(), generation));
179 state.tasks.insert(
180 key,
181 TaskEntry {
182 attribution,
183 generation,
184 },
185 );
186 Ok(())
187 })
188 }
189
190 fn get<'a>(
191 &'a self,
192 tenant_id: &'a str,
193 task_id: &'a str,
194 ) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
195 Box::pin(async move {
196 Ok(self
197 .state
198 .lock()
199 .unwrap_or_else(std::sync::PoisonError::into_inner)
200 .tasks
201 .get(&(tenant_id.to_owned(), task_id.to_owned()))
202 .map(|entry| entry.attribution))
203 })
204 }
205
206 fn claim<'a>(
207 &'a self,
208 tenant_id: &'a str,
209 task_id: &'a str,
210 ) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
211 Box::pin(async move {
212 let key = (tenant_id.to_owned(), task_id.to_owned());
213 let mut state = self
214 .state
215 .lock()
216 .unwrap_or_else(std::sync::PoisonError::into_inner);
217 let claimed = state.tasks.remove(&key).map(|entry| entry.attribution);
218 if claimed.is_some() {
219 self.compact_order_if_needed(&mut state);
220 }
221 Ok(claimed)
222 })
223 }
224
225 fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()> {
226 Box::pin(async move {
227 let key = (tenant_id.to_owned(), task_id.to_owned());
228 let mut state = self
229 .state
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 state.tasks.remove(&key);
233 self.compact_order_if_needed(&mut state);
234 Ok(())
235 })
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use mcp_usage_core::TaskOriginKind;
243
244 const fn attribution(units: u64) -> TaskAttribution {
245 TaskAttribution::new(TaskOriginKind::ToolsCall, units)
246 }
247
248 #[tokio::test]
249 async fn capacity_is_bounded_and_task_origins_cannot_be_replaced() {
250 let store = InMemoryTaskStore::with_capacity(2);
251 store
252 .insert("tenant", "one", attribution(10))
253 .await
254 .unwrap();
255 store.insert("tenant", "one", attribution(1)).await.unwrap();
256 assert_eq!(
257 store.get("tenant", "one").await.unwrap(),
258 Some(attribution(10))
259 );
260
261 store
262 .insert("tenant", "two", attribution(20))
263 .await
264 .unwrap();
265 store
266 .insert("tenant", "three", attribution(30))
267 .await
268 .unwrap();
269 assert_eq!(store.len(), 2);
270 assert!(store.get("tenant", "one").await.unwrap().is_none());
271 assert_eq!(
272 store.claim("tenant", "two").await.unwrap(),
273 Some(attribution(20))
274 );
275 assert!(store.claim("tenant", "two").await.unwrap().is_none());
276 assert_eq!(
277 store.get("tenant", "three").await.unwrap(),
278 Some(attribution(30))
279 );
280 }
281
282 #[tokio::test]
283 async fn zero_capacity_disables_storage() {
284 let store = InMemoryTaskStore::with_capacity(0);
285 store
286 .insert("tenant", "task", attribution(1))
287 .await
288 .unwrap();
289 assert!(store.is_empty());
290 }
291
292 #[tokio::test]
293 async fn repeated_insert_and_claim_cycles_bound_ordering_metadata() {
294 let capacity = 4;
295 let store = InMemoryTaskStore::with_capacity(capacity);
296 for index in 0..100 {
297 let task_id = format!("task-{index}");
298 store
299 .insert("tenant", &task_id, attribution(1))
300 .await
301 .unwrap();
302 assert!(store.claim("tenant", &task_id).await.unwrap().is_some());
303 let state = store.state.lock().unwrap();
304 assert!(state.tasks.len() <= capacity);
305 assert!(state.insertion_order.len() <= capacity * 2);
306 }
307 }
308}