Skip to main content

a2a_rs/adapter/storage/
event_log.rs

1//! In-process [`AsyncEventLog`]: a bounded ring buffer per task.
2
3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use tokio::sync::Mutex;
8
9use crate::domain::A2AError;
10use crate::port::event_log::{AsyncEventLog, Replay};
11use crate::port::streaming_handler::{SeqEvent, UpdateEvent};
12
13/// How many events per task an [`InMemoryEventLog`] keeps by default.
14pub const DEFAULT_CAPACITY: usize = 256;
15
16/// One task's retained tail.
17struct TaskLog {
18    next_id: u64,
19    events: VecDeque<SeqEvent>,
20}
21
22impl TaskLog {
23    fn new(capacity: usize) -> Self {
24        Self {
25            next_id: 0,
26            events: VecDeque::with_capacity(capacity),
27        }
28    }
29
30    fn append(&mut self, event: UpdateEvent, capacity: usize) -> SeqEvent {
31        self.next_id += 1;
32        let seq = SeqEvent::new(self.next_id, event);
33        if self.events.len() == capacity {
34            self.events.pop_front();
35        }
36        self.events.push_back(seq.clone());
37        seq
38    }
39}
40
41/// A task's update log held in this process's memory.
42///
43/// Cloning shares the log, so a clone sees the same events. This is the default
44/// under [`InMemoryStreamingHandler`], and it is what makes resumption work
45/// within one run of a server: a client that reconnects gets the tail it missed,
46/// up to [`capacity`](Self::with_capacity) events per task.
47///
48/// What it cannot do is survive the process. A restart starts every task's ids
49/// again at 1, and a client resuming with an id from before the restart is told
50/// the log cannot cover it ([`Replay::complete`] is false) rather than handed a
51/// tail that means something else. Use a durable log — `SqlxTaskStorage`
52/// implements this port — where resumption has to outlive a restart.
53///
54/// Retained tasks are never evicted: a task that has finished still holds its
55/// ring until [`discard`](AsyncEventLog::discard) is called for it. Bounded per
56/// task, unbounded in task count, which is the other reason a long-running
57/// server wants the durable log.
58///
59/// [`InMemoryStreamingHandler`]: crate::adapter::InMemoryStreamingHandler
60#[derive(Clone)]
61pub struct InMemoryEventLog {
62    tasks: Arc<Mutex<HashMap<String, TaskLog>>>,
63    capacity: usize,
64}
65
66impl InMemoryEventLog {
67    /// A log keeping [`DEFAULT_CAPACITY`] events per task.
68    pub fn new() -> Self {
69        Self::with_capacity(DEFAULT_CAPACITY)
70    }
71
72    /// A log keeping `capacity` events per task.
73    ///
74    /// A capacity of zero would retain nothing and make every resume
75    /// incomplete, so it is raised to one.
76    pub fn with_capacity(capacity: usize) -> Self {
77        Self {
78            tasks: Arc::new(Mutex::new(HashMap::new())),
79            capacity: capacity.max(1),
80        }
81    }
82}
83
84impl Default for InMemoryEventLog {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[async_trait]
91impl AsyncEventLog for InMemoryEventLog {
92    async fn append(&self, task_id: &str, event: UpdateEvent) -> Result<SeqEvent, A2AError> {
93        let mut guard = self.tasks.lock().await;
94        Ok(guard
95            .entry(task_id.to_string())
96            .or_insert_with(|| TaskLog::new(self.capacity))
97            .append(event, self.capacity))
98    }
99
100    async fn replay(&self, task_id: &str, from: u64) -> Result<Replay, A2AError> {
101        let guard = self.tasks.lock().await;
102        let Some(log) = guard.get(task_id) else {
103            return Ok(Replay::bounded_by(None, from, Vec::new()));
104        };
105        let oldest = log.events.front().map(|event| event.id);
106        let events = log
107            .events
108            .iter()
109            .filter(|event| event.id > from)
110            .cloned()
111            .collect();
112        Ok(Replay::bounded_by(oldest, from, events))
113    }
114
115    async fn discard(&self, task_id: &str) -> Result<(), A2AError> {
116        self.tasks.lock().await.remove(task_id);
117        Ok(())
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::domain::{TaskState, TaskStatus, TaskStatusUpdateEvent};
125
126    fn event(state: TaskState) -> UpdateEvent {
127        UpdateEvent::StatusUpdate(TaskStatusUpdateEvent {
128            task_id: "t".to_string(),
129            context_id: "ctx".to_string(),
130            kind: "status-update".to_string(),
131            status: TaskStatus::new(state, None),
132            metadata: None,
133        })
134    }
135
136    #[tokio::test]
137    async fn ids_start_at_one_and_run_per_task() {
138        let log = InMemoryEventLog::new();
139        assert_eq!(
140            log.append("a", event(TaskState::Working)).await.unwrap().id,
141            1
142        );
143        assert_eq!(
144            log.append("a", event(TaskState::Working)).await.unwrap().id,
145            2
146        );
147        assert_eq!(
148            log.append("b", event(TaskState::Working)).await.unwrap().id,
149            1,
150            "a second task counts from its own start"
151        );
152    }
153
154    #[tokio::test]
155    async fn a_covered_gap_replays_only_its_tail() {
156        let log = InMemoryEventLog::new();
157        for _ in 0..5 {
158            log.append("t", event(TaskState::Working)).await.unwrap();
159        }
160
161        let replay = log.replay("t", 3).await.unwrap();
162        assert!(replay.complete);
163        assert_eq!(
164            replay.events.iter().map(|e| e.id).collect::<Vec<_>>(),
165            vec![4, 5]
166        );
167    }
168
169    /// Caught up is not the same as fell behind: a client holding the newest id
170    /// gets nothing back, and that is a complete answer.
171    #[tokio::test]
172    async fn a_caught_up_client_gets_an_empty_complete_replay() {
173        let log = InMemoryEventLog::new();
174        for _ in 0..3 {
175            log.append("t", event(TaskState::Working)).await.unwrap();
176        }
177
178        let replay = log.replay("t", 3).await.unwrap();
179        assert!(replay.complete);
180        assert!(replay.events.is_empty());
181    }
182
183    /// Past the ring's capacity the log holds a fragment of the gap, not its
184    /// remainder, and says so.
185    #[tokio::test]
186    async fn falling_off_the_ring_is_an_incomplete_replay() {
187        let log = InMemoryEventLog::with_capacity(4);
188        for _ in 0..10 {
189            log.append("t", event(TaskState::Working)).await.unwrap();
190        }
191
192        let replay = log.replay("t", 1).await.unwrap();
193        assert!(
194            !replay.complete,
195            "events 2..6 are gone, so this is not the tail the client asked for"
196        );
197        assert_eq!(
198            replay.events.iter().map(|e| e.id).collect::<Vec<_>>(),
199            vec![7, 8, 9, 10]
200        );
201
202        let covered = log.replay("t", 6).await.unwrap();
203        assert!(
204            covered.complete,
205            "id 7 is still held, so the gap is covered"
206        );
207    }
208
209    /// What a restart looks like from the client's side: the ids it holds are
210    /// from a log that no longer exists.
211    #[tokio::test]
212    async fn an_empty_log_covers_only_a_client_that_has_seen_nothing() {
213        let log = InMemoryEventLog::new();
214        assert!(log.replay("t", 0).await.unwrap().complete);
215        assert!(!log.replay("t", 7).await.unwrap().complete);
216    }
217
218    #[tokio::test]
219    async fn discarding_forgets_the_task() {
220        let log = InMemoryEventLog::new();
221        log.append("t", event(TaskState::Working)).await.unwrap();
222        log.discard("t").await.unwrap();
223
224        let replay = log.replay("t", 0).await.unwrap();
225        assert!(replay.events.is_empty());
226        assert_eq!(
227            log.append("t", event(TaskState::Working)).await.unwrap().id,
228            1,
229            "a discarded task counts from the start again"
230        );
231    }
232}