mod eviction;
use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use std::pin::Pin;
use std::time::Instant;
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::params::ListTasksParams;
use a2a_protocol_types::responses::TaskListResponse;
use a2a_protocol_types::task::{Task, TaskId};
use tokio::sync::RwLock;
use super::{TaskStore, TaskStoreConfig};
pub(super) type OrderKey = (i64, u64);
#[derive(Debug, Clone)]
pub(super) struct TaskEntry {
pub(super) task: Task,
pub(super) last_updated: Instant,
pub(super) order_key: OrderKey,
}
#[derive(Debug)]
pub(super) struct StoreData {
pub(super) entries: HashMap<TaskId, TaskEntry>,
pub(super) order_index: BTreeMap<OrderKey, TaskId>,
pub(super) context_index: HashMap<String, BTreeMap<OrderKey, TaskId>>,
pub(super) next_seq: u64,
}
impl StoreData {
fn with_capacity(capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(capacity),
order_index: BTreeMap::new(),
context_index: HashMap::new(),
next_seq: 0,
}
}
#[inline]
pub(super) fn len(&self) -> usize {
self.entries.len()
}
pub(super) fn insert(&mut self, task_id: TaskId, task: Task, last_updated: Instant) {
let seq = self.next_seq;
self.next_seq += 1;
let millis = task
.status
.timestamp
.as_deref()
.and_then(a2a_protocol_types::parse_iso8601_to_unix_millis)
.unwrap_or_else(now_unix_millis);
let key: OrderKey = (millis, seq);
if let Some(old) = self.entries.get(&task_id) {
let old_key = old.order_key;
let old_ctx = old.task.context_id.0.clone();
self.order_index.remove(&old_key);
if let Some(map) = self.context_index.get_mut(&old_ctx) {
map.remove(&old_key);
if map.is_empty() {
self.context_index.remove(&old_ctx);
}
}
}
self.order_index.insert(key, task_id.clone());
self.context_index
.entry(task.context_id.0.clone())
.or_default()
.insert(key, task_id.clone());
self.entries.insert(
task_id,
TaskEntry {
task,
last_updated,
order_key: key,
},
);
}
pub(super) fn remove(&mut self, id: &TaskId) -> Option<TaskEntry> {
if let Some(entry) = self.entries.remove(id) {
self.order_index.remove(&entry.order_key);
let ctx = &entry.task.context_id.0;
if let Some(map) = self.context_index.get_mut(ctx) {
map.remove(&entry.order_key);
if map.is_empty() {
self.context_index.remove(ctx);
}
}
Some(entry)
} else {
None
}
}
}
#[derive(Debug)]
pub struct InMemoryTaskStore {
pub(super) data: RwLock<StoreData>,
pub(super) config: TaskStoreConfig,
pub(super) write_count: std::sync::atomic::AtomicU64,
pub(super) eviction_in_progress: std::sync::atomic::AtomicBool,
}
impl Default for InMemoryTaskStore {
fn default() -> Self {
Self::new()
}
}
const DEFAULT_INITIAL_CAPACITY: usize = 256;
#[allow(clippy::cast_possible_truncation)]
fn now_unix_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn encode_order_key((millis, seq): OrderKey) -> String {
format!("{millis}:{seq}")
}
fn decode_order_key(token: &str) -> Option<OrderKey> {
let (millis, seq) = token.split_once(':')?;
Some((millis.parse().ok()?, seq.parse().ok()?))
}
impl InMemoryTaskStore {
#[must_use]
pub fn new() -> Self {
let config = TaskStoreConfig::default();
let capacity = config.max_capacity.unwrap_or(DEFAULT_INITIAL_CAPACITY);
Self {
data: RwLock::new(StoreData::with_capacity(capacity)),
config,
write_count: std::sync::atomic::AtomicU64::new(0),
eviction_in_progress: std::sync::atomic::AtomicBool::new(false),
}
}
#[must_use]
pub fn with_config(config: TaskStoreConfig) -> Self {
let capacity = config.max_capacity.unwrap_or(DEFAULT_INITIAL_CAPACITY);
Self {
data: RwLock::new(StoreData::with_capacity(capacity)),
config,
write_count: std::sync::atomic::AtomicU64::new(0),
eviction_in_progress: std::sync::atomic::AtomicBool::new(false),
}
}
}
#[allow(clippy::manual_async_fn)]
impl TaskStore for InMemoryTaskStore {
fn save<'a>(
&'a self,
task: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let task = task.clone();
trace_debug!(task_id = %task.id, state = ?task.status.state, "saving task");
let needs_eviction = {
let mut store = self.data.write().await;
store.insert(task.id.clone(), task, Instant::now());
let len = store.len();
drop(store);
self.should_evict(len)
};
if needs_eviction {
self.maybe_evict().await;
}
Ok(())
})
}
fn get<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
Box::pin(async move {
trace_debug!(task_id = %id, "fetching task");
let store = self.data.read().await;
let result = store.entries.get(id).map(|e| e.task.clone());
drop(store);
Ok(result)
})
}
#[allow(clippy::too_many_lines, clippy::option_if_let_else)]
fn list<'a>(
&'a self,
params: &'a ListTasksParams,
) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
Box::pin(async move {
let store = self.data.read().await;
let page_size = match params.page_size {
Some(0) | None => 50_usize,
Some(n) => (n.min(self.config.max_page_size)) as usize,
};
let cursor_key: Option<OrderKey> = match params.page_token.as_deref() {
None => None,
Some(tok) => match decode_order_key(tok) {
Some(key) => Some(key),
None => {
return Ok(TaskListResponse::new(Vec::new()));
}
},
};
let lower = match params.status_timestamp_after.as_deref() {
None => std::ops::Bound::Unbounded,
Some(ts) => match a2a_protocol_types::parse_iso8601_to_unix_millis(ts) {
Some(millis) => std::ops::Bound::Excluded((millis, u64::MAX)),
None => {
return Ok(TaskListResponse::new(Vec::new()));
}
},
};
let take = page_size + 1; let collect_from = |index: &BTreeMap<OrderKey, TaskId>| -> Vec<(OrderKey, Task)> {
let upper = match cursor_key {
Some(c) => std::ops::Bound::Excluded(c),
None => std::ops::Bound::Unbounded,
};
index
.range((lower, upper))
.rev()
.filter_map(|(key, id)| {
let entry = store.entries.get(id)?;
if let Some(ref status) = params.status {
if entry.task.status.state != *status {
return None;
}
}
Some((*key, entry.task.clone()))
})
.take(take)
.collect()
};
let collected: Vec<(OrderKey, Task)> = if let Some(ref ctx) = params.context_id {
store
.context_index
.get(ctx.as_str())
.map_or_else(Vec::new, collect_from)
} else {
collect_from(&store.order_index)
};
#[allow(clippy::cast_possible_truncation)]
let total_size = store.len() as u32;
drop(store);
let has_next_page = crate::store::pagination::has_next_page(collected.len(), page_size);
let mut collected = collected;
collected.truncate(page_size);
let next_page_token = if has_next_page {
collected
.last()
.map_or_else(String::new, |(key, _)| encode_order_key(*key))
} else {
String::new()
};
let tasks: Vec<Task> = collected.into_iter().map(|(_, t)| t).collect();
let mut response = TaskListResponse::new(tasks);
response.next_page_token = next_page_token;
#[allow(clippy::cast_possible_truncation)]
{
response.page_size = page_size as u32;
}
response.total_size = total_size;
Ok(response)
})
}
fn insert_if_absent<'a>(
&'a self,
task: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
Box::pin(async move {
let task = task.clone();
let (inserted, needs_eviction) = {
let mut store = self.data.write().await;
if store.entries.contains_key(&task.id) {
return Ok(false);
}
store.insert(task.id.clone(), task, Instant::now());
let len = store.len();
drop(store);
(true, self.should_evict(len))
};
if needs_eviction {
self.maybe_evict().await;
}
Ok(inserted)
})
}
fn delete<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let mut store = self.data.write().await;
store.remove(id);
drop(store);
Ok(())
})
}
fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
Box::pin(async move {
let store = self.data.read().await;
Ok(store.len() as u64)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
use std::time::Duration;
fn make_task(id: &str, state: TaskState) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new("ctx-default"),
status: TaskStatus::new(state),
history: None,
artifacts: None,
metadata: None,
}
}
fn make_task_with_ctx(id: &str, ctx: &str, state: TaskState) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new(ctx),
status: TaskStatus::new(state),
history: None,
artifacts: None,
metadata: None,
}
}
#[tokio::test]
async fn save_and_get_returns_task() {
let store = InMemoryTaskStore::new();
let task = make_task("t1", TaskState::Submitted);
store.save(&task).await.unwrap();
let fetched = store.get(&TaskId::new("t1")).await.unwrap();
assert!(fetched.is_some(), "saved task should be retrievable");
assert_eq!(fetched.unwrap().id, task.id);
}
#[tokio::test]
async fn get_nonexistent_returns_none() {
let store = InMemoryTaskStore::new();
let result = store.get(&TaskId::new("no-such-task")).await.unwrap();
assert!(result.is_none(), "missing task should return None");
}
#[tokio::test]
async fn save_overwrites_existing_task() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t1", TaskState::Working))
.await
.unwrap();
let fetched = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
fetched.status.state,
TaskState::Working,
"save should overwrite existing task"
);
}
#[tokio::test]
async fn delete_removes_task() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
store.delete(&TaskId::new("t1")).await.unwrap();
let result = store.get(&TaskId::new("t1")).await.unwrap();
assert!(result.is_none(), "deleted task should no longer exist");
}
#[tokio::test]
async fn delete_nonexistent_is_ok() {
let store = InMemoryTaskStore::new();
store.delete(&TaskId::new("ghost")).await.unwrap();
}
#[tokio::test]
async fn insert_if_absent_inserts_new_task() {
let store = InMemoryTaskStore::new();
let inserted = store
.insert_if_absent(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
assert!(inserted, "first insert should succeed");
let fetched = store.get(&TaskId::new("t1")).await.unwrap();
assert!(fetched.is_some());
}
#[tokio::test]
async fn insert_if_absent_rejects_duplicate() {
let store = InMemoryTaskStore::new();
store
.insert_if_absent(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
let second = store
.insert_if_absent(&make_task("t1", TaskState::Working))
.await
.unwrap();
assert!(!second, "duplicate insert should return false");
let fetched = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
fetched.status.state,
TaskState::Submitted,
"original task should not be overwritten by insert_if_absent"
);
}
#[tokio::test]
async fn count_empty_store() {
let store = InMemoryTaskStore::new();
assert_eq!(store.count().await.unwrap(), 0);
}
#[tokio::test]
async fn count_reflects_saves_and_deletes() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", TaskState::Working))
.await
.unwrap();
assert_eq!(store.count().await.unwrap(), 2);
store.delete(&TaskId::new("t1")).await.unwrap();
assert_eq!(store.count().await.unwrap(), 1);
}
#[tokio::test]
async fn list_empty_store_returns_empty() {
let store = InMemoryTaskStore::new();
let params = ListTasksParams::default();
let response = store.list(¶ms).await.unwrap();
assert!(response.tasks.is_empty());
assert!(response.next_page_token.is_empty());
}
#[tokio::test]
async fn list_returns_all_tasks_most_recently_updated_first() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("c", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("a", TaskState::Working))
.await
.unwrap();
store
.save(&make_task("b", TaskState::Completed))
.await
.unwrap();
let params = ListTasksParams::default();
let response = store.list(¶ms).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["b", "a", "c"],
"tasks should be ordered most-recently-updated first"
);
}
#[tokio::test]
async fn list_reorders_on_update() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t3", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t1", TaskState::Working))
.await
.unwrap();
let response = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["t1", "t3", "t2"],
"an updated task must move to the front of the update order"
);
}
#[tokio::test]
async fn list_pagination_is_stable_across_pages() {
let store = InMemoryTaskStore::new();
for i in 0..10 {
store
.save(&make_task(&format!("t{i:02}"), TaskState::Submitted))
.await
.unwrap();
}
let mut seen: Vec<String> = Vec::new();
let mut token: Option<String> = None;
loop {
let params = ListTasksParams {
page_size: Some(3),
page_token: token.clone(),
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
for t in &page.tasks {
seen.push(t.id.0.clone());
}
if page.next_page_token.is_empty() {
break;
}
token = Some(page.next_page_token);
}
let expected: Vec<String> = (0..10).rev().map(|i| format!("t{i:02}")).collect();
assert_eq!(
seen, expected,
"cursor walk must yield every task once in update-order"
);
}
#[tokio::test]
async fn list_same_instant_updates_have_stable_order() {
let store = InMemoryTaskStore::new();
for i in 0..100 {
store
.save(&make_task(&format!("t{i:03}"), TaskState::Submitted))
.await
.unwrap();
}
let mut seen = std::collections::HashSet::new();
let mut token: Option<String> = None;
let mut last_key: Option<super::OrderKey> = None;
loop {
let params = ListTasksParams {
page_size: Some(7),
page_token: token.clone(),
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
for t in &page.tasks {
assert!(seen.insert(t.id.0.clone()), "task {} seen twice", t.id.0);
}
if page.next_page_token.is_empty() {
break;
}
let tok_key = super::decode_order_key(&page.next_page_token)
.expect("cursor must be a valid millis:seq order key");
if let Some(prev) = last_key {
assert!(tok_key < prev, "cursor must strictly decrease");
}
last_key = Some(tok_key);
token = Some(page.next_page_token);
}
assert_eq!(seen.len(), 100, "every task must be visited exactly once");
}
fn make_task_with_ts(id: &str, state: TaskState, ts: &str) -> Task {
let mut task = make_task(id, state);
task.status = TaskStatus {
state,
message: None,
timestamp: Some(ts.to_owned()),
};
task
}
#[tokio::test]
async fn list_orders_by_status_timestamp_not_write_order() {
let store = InMemoryTaskStore::new();
store
.save(&make_task_with_ts(
"middle",
TaskState::Working,
"2026-01-02T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"newest",
TaskState::Working,
"2026-01-03T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"oldest",
TaskState::Working,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
let page = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = page.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newest", "middle", "oldest"],
"list must sort by status timestamp descending"
);
}
#[tokio::test]
async fn list_resave_without_status_change_keeps_position() {
let store = InMemoryTaskStore::new();
store
.save(&make_task_with_ts(
"older",
TaskState::Working,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"newer",
TaskState::Working,
"2026-01-02T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"older",
TaskState::Working,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
let page = store.list(&ListTasksParams::default()).await.unwrap();
let ids: Vec<&str> = page.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newer", "older"],
"a status-preserving re-save must not reorder the list"
);
}
#[tokio::test]
async fn list_filters_by_status_timestamp_after() {
let store = InMemoryTaskStore::new();
store
.save(&make_task_with_ts(
"old",
TaskState::Completed,
"2026-01-01T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"boundary",
TaskState::Working,
"2026-01-02T00:00:00.000Z",
))
.await
.unwrap();
store
.save(&make_task_with_ts(
"new",
TaskState::Working,
"2026-01-03T00:00:00.000Z",
))
.await
.unwrap();
let params = ListTasksParams {
status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
let ids: Vec<&str> = page.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["new"],
"filter must be strictly-after (boundary excluded)"
);
let params = ListTasksParams {
context_id: Some("ctx-default".into()),
status_timestamp_after: Some("2025-12-31T00:00:00.000Z".into()),
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
assert_eq!(page.tasks.len(), 3, "all three are after 2025-12-31");
}
#[tokio::test]
async fn list_status_timestamp_after_with_pagination() {
let store = InMemoryTaskStore::new();
for i in 0..10 {
store
.save(&make_task_with_ts(
&format!("t{i}"),
TaskState::Working,
&format!("2026-01-01T00:00:{i:02}.000Z"),
))
.await
.unwrap();
}
let mut seen = Vec::new();
let mut token: Option<String> = None;
loop {
let params = ListTasksParams {
status_timestamp_after: Some("2026-01-01T00:00:04.000Z".into()),
page_size: Some(2),
page_token: token,
..Default::default()
};
let page = store.list(¶ms).await.unwrap();
seen.extend(page.tasks.iter().map(|t| t.id.0.clone()));
if page.next_page_token.is_empty() {
break;
}
token = Some(page.next_page_token);
}
assert_eq!(
seen,
vec!["t9", "t8", "t7", "t6", "t5"],
"filtered pagination must visit exactly the strictly-after tasks in order"
);
}
#[tokio::test]
async fn list_filters_by_context_id() {
let store = InMemoryTaskStore::new();
store
.save(&make_task_with_ctx("t1", "ctx-a", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task_with_ctx("t2", "ctx-b", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task_with_ctx("t3", "ctx-a", TaskState::Working))
.await
.unwrap();
let params = ListTasksParams {
context_id: Some("ctx-a".to_string()),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(response.tasks.len(), 2);
assert!(response.tasks.iter().all(|t| t.context_id.0 == "ctx-a"));
}
#[tokio::test]
async fn list_filters_by_status() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", TaskState::Working))
.await
.unwrap();
store
.save(&make_task("t3", TaskState::Submitted))
.await
.unwrap();
let params = ListTasksParams {
status: Some(TaskState::Submitted),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(response.tasks.len(), 2);
}
#[tokio::test]
async fn list_pagination_page_size() {
let store = InMemoryTaskStore::new();
for i in 0..5 {
store
.save(&make_task(&format!("t{i:02}"), TaskState::Submitted))
.await
.unwrap();
}
let params = ListTasksParams {
page_size: Some(2),
..Default::default()
};
let page1 = store.list(¶ms).await.unwrap();
assert_eq!(page1.tasks.len(), 2, "first page should have 2 tasks");
assert!(
!page1.next_page_token.is_empty(),
"should have next_page_token when more results exist"
);
let params2 = ListTasksParams {
page_size: Some(2),
page_token: Some(page1.next_page_token),
..Default::default()
};
let page2 = store.list(¶ms2).await.unwrap();
assert_eq!(page2.tasks.len(), 2, "second page should have 2 tasks");
let params3 = ListTasksParams {
page_size: Some(2),
page_token: Some(page2.next_page_token),
..Default::default()
};
let page3 = store.list(¶ms3).await.unwrap();
assert_eq!(page3.tasks.len(), 1, "third page should have 1 task");
assert!(
page3.next_page_token.is_empty(),
"no more pages after the last task"
);
}
#[tokio::test]
async fn list_invalid_page_token_returns_empty() {
let store = InMemoryTaskStore::new();
store
.save(&make_task("t1", TaskState::Submitted))
.await
.unwrap();
let params = ListTasksParams {
page_token: Some("nonexistent-cursor".to_string()),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert!(
response.tasks.is_empty(),
"invalid page_token should yield empty results"
);
}
#[tokio::test]
async fn list_page_size_zero_uses_default() {
let store = InMemoryTaskStore::new();
for i in 0..60 {
store
.save(&make_task(&format!("t{i:03}"), TaskState::Submitted))
.await
.unwrap();
}
let params = ListTasksParams {
page_size: Some(0),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(
response.tasks.len(),
50,
"page_size=0 should use the default of 50"
);
}
#[tokio::test]
async fn ttl_eviction_removes_expired_terminal_tasks() {
let config = TaskStoreConfig {
max_capacity: None,
task_ttl: Some(Duration::from_millis(1)),
eviction_interval: 1,
max_page_size: 100,
};
let store = InMemoryTaskStore::with_config(config);
store
.save(&make_task("terminal", TaskState::Completed))
.await
.unwrap();
store
.save(&make_task("active", TaskState::Working))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
store.run_eviction().await;
assert!(
store.get(&TaskId::new("terminal")).await.unwrap().is_none(),
"expired terminal task should be evicted"
);
assert!(
store.get(&TaskId::new("active")).await.unwrap().is_some(),
"non-terminal task should survive TTL eviction"
);
}
#[tokio::test]
async fn ttl_eviction_keeps_fresh_terminal_tasks() {
let config = TaskStoreConfig {
max_capacity: None,
task_ttl: Some(Duration::from_secs(3600)),
eviction_interval: 1,
max_page_size: 100,
};
let store = InMemoryTaskStore::with_config(config);
store
.save(&make_task("t1", TaskState::Completed))
.await
.unwrap();
store.run_eviction().await;
assert!(
store.get(&TaskId::new("t1")).await.unwrap().is_some(),
"fresh terminal task should not be evicted"
);
}
#[tokio::test]
async fn max_capacity_eviction_removes_oldest_terminal_tasks() {
let config = TaskStoreConfig {
max_capacity: Some(2),
task_ttl: None,
eviction_interval: 1,
max_page_size: 100,
};
let store = InMemoryTaskStore::with_config(config);
store
.save(&make_task("oldest", TaskState::Completed))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("middle", TaskState::Completed))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("newest", TaskState::Completed))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(
store.get(&TaskId::new("oldest")).await.unwrap().is_none(),
"oldest terminal task should be evicted when over capacity"
);
assert_eq!(
store.count().await.unwrap(),
2,
"store should be back at max capacity"
);
}
#[tokio::test]
async fn capacity_eviction_prefers_terminal_tasks() {
let config = TaskStoreConfig {
max_capacity: Some(2),
task_ttl: None,
eviction_interval: 1,
max_page_size: 100,
};
let store = InMemoryTaskStore::with_config(config);
store
.save(&make_task("active", TaskState::Working))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("done", TaskState::Completed))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("new", TaskState::Submitted))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(
store.get(&TaskId::new("active")).await.unwrap().is_some(),
"non-terminal task should survive capacity eviction"
);
assert!(
store.get(&TaskId::new("done")).await.unwrap().is_none(),
"terminal task should be evicted first"
);
}
#[tokio::test]
async fn capacity_eviction_falls_back_to_non_terminal_when_needed() {
let config = TaskStoreConfig {
max_capacity: Some(2),
task_ttl: None,
eviction_interval: 1,
max_page_size: 100,
};
let store = InMemoryTaskStore::with_config(config);
store
.save(&make_task("oldest-active", TaskState::Working))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("middle-active", TaskState::Submitted))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
store
.save(&make_task("newest-active", TaskState::Working))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(
store
.get(&TaskId::new("oldest-active"))
.await
.unwrap()
.is_none(),
"oldest non-terminal task should be evicted as fallback"
);
assert_eq!(
store.count().await.unwrap(),
2,
"store should be at max capacity after fallback eviction"
);
}
#[test]
fn default_creates_new_store() {
let store = InMemoryTaskStore::default();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let count = rt.block_on(store.count()).unwrap();
assert_eq!(count, 0, "default store should be empty");
}
#[test]
fn default_config_has_expected_values() {
let cfg = TaskStoreConfig::default();
assert_eq!(cfg.max_capacity, Some(10_000));
assert_eq!(cfg.task_ttl, Some(Duration::from_secs(3600)));
assert_eq!(cfg.eviction_interval, 64);
assert_eq!(cfg.max_page_size, 1000);
}
}