Skip to main content

lc_a2a/
store.rs

1//! Task persistence abstraction (P1-1).
2//!
3//! `A2AServer` talks to tasks exclusively through the [`TaskStore`] trait, so
4//! the in-memory [`InMemoryTaskStore`] shipped here can be swapped for any
5//! backend (database, Redis, file) without touching server logic.
6//!
7//! The trait intentionally returns owned snapshots: every read produces a
8//! fresh [`StoredTask`] copy, so background workers and handlers never share
9//! mutable references across `.await` points.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use tokio::sync::RwLock;
17
18use crate::protocol::{A2ATask, A2ATaskResult, TaskFilter};
19
20/// A task snapshot stored by the server.
21///
22/// Wraps the protocol-visible [`A2ATask`] with server-only bookkeeping: the
23/// terminal `result`/`error` payloads and `created_at`/`updated_at` timestamps
24/// used for TTL expiry and LRU eviction (P1-2).
25#[derive(Debug, Clone)]
26pub struct StoredTask {
27    /// The protocol-visible task.
28    pub task: A2ATask,
29    /// Result of the task (present when the task completed).
30    pub result: Option<A2ATaskResult>,
31    /// Error message (present when the task failed).
32    pub error: Option<String>,
33    /// W3C-style trace id carried on the request that created this task (P1-5).
34    ///
35    /// Server-only bookkeeping so a distributed trace can be correlated with a
36    /// task after creation; the protocol-visible task itself does not expose it.
37    pub trace_id: Option<String>,
38    /// When the task was created.
39    pub created_at: Instant,
40    /// When the task was last modified.
41    pub updated_at: Instant,
42}
43
44impl StoredTask {
45    /// Wrap a task into a fresh stored snapshot.
46    pub fn new(task: A2ATask) -> Self {
47        let now = Instant::now();
48        Self {
49            task,
50            result: None,
51            error: None,
52            trace_id: None,
53            created_at: now,
54            updated_at: now,
55        }
56    }
57
58    /// Attach the trace id that created this task (P1-5).
59    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
60        self.trace_id = Some(trace_id.into());
61        self
62    }
63
64    /// Mark the task as modified (bumps `updated_at`).
65    pub fn touch(&mut self) {
66        self.updated_at = Instant::now();
67    }
68
69    /// Age of this snapshot, measured from its last modification.
70    pub fn age(&self) -> Duration {
71        self.updated_at.elapsed()
72    }
73}
74
75/// Error returned by a [`TaskStore`] backend.
76#[derive(Debug, thiserror::Error)]
77#[non_exhaustive]
78pub enum StoreError {
79    /// The backend is temporarily unavailable (e.g. connection loss).
80    #[error("task store unavailable: {0}")]
81    Unavailable(String),
82    /// The store has reached its configured capacity.
83    #[error("task store capacity exceeded: {0}")]
84    CapacityExceeded(String),
85}
86
87/// Task persistence backend (P1-1).
88///
89/// The four operations mirror the A2A `tasks/*` surface: create/update
90/// ([`upsert`](TaskStore::upsert)), read ([`get`](TaskStore::get)),
91/// enumerate ([`list`](TaskStore::list)) and remove
92/// ([`delete`](TaskStore::delete)). Implementations must be cheap under
93/// concurrent access; the server does not hold the returned snapshot across
94/// `.await` boundaries.
95#[async_trait]
96pub trait TaskStore: Send + Sync {
97    /// Insert a new task or replace an existing one.
98    async fn upsert(&self, stored: StoredTask) -> Result<(), StoreError>;
99
100    /// Fetch a task snapshot by id, or `None` if absent.
101    async fn get(&self, task_id: &str) -> Result<Option<StoredTask>, StoreError>;
102
103    /// List task snapshots matching `filter`, ordered by creation (oldest first).
104    async fn list(&self, filter: &TaskFilter) -> Result<Vec<StoredTask>, StoreError>;
105
106    /// Delete a task by id. Returns `true` if a task was actually removed.
107    async fn delete(&self, task_id: &str) -> Result<bool, StoreError>;
108
109    /// Atomically replace the stored task for `task_id` when the currently
110    /// stored status may transition to the update's status (per
111    /// `TaskStatus::can_transition_to`). Returns `true` when the update was
112    /// applied.
113    ///
114    /// 0.22.0 audit fix: this closes the check-then-act window where a
115    /// handler read a task, validated its status, and wrote it back — a
116    /// concurrent writer (e.g. `tasks/cancel` racing the chain completing)
117    /// could overwrite the other's terminal state in between. The default
118    /// implementation is a racy `get` + `upsert` fallback; backends that can
119    /// should override it with a truly atomic operation.
120    async fn compare_and_update(
121        &self,
122        task_id: &str,
123        update: StoredTask,
124    ) -> Result<bool, StoreError> {
125        match self.get(task_id).await? {
126            Some(current) if current.task.status.can_transition_to(&update.task.status) => {
127                self.upsert(update).await?;
128                Ok(true)
129            }
130            _ => Ok(false),
131        }
132    }
133}
134
135/// Default maximum number of tasks stored before LRU eviction.
136pub const DEFAULT_MAX_TASKS: usize = 10_000;
137
138/// In-memory [`TaskStore`] backed by a `RwLock<HashMap>`.
139///
140/// When at capacity and a *new* task id is inserted, the least recently
141/// updated task is evicted (LRU). Re-inserting an existing id never evicts.
142/// This is the default backend used by `A2AServer`.
143#[derive(Debug, Clone)]
144pub struct InMemoryTaskStore {
145    inner: Arc<RwLock<HashMap<String, StoredTask>>>,
146    max_tasks: usize,
147}
148
149impl InMemoryTaskStore {
150    /// Create a store with the default capacity ([`DEFAULT_MAX_TASKS`]).
151    pub fn new() -> Self {
152        Self::with_max_tasks(DEFAULT_MAX_TASKS)
153    }
154
155    /// Create a store with an explicit capacity cap.
156    pub fn with_max_tasks(max_tasks: usize) -> Self {
157        Self {
158            inner: Arc::new(RwLock::new(HashMap::new())),
159            max_tasks,
160        }
161    }
162}
163
164impl Default for InMemoryTaskStore {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[async_trait]
171impl TaskStore for InMemoryTaskStore {
172    async fn upsert(&self, stored: StoredTask) -> Result<(), StoreError> {
173        // Atomic section: capacity check, LRU eviction and insert share one
174        // write lock, so concurrent upserts cannot exceed `max_tasks` (the
175        // previous check-then-act released the lock between the steps).
176        let mut guard = self.inner.write().await;
177        let inserting_new = !guard.contains_key(&stored.task.id);
178        if inserting_new && self.max_tasks > 0 && guard.len() >= self.max_tasks {
179            // Oldest-by-updated wins the LRU slot.
180            let oldest_key = guard
181                .iter()
182                .min_by_key(|(_, t)| t.updated_at)
183                .map(|(k, _)| k.clone());
184            if let Some(key) = oldest_key {
185                guard.remove(&key);
186            }
187        }
188        guard.insert(stored.task.id.clone(), stored);
189        Ok(())
190    }
191
192    async fn get(&self, task_id: &str) -> Result<Option<StoredTask>, StoreError> {
193        Ok(self.inner.read().await.get(task_id).cloned())
194    }
195
196    async fn list(&self, filter: &TaskFilter) -> Result<Vec<StoredTask>, StoreError> {
197        let guard = self.inner.read().await;
198        let mut out: Vec<StoredTask> = guard
199            .values()
200            .filter(|t| filter.matches(&t.task))
201            .cloned()
202            .collect();
203        // Deterministic order: oldest created first.
204        out.sort_by_key(|t| t.created_at);
205        Ok(out)
206    }
207
208    async fn delete(&self, task_id: &str) -> Result<bool, StoreError> {
209        Ok(self.inner.write().await.remove(task_id).is_some())
210    }
211
212    async fn compare_and_update(
213        &self,
214        task_id: &str,
215        update: StoredTask,
216    ) -> Result<bool, StoreError> {
217        // Single write lock over the read-validate-write sequence: a
218        // concurrent writer (e.g. `tasks/cancel` vs. the chain completing)
219        // can no longer flip the status between our check and our write
220        // (0.22.0 audit fix).
221        let mut guard = self.inner.write().await;
222        match guard.get(task_id) {
223            Some(current) if current.task.status.can_transition_to(&update.task.status) => {
224                guard.insert(task_id.to_string(), update);
225                Ok(true)
226            }
227            _ => Ok(false),
228        }
229    }
230}
231
232/// Shared convenience: create a fresh in-memory store wrapped for trait use.
233pub fn in_memory_store() -> Arc<dyn TaskStore> {
234    Arc::new(InMemoryTaskStore::new())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::protocol::{A2AMessage, TaskStatus};
241
242    fn sample_task(id: &str, status: TaskStatus) -> A2ATask {
243        A2ATask::new(id, A2AMessage::user("hi")).with_status(status)
244    }
245
246    #[tokio::test]
247    async fn upsert_get_roundtrip() {
248        let store = InMemoryTaskStore::new();
249        let mut stored = StoredTask::new(sample_task("t1", TaskStatus::Working));
250        stored.result = Some(A2ATaskResult::new("done"));
251        store.upsert(stored).await.unwrap();
252
253        let got = store.get("t1").await.unwrap().expect("task present");
254        assert_eq!(got.task.id, "t1");
255        assert_eq!(got.result.as_ref().unwrap().output, "done");
256        assert_eq!(got.task.status, TaskStatus::Working);
257        assert_eq!(got.created_at, got.updated_at);
258    }
259
260    #[tokio::test]
261    async fn upsert_updates_existing_in_place() {
262        let store = InMemoryTaskStore::new();
263        store
264            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Submitted)))
265            .await
266            .unwrap();
267        store
268            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Completed)))
269            .await
270            .unwrap();
271
272        let got = store.get("t1").await.unwrap().unwrap();
273        assert_eq!(got.task.status, TaskStatus::Completed);
274    }
275
276    #[tokio::test]
277    async fn get_missing_returns_none() {
278        let store = InMemoryTaskStore::new();
279        assert!(store.get("nope").await.unwrap().is_none());
280    }
281
282    #[tokio::test]
283    async fn list_filters_by_owner_and_status() {
284        let store = InMemoryTaskStore::new();
285        store
286            .upsert(StoredTask::new(
287                sample_task("t1", TaskStatus::Working).with_owner("a"),
288            ))
289            .await
290            .unwrap();
291        store
292            .upsert(StoredTask::new(
293                sample_task("t2", TaskStatus::Completed).with_owner("a"),
294            ))
295            .await
296            .unwrap();
297        store
298            .upsert(StoredTask::new(
299                sample_task("t3", TaskStatus::Working).with_owner("b"),
300            ))
301            .await
302            .unwrap();
303
304        let all = store.list(&TaskFilter::new()).await.unwrap();
305        assert_eq!(all.len(), 3);
306
307        let only_a = store
308            .list(&TaskFilter::new().with_owner("a"))
309            .await
310            .unwrap();
311        assert_eq!(only_a.len(), 2);
312
313        let a_working = store
314            .list(
315                &TaskFilter::new()
316                    .with_owner("a")
317                    .with_statuses(vec![TaskStatus::Working]),
318            )
319            .await
320            .unwrap();
321        assert_eq!(a_working.len(), 1);
322        assert_eq!(a_working[0].task.id, "t1");
323    }
324
325    #[tokio::test]
326    async fn delete_removes_and_reports() {
327        let store = InMemoryTaskStore::new();
328        store
329            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Submitted)))
330            .await
331            .unwrap();
332
333        assert!(store.delete("t1").await.unwrap());
334        assert!(!store.delete("t1").await.unwrap());
335        assert!(store.get("t1").await.unwrap().is_none());
336    }
337
338    #[tokio::test]
339    async fn evicts_oldest_when_full() {
340        let store = InMemoryTaskStore::with_max_tasks(2);
341        store
342            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Submitted)))
343            .await
344            .unwrap();
345        store
346            .upsert(StoredTask::new(sample_task("t2", TaskStatus::Submitted)))
347            .await
348            .unwrap();
349        // t3 is new → evicts oldest (t1).
350        store
351            .upsert(StoredTask::new(sample_task("t3", TaskStatus::Submitted)))
352            .await
353            .unwrap();
354
355        assert!(store.get("t1").await.unwrap().is_none());
356        assert!(store.get("t2").await.unwrap().is_some());
357        assert!(store.get("t3").await.unwrap().is_some());
358    }
359
360    #[tokio::test]
361    async fn touch_bumps_updated_at() {
362        let store = InMemoryTaskStore::new();
363        store
364            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Submitted)))
365            .await
366            .unwrap();
367        let mut stored = store.get("t1").await.unwrap().unwrap();
368        stored.touch();
369        assert!(stored.updated_at >= stored.created_at);
370    }
371
372    #[tokio::test]
373    async fn store_is_clone_shareable() {
374        let store = InMemoryTaskStore::new();
375        let clone = store.clone();
376        store
377            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Submitted)))
378            .await
379            .unwrap();
380        assert!(clone.get("t1").await.unwrap().is_some());
381    }
382
383    #[tokio::test]
384    async fn compare_and_update_rejects_stale_status() {
385        // 0.22.0 audit fix: a writer holding a stale snapshot must not
386        // clobber a terminal state written concurrently.
387        let store = InMemoryTaskStore::new();
388        store
389            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Working)))
390            .await
391            .unwrap();
392        let mut stale = store.get("t1").await.unwrap().unwrap();
393
394        // Concurrent writer flips the task to Cancelled first.
395        let mut cancelled = store.get("t1").await.unwrap().unwrap();
396        cancelled.task.status = TaskStatus::Cancelled;
397        cancelled.touch();
398        store.upsert(cancelled).await.unwrap();
399
400        stale.task.status = TaskStatus::Completed;
401        stale.touch();
402        assert!(!store.compare_and_update("t1", stale).await.unwrap());
403        assert_eq!(
404            store.get("t1").await.unwrap().unwrap().task.status,
405            TaskStatus::Cancelled
406        );
407    }
408
409    #[tokio::test]
410    async fn compare_and_update_applies_valid_transition() {
411        let store = InMemoryTaskStore::new();
412        store
413            .upsert(StoredTask::new(sample_task("t1", TaskStatus::Working)))
414            .await
415            .unwrap();
416        let mut done = store.get("t1").await.unwrap().unwrap();
417        done.task.status = TaskStatus::Completed;
418        done.touch();
419        assert!(store.compare_and_update("t1", done).await.unwrap());
420        assert_eq!(
421            store.get("t1").await.unwrap().unwrap().task.status,
422            TaskStatus::Completed
423        );
424    }
425
426    #[tokio::test]
427    async fn compare_and_update_missing_task_is_noop() {
428        let store = InMemoryTaskStore::new();
429        let update = StoredTask::new(sample_task("ghost", TaskStatus::Completed));
430        assert!(!store.compare_and_update("ghost", update).await.unwrap());
431        assert!(store.get("ghost").await.unwrap().is_none());
432    }
433}