Skip to main content

mcp_usage_tower/
task.rs

1//! Durable-task attribution storage.
2
3use 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
11/// Boxed future returned by object-safe task stores.
12pub type TaskStoreFuture<'a, T> =
13    Pin<Box<dyn Future<Output = Result<T, TaskStoreError>> + Send + 'a>>;
14
15/// A sanitized durable-task store failure.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
17pub enum TaskStoreError {
18    /// The configured backend could not complete the operation.
19    #[error("task-attribution backend unavailable")]
20    BackendUnavailable,
21    /// Stored data was not a valid task origin.
22    #[error("task-attribution record is invalid")]
23    InvalidRecord,
24}
25
26/// Persists pre-priced, non-identifying attribution under a durable task ID.
27pub trait TaskAttributionStore: Send + Sync {
28    /// Associate a task with its resolved price and fixed origin category.
29    fn insert<'a>(
30        &'a self,
31        tenant_id: &'a str,
32        task_id: &'a str,
33        attribution: TaskAttribution,
34    ) -> TaskStoreFuture<'a, ()>;
35    /// Resolve an association without consuming it.
36    fn get<'a>(
37        &'a self,
38        tenant_id: &'a str,
39        task_id: &'a str,
40    ) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
41    /// Atomically consume and return an association.
42    ///
43    /// A completed durable task can be returned by many later polls. Using this
44    /// operation before recording usage guarantees that only one process can
45    /// account for that task.
46    fn claim<'a>(
47        &'a self,
48        tenant_id: &'a str,
49        task_id: &'a str,
50    ) -> TaskStoreFuture<'a, Option<TaskAttribution>>;
51    /// Remove an association after a terminal task is accounted for.
52    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
70/// Process-local task attribution for an embedded server.
71pub 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    /// Construct an empty task table.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Construct a task table retaining at most `max_tasks` associations.
99    ///
100    /// When full, the oldest association is removed. Zero disables process-local
101    /// task attribution.
102    #[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    /// Number of live task associations.
111    #[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    /// Whether the task table is empty.
121    #[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            // A durable task's origin is immutable. A reused or hostile task ID
159            // must not replace the first captured price attribution.
160            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}