use std::time::Instant;
use a2a_protocol_types::task::TaskId;
use super::{StoreData, TaskStoreConfig};
use super::InMemoryTaskStore;
impl InMemoryTaskStore {
pub async fn run_eviction(&self) {
let mut store = self.data.write().await;
Self::evict(&mut store, &self.config);
}
pub(super) fn should_evict(&self, store_len: usize) -> bool {
let count = self
.write_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let over_capacity = self.config.max_capacity.is_some_and(|max| store_len > max);
let interval_hit = self.config.eviction_interval > 0
&& count.is_multiple_of(self.config.eviction_interval);
interval_hit || over_capacity
}
pub(super) async fn maybe_evict(&self) {
if self
.eviction_in_progress
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::AcqRel,
std::sync::atomic::Ordering::Relaxed,
)
.is_err()
{
return;
}
let mut store = self.data.write().await;
Self::evict(&mut store, &self.config);
drop(store);
self.eviction_in_progress
.store(false, std::sync::atomic::Ordering::Release);
}
pub(super) fn evict(store: &mut StoreData, config: &TaskStoreConfig) {
let now = Instant::now();
if let Some(ttl) = config.task_ttl {
let expired: Vec<TaskId> = store
.entries
.iter()
.filter(|(_, entry)| {
entry.task.status.state.is_terminal()
&& now.duration_since(entry.last_updated) >= ttl
})
.map(|(id, _)| id.clone())
.collect();
for id in expired {
store.remove(&id);
}
}
if let Some(max) = config.max_capacity {
let overflow = store.len().saturating_sub(max);
if overflow != 0 {
let mut terminal: Vec<(TaskId, Instant)> = store
.entries
.iter()
.filter(|(_, e)| e.task.status.state.is_terminal())
.map(|(id, e)| (id.clone(), e.last_updated))
.collect();
terminal.sort_by_key(|(_, t)| *t);
for (id, _) in terminal.into_iter().take(overflow) {
store.remove(&id);
}
let remaining = store.len().saturating_sub(max);
if remaining != 0 {
let mut non_terminal: Vec<(TaskId, Instant)> = store
.entries
.iter()
.map(|(id, e)| (id.clone(), e.last_updated))
.collect();
non_terminal.sort_by_key(|(_, t)| *t);
for (id, _) in non_terminal.into_iter().take(remaining) {
store.remove(&id);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_protocol_types::task::{ContextId, Task, TaskState, TaskStatus};
use std::time::Duration;
fn task(id: &str, state: TaskState) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new("ctx"),
status: TaskStatus::new(state),
history: None,
artifacts: None,
metadata: None,
}
}
fn store_of(states: &[TaskState]) -> StoreData {
let mut data = StoreData::with_capacity(states.len());
let base = Instant::now();
for (i, state) in states.iter().enumerate() {
let id = TaskId::new(format!("t{i}"));
let age = Duration::from_secs((states.len() - i) as u64);
let when = base.checked_sub(age).unwrap_or(base);
data.insert(id.clone(), task(&format!("t{i}"), *state), when);
}
data
}
fn config(
max_capacity: Option<usize>,
ttl: Option<Duration>,
interval: u64,
) -> TaskStoreConfig {
TaskStoreConfig {
max_capacity,
task_ttl: ttl,
eviction_interval: interval,
..TaskStoreConfig::default()
}
}
fn ids(data: &StoreData) -> Vec<String> {
let mut v: Vec<String> = data.entries.keys().map(|k| k.0.clone()).collect();
v.sort();
v
}
#[test]
fn should_evict_is_false_when_nothing_triggers_a_sweep() {
let store = InMemoryTaskStore::with_config(config(None, None, 0));
assert!(!store.should_evict(0));
}
#[test]
fn should_evict_fires_on_the_interval() {
let store = InMemoryTaskStore::with_config(config(None, None, 2));
assert!(store.should_evict(0), "write 0 is a multiple of 2");
}
#[test]
fn should_evict_treats_capacity_as_a_strict_overflow() {
let store = InMemoryTaskStore::with_config(config(Some(3), None, 100));
assert!(
store.should_evict(0),
"write 0 is a multiple of any interval"
);
assert!(!store.should_evict(3), "exactly at capacity is not over it");
assert!(store.should_evict(4), "one past capacity triggers a sweep");
}
#[test]
fn evict_removes_exactly_the_overflow_of_terminal_tasks() {
let mut data = store_of(&[TaskState::Completed; 5]);
InMemoryTaskStore::evict(&mut data, &config(Some(2), None, 0));
assert_eq!(data.len(), 2, "store must be brought down to the cap");
assert_eq!(
ids(&data),
vec!["t3".to_string(), "t4".to_string()],
"the three oldest terminal tasks are the ones evicted"
);
}
#[test]
fn evict_falls_back_to_non_terminal_tasks_to_enforce_the_cap() {
let mut data = store_of(&[TaskState::Working; 5]);
InMemoryTaskStore::evict(&mut data, &config(Some(2), None, 0));
assert_eq!(
data.len(),
2,
"the cap is enforced even with no terminal tasks"
);
assert_eq!(ids(&data), vec!["t3".to_string(), "t4".to_string()]);
}
#[test]
fn evict_prefers_terminal_tasks_over_an_older_in_flight_one() {
let mut data = store_of(&[
TaskState::Working,
TaskState::Completed,
TaskState::Completed,
TaskState::Completed,
TaskState::Completed,
]);
InMemoryTaskStore::evict(&mut data, &config(Some(2), None, 0));
assert_eq!(
ids(&data),
vec!["t0".to_string(), "t4".to_string()],
"the oldest in-flight task is spared; the three oldest completed go"
);
}
#[test]
fn evict_expires_terminal_tasks_only() {
let mut data = store_of(&[TaskState::Completed, TaskState::Working]);
InMemoryTaskStore::evict(&mut data, &config(None, Some(Duration::from_millis(1)), 0));
assert_eq!(
ids(&data),
vec!["t1".to_string()],
"the terminal task expires; the working one is kept regardless of age"
);
}
}