mod eviction;
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::time::Instant;
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::events::StreamResponse;
use a2a_protocol_types::message::{Message, MessageId};
use a2a_protocol_types::params::ListTasksParams;
use a2a_protocol_types::responses::TaskListResponse;
use a2a_protocol_types::task::{Task, TaskId, TaskStatus};
use tokio::sync::RwLock;
use super::{ArtifactDelta, IdempotencyClaim, RecordedEvent, TaskStore, TaskStoreConfig};
use crate::metrics::{self, MetricsHandle};
use crate::store::terminal::{TerminalStateConflict, refuses_write};
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,
pub(super) log: VecDeque<RecordedEvent>,
}
#[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,
pub(super) idempotency_index: HashMap<String, (MessageId, TaskId, Instant)>,
}
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,
idempotency_index: HashMap::new(),
}
}
pub(super) fn refusal(&self, task: &Task) -> Option<TerminalStateConflict> {
let stored = self.entries.get(&task.id)?.task.status.state;
refuses_write(stored, task.status.state)
.then(|| TerminalStateConflict::new(task.id.clone(), stored, task.status.state))
}
#[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);
let log = self
.entries
.get_mut(&task_id)
.map_or_else(VecDeque::new, |old| std::mem::take(&mut old.log));
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,
log,
},
);
}
pub(super) fn update_status(
&mut self,
task_id: &TaskId,
status: TaskStatus,
last_updated: Instant,
) -> bool {
let Some(entry) = self.entries.get(task_id) else {
return false;
};
let old_key = entry.order_key;
let ctx = entry.task.context_id.0.clone();
let seq = self.next_seq;
self.next_seq += 1;
let millis = 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);
self.order_index.remove(&old_key);
if let Some(map) = self.context_index.get_mut(&ctx) {
map.remove(&old_key);
if map.is_empty() {
self.context_index.remove(&ctx);
}
}
self.order_index.insert(key, task_id.clone());
self.context_index
.entry(ctx)
.or_default()
.insert(key, task_id.clone());
if let Some(entry) = self.entries.get_mut(task_id) {
entry.task.status = status;
entry.order_key = key;
entry.last_updated = last_updated;
}
true
}
pub(super) fn append_history(
&mut self,
task: &Task,
messages: &[Message],
max_history: usize,
last_updated: Instant,
) -> bool {
if !self.update_status(&task.id, task.status.clone(), last_updated) {
return false;
}
let Some(entry) = self.entries.get_mut(&task.id) else {
return false;
};
let mut history = entry.task.history.take().unwrap_or_default();
history.extend_from_slice(messages);
let excess = history.len().saturating_sub(max_history);
history.drain(..excess);
entry.task.history = Some(history);
entry.task.context_id.clone_from(&task.context_id);
entry.task.artifacts.clone_from(&task.artifacts);
entry.task.metadata.clone_from(&task.metadata);
true
}
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) metrics: MetricsHandle,
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}")
}
pub fn decode_order_key(token: &str) -> Option<OrderKey> {
let (millis, seq) = token.split_once(':')?;
Some((millis.parse().ok()?, seq.parse().ok()?))
}
fn is_same_event(stored: &StreamResponse, incoming: &StreamResponse) -> bool {
match (serde_json::to_vec(stored), serde_json::to_vec(incoming)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
impl InMemoryTaskStore {
fn report_dropped_append(&self, _task_id: &TaskId, _seq: u64, error_kind: &'static str) {
trace_warn!(
task_id = %_task_id,
seq = _seq,
reason = error_kind,
"in-memory event log: append recorded nothing; the history skips this position"
);
self.metrics
.on_persistence_error(metrics::persistence_operation::EVENT_APPEND, error_kind);
}
#[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,
metrics: MetricsHandle::default(),
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,
metrics: MetricsHandle::default(),
write_count: std::sync::atomic::AtomicU64::new(0),
eviction_in_progress: std::sync::atomic::AtomicBool::new(false),
}
}
#[must_use]
pub fn with_metrics(mut self, metrics: MetricsHandle) -> Self {
self.metrics = metrics;
self
}
pub async fn idempotency_key_count(&self) -> usize {
let data = self.data.read().await;
let count = data.idempotency_index.len();
drop(data);
count
}
pub async fn is_prunable(&self) -> bool {
let data = self.data.read().await;
let prunable = data.entries.is_empty() && data.idempotency_index.is_empty();
drop(data);
prunable
}
}
fn apply_delta(stored: &mut Task, incoming: &Task, delta: ArtifactDelta) -> bool {
let (Some(stored_artifacts), Some(incoming_artifacts)) =
(stored.artifacts.as_mut(), incoming.artifacts.as_ref())
else {
return false;
};
match delta {
ArtifactDelta::AppendedParts { index, count } => {
let (Some(stored_artifact), Some(incoming_artifact)) = (
stored_artifacts.get_mut(index),
incoming_artifacts.get(index),
) else {
return false;
};
if stored_artifact.id != incoming_artifact.id
|| stored_artifact.parts.len() + count != incoming_artifact.parts.len()
{
return false;
}
let tail = &incoming_artifact.parts[incoming_artifact.parts.len() - count..];
stored_artifact.parts.extend_from_slice(tail);
stored_artifact
.metadata
.clone_from(&incoming_artifact.metadata);
}
ArtifactDelta::Pushed { index } => {
if index != stored_artifacts.len() || incoming_artifacts.len() != index + 1 {
return false;
}
let Some(pushed) = incoming_artifacts.get(index) else {
return false;
};
stored_artifacts.push(pushed.clone());
}
}
stored.status.clone_from(&incoming.status);
stored.metadata.clone_from(&incoming.metadata);
true
}
#[allow(clippy::manual_async_fn)]
impl TaskStore for InMemoryTaskStore {
fn supports_idempotency(&self) -> bool {
true
}
fn claim_idempotency_key<'a>(
&'a self,
key: &'a str,
message_id: &'a MessageId,
task_id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<IdempotencyClaim>> + Send + 'a>> {
Box::pin(async move {
let mut data = self.data.write().await;
let outcome = match data.idempotency_index.get(key) {
Some((held_by, held_task, _)) if held_by == message_id => {
IdempotencyClaim::Replay(held_task.clone())
}
Some((held_by, _, _)) => IdempotencyClaim::Conflict {
held_by: held_by.clone(),
},
None => {
data.idempotency_index.insert(
key.to_owned(),
(message_id.clone(), task_id.clone(), Instant::now()),
);
IdempotencyClaim::Claimed
}
};
drop(data);
Ok(outcome)
})
}
fn release_idempotency_key<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let mut data = self.data.write().await;
data.idempotency_index.remove(key);
drop(data);
Ok(())
})
}
fn supports_event_log(&self) -> bool {
true
}
fn append_event<'a>(
&'a self,
task_id: &'a TaskId,
seq: u64,
event: &'a StreamResponse,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let mut store = self.data.write().await;
let Some(entry) = store.entries.get_mut(task_id) else {
drop(store);
self.report_dropped_append(task_id, seq, metrics::event_append_error::TASK_ABSENT);
return Ok(());
};
let conflict = match entry.log.binary_search_by_key(&seq, |e| e.seq) {
Err(at) => {
entry.log.insert(
at,
RecordedEvent {
seq,
event: event.clone(),
},
);
if let Some(max) = self.config.effective_max_events_per_task() {
while entry.log.len() > max {
entry.log.pop_front();
}
}
false
}
Ok(at) => !is_same_event(&entry.log[at].event, event),
};
drop(store);
if conflict {
self.report_dropped_append(
task_id,
seq,
metrics::event_append_error::POSITION_CONFLICT,
);
}
Ok(())
})
}
fn earliest_event_seq<'a>(
&'a self,
task_id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<u64>>> + Send + 'a>> {
Box::pin(async move {
let store = self.data.read().await;
let seq = store
.entries
.get(task_id)
.and_then(|e| e.log.front())
.map(|e| e.seq);
drop(store);
Ok(seq)
})
}
fn last_event_seq<'a>(
&'a self,
task_id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
Box::pin(async move {
let store = self.data.read().await;
let seq = store
.entries
.get(task_id)
.and_then(|e| e.log.back())
.map_or(0, |e| e.seq);
drop(store);
Ok(seq)
})
}
fn read_events<'a>(
&'a self,
task_id: &'a TaskId,
after_seq: u64,
limit: usize,
) -> Pin<Box<dyn Future<Output = A2aResult<Vec<RecordedEvent>>> + Send + 'a>> {
Box::pin(async move {
let store = self.data.read().await;
let found = store.entries.get(task_id).map_or_else(Vec::new, |entry| {
entry
.log
.iter()
.filter(|e| e.seq > after_seq)
.take(limit)
.cloned()
.collect()
});
drop(store);
Ok(found)
})
}
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 passes = {
let mut store = self.data.write().await;
if let Some(conflict) = store.refusal(&task) {
drop(store);
return Err(conflict.into_error());
}
store.insert(task.id.clone(), task, Instant::now());
let len = store.len();
drop(store);
self.should_evict(len)
};
if passes.any() {
self.maybe_evict(passes).await;
}
Ok(())
})
}
fn save_status_delta<'a>(
&'a self,
task: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let outcome = {
let mut store = self.data.write().await;
if let Some(conflict) = store.refusal(task) {
drop(store);
return Err(conflict.into_error());
}
let applied = store.update_status(&task.id, task.status.clone(), Instant::now());
let len = store.len();
drop(store);
applied.then(|| self.should_evict(len))
};
let Some(passes) = outcome else {
return self.save(task).await;
};
trace_debug!(task_id = %task.id, "applied status delta in place");
if passes.any() {
self.maybe_evict(passes).await;
}
Ok(())
})
}
fn save_artifact_delta<'a>(
&'a self,
task: &'a Task,
delta: ArtifactDelta,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let outcome = {
let mut store = self.data.write().await;
if let Some(conflict) = store.refusal(task) {
drop(store);
return Err(conflict.into_error());
}
let applied = store
.entries
.get_mut(&task.id)
.is_some_and(|entry| apply_delta(&mut entry.task, task, delta));
if applied && let Some(entry) = store.entries.get_mut(&task.id) {
entry.last_updated = Instant::now();
}
let len = store.len();
drop(store);
applied.then(|| self.should_evict(len))
};
let Some(passes) = outcome else {
return self.save(task).await;
};
trace_debug!(task_id = %task.id, "applied artifact delta in place");
if passes.any() {
self.maybe_evict(passes).await;
}
Ok(())
})
}
fn save_appending_history<'a>(
&'a self,
task: &'a Task,
messages: &'a [Message],
max_history: usize,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let outcome = {
let mut store = self.data.write().await;
if let Some(conflict) = store.refusal(task) {
drop(store);
return Err(conflict.into_error());
}
let applied = store.append_history(task, messages, max_history, Instant::now());
let len = store.len();
drop(store);
applied.then(|| self.should_evict(len))
};
let Some(passes) = outcome else {
return self.save(task).await;
};
trace_debug!(task_id = %task.id, "appended history in place");
if passes.any() {
self.maybe_evict(passes).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
&& 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, passes) = {
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 passes.any() {
self.maybe_evict(passes).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 a_status_delta_moves_the_status_and_keeps_the_rest() {
let store = InMemoryTaskStore::new();
let mut task = make_task("t-delta", TaskState::Working);
task.history = Some(vec![
a2a_protocol_types::message::Message::user_text("m-1", "first"),
a2a_protocol_types::message::Message::user_text("m-2", "second"),
]);
task.metadata = Some(serde_json::json!({"keep": true}));
store.save(&task).await.expect("seed");
let mut next = task.clone();
next.status = TaskStatus::with_timestamp(TaskState::Completed);
next.history = None;
next.metadata = None;
store.save_status_delta(&next).await.expect("delta");
let stored = store
.get(&TaskId::new("t-delta"))
.await
.expect("get")
.expect("present");
assert_eq!(
stored.status.state,
TaskState::Completed,
"the status is the one field a status delta must move"
);
assert_eq!(
stored.history.as_ref().map(Vec::len),
Some(2),
"history is what `save` would have kept, so the delta must keep it"
);
assert_eq!(
stored.metadata,
Some(serde_json::json!({"keep": true})),
"metadata is not the caller's to drop by omitting it"
);
}
#[tokio::test]
async fn a_status_delta_re_keys_the_ordering() {
let store = InMemoryTaskStore::new();
store
.save(&make_task_with_ctx(
"older",
"ctx-order",
TaskState::Working,
))
.await
.expect("seed older");
store
.save(&make_task_with_ctx(
"newer",
"ctx-order",
TaskState::Working,
))
.await
.expect("seed newer");
let params = ListTasksParams {
context_id: Some("ctx-order".to_owned()),
..Default::default()
};
let before = store.list(¶ms).await.expect("list");
assert_eq!(
before.tasks.first().map(|t| t.id.0.as_str()),
Some("newer"),
"most recently updated first, before any delta"
);
let mut moved = make_task_with_ctx("older", "ctx-order", TaskState::Working);
moved.status = TaskStatus::with_timestamp(TaskState::Completed);
store.save_status_delta(&moved).await.expect("delta");
let after = store.list(¶ms).await.expect("list");
assert_eq!(
after.tasks.first().map(|t| t.id.0.as_str()),
Some("older"),
"a status delta changes the status timestamp, so the record has to \
move to the front exactly as a full save would have moved it"
);
assert_eq!(
after.tasks.len(),
2,
"re-keying must not lose or duplicate the record"
);
}
#[tokio::test]
async fn a_status_delta_still_paces_the_eviction_sweep() {
let store = InMemoryTaskStore::with_config(TaskStoreConfig {
max_capacity: None,
task_ttl: Some(Duration::from_millis(1)),
eviction_interval: 1,
max_page_size: 100,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
});
store
.save(&make_task("expired", TaskState::Completed))
.await
.expect("seed the task that should be swept");
let live = make_task("live", TaskState::Working);
store.save(&live).await.expect("seed the live task");
tokio::time::sleep(Duration::from_millis(10)).await;
let mut moved = live.clone();
moved.status = TaskStatus::with_timestamp(TaskState::Working);
store.save_status_delta(&moved).await.expect("delta");
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
store
.get(&TaskId::new("expired"))
.await
.expect("get")
.is_none(),
"a status delta must advance the eviction counter as a save does; \
the expired terminal task is still here, so it did not"
);
assert!(
store
.get(&TaskId::new("live"))
.await
.expect("get")
.is_some(),
"the sweep must take the expired task and leave the live one"
);
}
#[tokio::test]
async fn a_status_delta_for_an_absent_task_falls_back_to_a_save() {
let store = InMemoryTaskStore::new();
let task = make_task("t-absent", TaskState::Completed);
store.save_status_delta(&task).await.expect("delta");
let stored = store
.get(&TaskId::new("t-absent"))
.await
.expect("get")
.expect("the fallback must have inserted it");
assert_eq!(stored.status.state, TaskState::Completed);
}
#[tokio::test]
async fn a_status_delta_keeps_the_event_log() {
let store = InMemoryTaskStore::new();
let task = make_task("t-log", TaskState::Working);
store.save(&task).await.expect("seed");
let event = a2a_protocol_types::events::StreamResponse::StatusUpdate(
a2a_protocol_types::events::TaskStatusUpdateEvent {
task_id: task.id.clone(),
context_id: task.context_id.clone(),
status: TaskStatus::new(TaskState::Working),
metadata: None,
},
);
store
.append_event(&task.id, 1, &event)
.await
.expect("append");
let mut next = task.clone();
next.status = TaskStatus::with_timestamp(TaskState::Completed);
store.save_status_delta(&next).await.expect("delta");
let read = store
.read_events(&task.id, 0, 10)
.await
.expect("read events");
assert_eq!(
read.len(),
1,
"the log is the record of what the agent emitted; a status delta \
is not an event and must not clear it"
);
}
#[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_eq!(response.tasks, [] as [a2a_protocol_types::Task; 0]);
assert_eq!(response.next_page_token, "");
}
#[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,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
};
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,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
};
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 save_does_not_return_with_the_store_over_capacity() {
let store = InMemoryTaskStore::with_config(TaskStoreConfig {
max_capacity: Some(4),
task_ttl: None,
eviction_interval: 0,
max_page_size: 100,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
});
for i in 0..4 {
store
.save(&make_task(&format!("t{i}"), TaskState::Completed))
.await
.unwrap();
}
assert_eq!(store.count().await.unwrap(), 4, "filled to capacity");
store
.save(&make_task("overflow", TaskState::Completed))
.await
.unwrap();
assert_eq!(
store.count().await.unwrap(),
4,
"the write that goes over capacity pays for the sweep before it returns"
);
}
#[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,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
};
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();
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,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
};
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,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
};
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);
}
#[test]
fn now_unix_millis_returns_a_real_wall_clock_reading() {
const JAN_2023: i64 = 1_672_531_200_000;
const JAN_2100: i64 = 4_102_444_800_000;
let now = now_unix_millis();
assert!(
now > JAN_2023,
"expected a current Unix-millis timestamp, got {now}; a value at \
or near zero means the clock read was replaced by a constant"
);
assert!(
now < JAN_2100,
"expected a current Unix-millis timestamp, got {now}; a value \
this large suggests the wrong time unit"
);
}
}
#[cfg(test)]
mod artifact_delta_tests {
use super::*;
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::message::Part;
use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
fn task_with(id: &str, artifacts: Option<Vec<Artifact>>) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new("ctx"),
status: TaskStatus::new(TaskState::Working),
history: None,
artifacts,
metadata: None,
}
}
fn artifact(id: &str, parts: usize) -> Artifact {
Artifact::new(
id,
(0..parts).map(|i| Part::text(format!("p{i}"))).collect(),
)
}
#[test]
fn append_delta_accepts_only_a_well_formed_append() {
let base = || task_with("t", Some(vec![artifact("a", 2)]));
let mut stored = base();
let mut incoming = task_with("t", Some(vec![artifact("a", 3)]));
assert!(
apply_delta(
&mut stored,
&incoming,
ArtifactDelta::AppendedParts { index: 0, count: 1 }
),
"a delta whose arithmetic checks out must be applied, not declined"
);
assert_eq!(
stored.artifacts.as_ref().expect("artifacts")[0].parts.len(),
3
);
let mut stored = base();
incoming = task_with("t", Some(vec![artifact("other", 3)]));
assert!(
!apply_delta(
&mut stored,
&incoming,
ArtifactDelta::AppendedParts { index: 0, count: 1 }
),
"a delta for a different artifact id must be declined"
);
let mut stored = base();
incoming = task_with("t", Some(vec![artifact("a", 3)]));
assert!(
!apply_delta(
&mut stored,
&incoming,
ArtifactDelta::AppendedParts { index: 0, count: 4 }
),
"a delta claiming more parts than the incoming artifact holds must be declined"
);
let mut stored = base();
incoming = task_with("t", Some(vec![artifact("a", 5)]));
assert!(
!apply_delta(
&mut stored,
&incoming,
ArtifactDelta::AppendedParts { index: 0, count: 1 }
),
"2 stored + 1 appended is not 5 incoming, so the delta must be declined"
);
}
#[test]
fn push_delta_accepts_only_an_append_at_the_end() {
let mut stored = task_with("t", Some(vec![artifact("a", 1)]));
let incoming = task_with("t", Some(vec![artifact("a", 1), artifact("b", 1)]));
assert!(
apply_delta(&mut stored, &incoming, ArtifactDelta::Pushed { index: 1 }),
"a push landing at the end must be applied"
);
assert_eq!(stored.artifacts.as_ref().expect("artifacts").len(), 2);
let mut stored = task_with("t", Some(vec![artifact("a", 1)]));
assert!(
!apply_delta(&mut stored, &incoming, ArtifactDelta::Pushed { index: 0 }),
"a push at an index that is not the stored end must be declined"
);
let mut stored = task_with("t", Some(vec![artifact("a", 1)]));
let too_long = task_with(
"t",
Some(vec![artifact("a", 1), artifact("b", 1), artifact("c", 1)]),
);
assert!(
!apply_delta(&mut stored, &too_long, ArtifactDelta::Pushed { index: 1 }),
"a push whose incoming vector holds more than index + 1 must be declined"
);
let mut stored = task_with("t", Some(Vec::new()));
let first = task_with("t", Some(vec![artifact("a", 1)]));
assert!(
apply_delta(&mut stored, &first, ArtifactDelta::Pushed { index: 0 }),
"the first artifact is pushed at index 0 and must be applied"
);
}
#[tokio::test]
async fn appending_matches_full_save_at_every_step() {
let delta_store = InMemoryTaskStore::new();
let save_store = InMemoryTaskStore::new();
let mut task = task_with("t", Some(vec![artifact("a", 1)]));
delta_store.save(&task).await.unwrap();
save_store.save(&task).await.unwrap();
for i in 0..200 {
let arts = task.artifacts.as_mut().unwrap();
arts[0].parts.push(Part::text(format!("chunk{i}")));
delta_store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
save_store.save(&task).await.unwrap();
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await.unwrap(),
save_store.get(&id).await.unwrap(),
"diverged after {i} appends"
);
}
}
#[tokio::test]
async fn pushing_matches_full_save_at_every_step() {
let delta_store = InMemoryTaskStore::new();
let save_store = InMemoryTaskStore::new();
let mut task = task_with("t", Some(vec![]));
delta_store.save(&task).await.unwrap();
save_store.save(&task).await.unwrap();
for i in 0..100 {
let arts = task.artifacts.as_mut().unwrap();
arts.push(artifact(&format!("a{i}"), 2));
let index = arts.len() - 1;
delta_store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index })
.await
.unwrap();
save_store.save(&task).await.unwrap();
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await.unwrap(),
save_store.get(&id).await.unwrap(),
"diverged after {i} pushes"
);
}
}
#[tokio::test]
async fn absent_task_falls_back_to_full_save() {
let store = InMemoryTaskStore::new();
let task = task_with("never-saved", Some(vec![artifact("a", 3)]));
store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
.await
.unwrap();
assert_eq!(
store.get(&TaskId::new("never-saved")).await.unwrap(),
Some(task)
);
}
#[tokio::test]
async fn mismatched_index_falls_back_and_stays_correct() {
let store = InMemoryTaskStore::new();
let mut task = task_with("t", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("new"));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 7, count: 1 })
.await
.unwrap();
assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
}
#[tokio::test]
async fn wrong_count_falls_back_and_stays_correct() {
let store = InMemoryTaskStore::new();
let mut task = task_with("t", Some(vec![artifact("a", 2)]));
store.save(&task).await.unwrap();
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("one-more"));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
.await
.unwrap();
assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
}
#[tokio::test]
async fn delta_preserves_list_position() {
let store = InMemoryTaskStore::new();
let older = task_with("older", Some(vec![artifact("a", 1)]));
let newer = task_with("newer", None);
store.save(&older).await.unwrap();
store.save(&newer).await.unwrap();
let before: Vec<_> = store
.list(&ListTasksParams::default())
.await
.unwrap()
.tasks
.iter()
.map(|t| t.id.clone())
.collect();
let mut grown = older.clone();
grown.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("more"));
store
.save_artifact_delta(&grown, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
let after: Vec<_> = store
.list(&ListTasksParams::default())
.await
.unwrap()
.tasks
.iter()
.map(|t| t.id.clone())
.collect();
assert_eq!(before, after, "appending an artifact reordered the list");
}
#[tokio::test]
async fn an_artifact_delta_still_paces_the_eviction_sweep() {
use std::time::Duration;
let store = InMemoryTaskStore::with_config(TaskStoreConfig {
max_capacity: None,
task_ttl: Some(Duration::from_millis(1)),
eviction_interval: 1,
max_page_size: 100,
max_events_per_task: Some(8),
idempotency_key_ttl: None,
});
let mut expired = task_with("expired", None);
expired.status = TaskStatus::new(TaskState::Completed);
store
.save(&expired)
.await
.expect("seed the task that should be swept");
let streaming = task_with("streaming", Some(vec![artifact("a", 2)]));
store
.save(&streaming)
.await
.expect("seed the task the stream appends to");
tokio::time::sleep(Duration::from_millis(10)).await;
let grown = task_with("streaming", Some(vec![artifact("a", 3)]));
store
.save_artifact_delta(&grown, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.expect("delta");
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
store
.get(&TaskId::new("expired"))
.await
.expect("get")
.is_none(),
"an artifact delta must advance the eviction counter as a save \
does; the expired terminal task is still here, so it did not"
);
assert!(
store
.get(&TaskId::new("streaming"))
.await
.expect("get")
.is_some(),
"the sweep must take the expired task and leave the one the \
stream is still appending to"
);
}
}
#[cfg(test)]
mod idempotency_tests {
use super::*;
use std::sync::Arc;
const KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
const OTHER_KEY: &str = "0123456789abcdef0123456789abcdef";
#[tokio::test]
async fn the_default_store_supports_idempotency() {
assert!(InMemoryTaskStore::new().supports_idempotency());
}
#[tokio::test]
async fn a_free_key_is_claimed() {
let store = InMemoryTaskStore::new();
let claim = store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("t1"))
.await
.unwrap();
assert_eq!(claim, IdempotencyClaim::Claimed);
}
#[tokio::test]
async fn the_same_message_replays_to_the_first_task() {
let store = InMemoryTaskStore::new();
let msg = MessageId::new("m1");
store
.claim_idempotency_key(KEY, &msg, &TaskId::new("t1"))
.await
.unwrap();
let claim = store
.claim_idempotency_key(KEY, &msg, &TaskId::new("t2"))
.await
.unwrap();
assert_eq!(claim, IdempotencyClaim::Replay(TaskId::new("t1")));
}
#[tokio::test]
async fn replaying_twice_still_names_the_first_task() {
let store = InMemoryTaskStore::new();
let msg = MessageId::new("m1");
store
.claim_idempotency_key(KEY, &msg, &TaskId::new("t1"))
.await
.unwrap();
for attempt in ["t2", "t3", "t4"] {
assert_eq!(
store
.claim_idempotency_key(KEY, &msg, &TaskId::new(attempt))
.await
.unwrap(),
IdempotencyClaim::Replay(TaskId::new("t1")),
"attempt {attempt} did not replay to the original"
);
}
}
#[tokio::test]
async fn a_different_message_on_the_same_key_conflicts() {
let store = InMemoryTaskStore::new();
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("t1"))
.await
.unwrap();
let claim = store
.claim_idempotency_key(KEY, &MessageId::new("m2"), &TaskId::new("t2"))
.await
.unwrap();
assert_eq!(
claim,
IdempotencyClaim::Conflict {
held_by: MessageId::new("m1")
}
);
}
#[tokio::test]
async fn a_conflict_does_not_take_the_key_from_its_holder() {
let store = InMemoryTaskStore::new();
let first = MessageId::new("m1");
store
.claim_idempotency_key(KEY, &first, &TaskId::new("t1"))
.await
.unwrap();
store
.claim_idempotency_key(KEY, &MessageId::new("m2"), &TaskId::new("t2"))
.await
.unwrap();
assert_eq!(
store
.claim_idempotency_key(KEY, &first, &TaskId::new("t3"))
.await
.unwrap(),
IdempotencyClaim::Replay(TaskId::new("t1"))
);
}
#[tokio::test]
async fn distinct_keys_do_not_interfere() {
let store = InMemoryTaskStore::new();
assert_eq!(
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("t1"))
.await
.unwrap(),
IdempotencyClaim::Claimed
);
assert_eq!(
store
.claim_idempotency_key(OTHER_KEY, &MessageId::new("m2"), &TaskId::new("t2"))
.await
.unwrap(),
IdempotencyClaim::Claimed
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_claims_of_one_key_produce_exactly_one_winner() {
let store = Arc::new(InMemoryTaskStore::new());
let msg = MessageId::new("m1");
let mut handles = Vec::new();
for i in 0..64 {
let store = Arc::clone(&store);
let msg = msg.clone();
handles.push(tokio::spawn(async move {
store
.claim_idempotency_key(KEY, &msg, &TaskId::new(format!("t{i}")))
.await
.unwrap()
}));
}
let mut claimed = Vec::new();
let mut replays = 0;
for h in handles {
match h.await.unwrap() {
IdempotencyClaim::Claimed => claimed.push(()),
IdempotencyClaim::Replay(_) => replays += 1,
IdempotencyClaim::Conflict { held_by } => {
panic!("same message must never conflict with itself (held_by {held_by})")
}
}
}
assert_eq!(claimed.len(), 1, "exactly one racer may claim the key");
assert_eq!(replays, 63, "every other racer must observe a replay");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_distinct_messages_leave_one_winner_and_the_rest_conflicting() {
let store = Arc::new(InMemoryTaskStore::new());
let mut handles = Vec::new();
for i in 0..32 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
store
.claim_idempotency_key(
KEY,
&MessageId::new(format!("m{i}")),
&TaskId::new(format!("t{i}")),
)
.await
.unwrap()
}));
}
let mut claimed = 0;
let mut conflicts = 0;
for h in handles {
match h.await.unwrap() {
IdempotencyClaim::Claimed => claimed += 1,
IdempotencyClaim::Conflict { .. } => conflicts += 1,
IdempotencyClaim::Replay(t) => {
panic!("distinct messages must never replay each other (got {t})")
}
}
}
assert_eq!(claimed, 1);
assert_eq!(conflicts, 31);
}
#[tokio::test]
async fn a_store_that_has_not_implemented_it_says_so_rather_than_allowing_the_send() {
struct Unsupported;
#[allow(clippy::manual_async_fn)]
impl TaskStore for Unsupported {
fn save<'a>(
&'a self,
_t: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
fn get<'a>(
&'a self,
_id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
Box::pin(async { Ok(None) })
}
fn list<'a>(
&'a self,
_p: &'a ListTasksParams,
) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>>
{
Box::pin(async { Ok(TaskListResponse::new(Vec::new())) })
}
fn insert_if_absent<'a>(
&'a self,
_t: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
Box::pin(async { Ok(true) })
}
fn delete<'a>(
&'a self,
_id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
assert!(!Unsupported.supports_idempotency());
let err = Unsupported
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("t1"))
.await
.expect_err("a store without the index must not report a successful claim");
assert!(
err.to_string().contains("does not implement idempotency"),
"unhelpful error: {err}"
);
}
}
#[cfg(test)]
mod event_log_tests {
use super::*;
use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
use std::sync::Mutex;
#[derive(Debug, Default)]
struct Recorder {
seen: Mutex<Vec<(String, String)>>,
}
impl crate::metrics::Metrics for Recorder {
fn on_persistence_error(&self, operation: &str, error_kind: &str) {
self.seen
.lock()
.expect("recorder mutex")
.push((operation.to_owned(), error_kind.to_owned()));
}
}
fn task(id: &str) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new("c-1"),
status: TaskStatus::new(TaskState::Working),
history: None,
artifacts: None,
metadata: None,
}
}
fn event(state: TaskState) -> StreamResponse {
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new("t-1"),
context_id: ContextId::new("c-1"),
status: TaskStatus::new(state),
metadata: None,
})
}
fn store_with(recorder: &std::sync::Arc<Recorder>) -> InMemoryTaskStore {
InMemoryTaskStore::new()
.with_metrics(crate::metrics::MetricsHandle::from_arc(recorder.clone()))
}
#[tokio::test]
async fn an_append_for_a_task_the_store_does_not_hold_is_reported() {
let recorder = std::sync::Arc::new(Recorder::default());
let store = store_with(&recorder);
store
.append_event(&TaskId::new("gone"), 1, &event(TaskState::Working))
.await
.expect("a swept task must not fail the agent");
let seen = recorder.seen.lock().expect("recorder mutex").clone();
assert_eq!(
seen,
vec![(
crate::metrics::persistence_operation::EVENT_APPEND.to_owned(),
crate::metrics::event_append_error::TASK_ABSENT.to_owned(),
)],
"the drop must be counted, since it cannot be returned"
);
}
#[tokio::test]
async fn a_second_writer_on_one_position_is_reported_and_a_replay_is_not() {
let recorder = std::sync::Arc::new(Recorder::default());
let store = store_with(&recorder);
store.save(&task("t-1")).await.expect("save");
store
.append_event(&TaskId::new("t-1"), 1, &event(TaskState::Working))
.await
.expect("append");
store
.append_event(&TaskId::new("t-1"), 1, &event(TaskState::Working))
.await
.expect("replay");
assert!(
recorder.seen.lock().expect("recorder mutex").is_empty(),
"a replay of the same event loses nothing and must not be counted"
);
store
.append_event(&TaskId::new("t-1"), 1, &event(TaskState::Completed))
.await
.expect("collision");
let seen = recorder.seen.lock().expect("recorder mutex").clone();
assert_eq!(
seen,
vec![(
crate::metrics::persistence_operation::EVENT_APPEND.to_owned(),
crate::metrics::event_append_error::POSITION_CONFLICT.to_owned(),
)]
);
let held = store
.read_events(&TaskId::new("t-1"), 0, 10)
.await
.expect("read");
assert_eq!(held.len(), 1, "a position holds one event, not two");
}
#[tokio::test]
async fn the_log_stops_growing_at_the_configured_bound() {
let config = TaskStoreConfig::default().with_max_events_per_task(Some(4));
let store = InMemoryTaskStore::with_config(config);
store.save(&task("t-1")).await.expect("save");
for seq in 1..=10 {
store
.append_event(&TaskId::new("t-1"), seq, &event(TaskState::Working))
.await
.expect("append");
}
let held = store
.read_events(&TaskId::new("t-1"), 0, 100)
.await
.expect("read");
assert_eq!(
held.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![7, 8, 9, 10],
"the bound keeps the newest positions and drops the oldest"
);
assert_eq!(
store
.last_event_seq(&TaskId::new("t-1"))
.await
.expect("last"),
10,
"truncating the head must not move the writer's position"
);
}
#[tokio::test]
async fn a_truncated_log_says_which_positions_it_no_longer_holds() {
let config = TaskStoreConfig::default().with_max_events_per_task(Some(4));
let store = InMemoryTaskStore::with_config(config);
store.save(&task("t-1")).await.expect("save");
for seq in 1..=10 {
store
.append_event(&TaskId::new("t-1"), seq, &event(TaskState::Working))
.await
.expect("append");
}
assert_eq!(
store
.earliest_event_seq(&TaskId::new("t-1"))
.await
.expect("earliest"),
Some(7),
);
assert!(
!store
.event_log_covers(&TaskId::new("t-1"), 3)
.await
.expect("covers"),
"resuming after 3 needs position 4, and the log starts at 7"
);
assert!(
store
.event_log_covers(&TaskId::new("t-1"), 6)
.await
.expect("covers"),
"the boundary is inclusive: after_seq + 1 == earliest is covered"
);
assert!(
store
.event_log_covers(&TaskId::new("t-1"), 9)
.await
.expect("covers"),
);
}
#[tokio::test]
async fn an_untruncated_log_covers_a_resume_from_the_beginning() {
let store = InMemoryTaskStore::new();
store.save(&task("t-1")).await.expect("save");
for seq in 1..=3 {
store
.append_event(&TaskId::new("t-1"), seq, &event(TaskState::Working))
.await
.expect("append");
}
assert_eq!(
store
.earliest_event_seq(&TaskId::new("t-1"))
.await
.expect("earliest"),
Some(1),
);
assert!(
store
.event_log_covers(&TaskId::new("t-1"), 0)
.await
.expect("covers")
);
assert_eq!(
store
.earliest_event_seq(&TaskId::new("absent"))
.await
.expect("earliest"),
None,
);
assert!(
store
.event_log_covers(&TaskId::new("absent"), 5)
.await
.expect("covers")
);
}
#[tokio::test]
async fn a_zero_bound_keeps_one_event_rather_than_none() {
let config = TaskStoreConfig::default().with_max_events_per_task(Some(0));
let store = InMemoryTaskStore::with_config(config);
store.save(&task("t-1")).await.expect("save");
for seq in 1..=3 {
store
.append_event(&TaskId::new("t-1"), seq, &event(TaskState::Working))
.await
.expect("append");
}
assert_eq!(
store
.earliest_event_seq(&TaskId::new("t-1"))
.await
.expect("earliest"),
Some(3),
);
assert!(
!store
.event_log_covers(&TaskId::new("t-1"), 0)
.await
.expect("covers")
);
}
#[tokio::test]
async fn an_unbounded_log_is_still_available() {
let config = TaskStoreConfig::default().with_max_events_per_task(None);
let store = InMemoryTaskStore::with_config(config);
store.save(&task("t-1")).await.expect("save");
for seq in 1..=200 {
store
.append_event(&TaskId::new("t-1"), seq, &event(TaskState::Working))
.await
.expect("append");
}
assert_eq!(
store
.read_events(&TaskId::new("t-1"), 0, 1000)
.await
.expect("read")
.len(),
200
);
}
#[tokio::test]
async fn a_store_that_holds_keys_is_not_prunable_though_it_holds_no_task() {
let store = InMemoryTaskStore::new();
assert!(store.is_prunable().await, "a fresh store holds nothing");
store.save(&task("t-1")).await.expect("save");
store
.claim_idempotency_key(
"8f14e45fceea167a5a36dedd4bea2543",
&a2a_protocol_types::message::MessageId::new("m-1"),
&TaskId::new("t-1"),
)
.await
.expect("claim");
store.delete(&TaskId::new("t-1")).await.expect("delete");
assert_eq!(store.count().await.expect("count"), 0);
assert_eq!(store.idempotency_key_count().await, 1);
assert!(
!store.is_prunable().await,
"the key outlives its task on purpose; pruning on count() alone drops it"
);
}
}