Skip to main content

InMemoryTaskStore

Struct InMemoryTaskStore 

Source
pub struct InMemoryTaskStore { /* private fields */ }
Expand description

In-memory TaskStore backed by a pre-allocated HashMap with secondary indexes under a single RwLock.

Suitable for testing and single-process deployments. Data is lost when the process exits.

The internal HashMap is pre-allocated to the configured max_capacity (default 10,000) to prevent latency spikes from table resizing. Without pre-allocation, HashMap doubles its capacity when load factor exceeds ~87.5%, triggering a full rehash of every stored entry. Pre-allocation eliminates these unpredictable latency cliffs entirely.

§Indexing strategy

IndexStructurePurpose
PrimaryHashMap<TaskId, TaskEntry>O(1) get/save
OrderBTreeMap<u64, TaskId>O(log n + page_size) update-order pagination
ContextHashMap<String, BTreeMap<u64, TaskId>>O(log m + page_size) filtered list

The order index is keyed by a monotonic per-write sequence and iterated in reverse to return most-recently-updated tasks first (spec §3.1.4) without the O(n log n) per-call sort that previously caused 20-70× regressions at 10K+ tasks. The context index avoids full-scan filtering by pre-partitioning task IDs by context, preserving the same update-order within each context.

§Eviction behavior

Eviction runs every N writes (configurable via TaskStoreConfig::eviction_interval) and whenever the store exceeds max_capacity. If the system goes idle (no save() calls), completed tasks persist in memory past their TTL.

Operators should call run_eviction() periodically (e.g. every 60 seconds via tokio::time::interval) to ensure timely cleanup of terminal tasks during idle periods.

§The write that triggers a sweep pays for it

This paragraph used to say the sweep “runs as a background task” and that “writers are not blocked during the O(n) cleanup”. Neither is true and both were corrected on 2026-08-19. There is no spawn: the sweep is awaited inside save, so the caller that triggers it waits for it, and it holds the write lock for its whole duration, so every other writer waits too. What is genuinely decoupled is only the lock acquisition — the insert’s write lock is released before the sweep takes its own.

The size of that, MEASURED (debug profile, tokio multi-thread, 50,000 terminal tasks, eviction_interval 1000):

latency
quietest of 1,000 consecutive saves3.99 µs
slowest of the same 1,000 (the one that swept)4.54 ms

One write in eviction_interval costs about 1,100× a quiet one at that size, and it stalls every concurrent writer, not just itself. The capacity pass is much cheaper — measured 3.5× and 3.7× on two runs at 10,000 entries — because it removes only the overflow rather than scanning for it.

This is a shape to plan for, not a defect to route around: the TTL pass is O(n) unavoidably (finding expired entries means looking at all of them), which is exactly why it is amortized behind eviction_interval rather than run per write. What it means in practice is that eviction_interval is a tail-latency knob as much as a cleanliness one, and that a deployment sensitive to p99.9 write latency should raise it and call run_eviction() from its own scheduler instead — where the stall lands somewhere it chose.

§Concurrency

For high-concurrency production deployments, consider SqliteTaskStore which uses a connection pool and row-level locking. The in-memory store uses a single RwLock and is optimized for testing and moderate load.

Implementations§

Source§

impl InMemoryTaskStore

Source

pub async fn run_eviction(&self)

Runs background eviction of expired and over-capacity entries.

Call this periodically (e.g. every 60 seconds) to clean up terminal tasks that would otherwise persist until the next save() call.

Source§

impl InMemoryTaskStore

Source

pub fn new() -> InMemoryTaskStore

Creates a new empty in-memory task store with default configuration.

Default: max 10,000 tasks, 1-hour TTL for terminal tasks. The internal HashMap is pre-allocated to the configured max_capacity to prevent resize-induced latency spikes during operation.

Source

pub fn with_config(config: TaskStoreConfig) -> InMemoryTaskStore

Creates a new in-memory task store with custom configuration.

The internal HashMap is pre-allocated to config.max_capacity (or a sensible default if None) to prevent resize-induced latency spikes.

Source

pub fn with_metrics(self, metrics: MetricsHandle) -> InMemoryTaskStore

Sets where this store reports an event it could not record.

Defaults to NoopMetrics. Two appends leave the log short of an event the agent emitted and still return Ok(()): one for a task this store no longer holds, and one landing on a position another writer already took. Neither can be an error — see event_append_error — so this is how they become visible.

Source

pub async fn idempotency_key_count(&self) -> usize

How many idempotency keys this store currently holds.

Not covered by count, which counts tasks. The two diverge on purpose: a key deliberately outlives the task it names, because dropping it would let a delayed retry of the same send execute a second time — see StoreData::idempotency_index. Anything deciding that a store is finished with therefore has to ask about both.

Source

pub async fn is_prunable(&self) -> bool

Whether this store can be discarded without losing anything.

true only when it holds no tasks and no idempotency keys. A caller that reclaims empty stores — a tenant partition map, say — must ask this rather than count() == 0: an emptied store can still be holding the keys that stop a retry executing twice, and discarding it reopens exactly the double execution the keys exist to prevent.

Trait Implementations§

Source§

impl Debug for InMemoryTaskStore

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
Source§

impl Default for InMemoryTaskStore

Source§

fn default() -> InMemoryTaskStore

Returns the “default value” for a type. Read more
Source§

impl TaskStore for InMemoryTaskStore

Source§

fn save_status_delta<'a>( &'a self, task: &'a Task, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Edits the stored status in place rather than replacing the record.

save here is a deep clone of the whole task, history included, so a status transition on a long conversation costs what that conversation has accumulated. This costs a TaskStatus and two index re-keys.

Falls back to save when the record is absent, which is the one case the in-place update cannot apply — the same discipline TaskStore::save_artifact_delta follows, and for the same reason: a dropped transition would be worse than a slow one.

Source§

fn save_artifact_delta<'a>( &'a self, task: &'a Task, delta: ArtifactDelta, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Applies the delta to the stored task in place, copying only what grew.

save clones the whole task, so using it per artifact event makes a stream cost quadratic in its own length — see TaskStore::save_artifact_delta for the measurement. Here the work is proportional to the appended parts instead of the accumulated ones.

Falls back to save whenever the stored record is not the one this delta describes: absent, no artifacts, index out of range, a different artifact at that index, or fewer parts present than the delta claims were appended. Those are all “cannot apply safely”, and a whole-record replace is always right — a wrong in-place edit would not be.

Source§

fn save_appending_history<'a>( &'a self, task: &'a Task, messages: &'a [Message], max_history: usize, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Appends the new messages to the stored history in place.

save here is a deep clone of the whole task, so a send on an aged channel costs what that channel has accumulated. This extends a Vec by the messages the turn actually added.

Falls back to save when the record is absent, which is the one case an in-place append cannot serve — and task carries the full initial history in that case, because a first turn has nothing to append to.

Source§

fn supports_idempotency(&self) -> bool

Whether this store can back idempotency keys. Read more
Source§

fn claim_idempotency_key<'a>( &'a self, key: &'a str, message_id: &'a MessageId, task_id: &'a TaskId, ) -> Pin<Box<dyn Future<Output = Result<IdempotencyClaim, A2aError>> + Send + 'a>>

Atomically claims key for task_id on behalf of message_id. Read more
Source§

fn release_idempotency_key<'a>( &'a self, key: &'a str, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Releases a key claimed by claim_idempotency_key, so it can be claimed again. Read more
Source§

fn supports_event_log(&self) -> bool

Whether this store keeps a per-task event log. Read more
Source§

fn append_event<'a>( &'a self, task_id: &'a TaskId, seq: u64, event: &'a StreamResponse, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Appends one event at seq in the task’s log. Read more
Source§

fn earliest_event_seq<'a>( &'a self, task_id: &'a TaskId, ) -> Pin<Box<dyn Future<Output = Result<Option<u64>, A2aError>> + Send + 'a>>

The lowest seq this task’s log still holds, or None when it holds nothing — or when the store cannot say, which is the default. Read more
Source§

fn last_event_seq<'a>( &'a self, task_id: &'a TaskId, ) -> Pin<Box<dyn Future<Output = Result<u64, A2aError>> + Send + 'a>>

The highest seq this task’s log holds, or 0 when it is empty. Read more
Source§

fn read_events<'a>( &'a self, task_id: &'a TaskId, after_seq: u64, limit: usize, ) -> Pin<Box<dyn Future<Output = Result<Vec<RecordedEvent>, A2aError>> + Send + 'a>>

Reads a task’s events in order, starting after after_seq. Read more
Source§

fn save<'a>( &'a self, task: &'a Task, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Saves (creates or updates) a task. Read more
Source§

fn get<'a>( &'a self, id: &'a TaskId, ) -> Pin<Box<dyn Future<Output = Result<Option<Task>, A2aError>> + Send + 'a>>

Retrieves a task by its ID, returning None if not found. Read more
Source§

fn list<'a>( &'a self, params: &'a ListTasksParams, ) -> Pin<Box<dyn Future<Output = Result<TaskListResponse, A2aError>> + Send + 'a>>

Lists tasks matching the given filter parameters. Read more
Source§

fn insert_if_absent<'a>( &'a self, task: &'a Task, ) -> Pin<Box<dyn Future<Output = Result<bool, A2aError>> + Send + 'a>>

Atomically inserts a task only if no task with the same ID exists. Read more
Source§

fn delete<'a>( &'a self, id: &'a TaskId, ) -> Pin<Box<dyn Future<Output = Result<(), A2aError>> + Send + 'a>>

Deletes a task by its ID. Read more
Source§

fn count<'a>( &'a self, ) -> Pin<Box<dyn Future<Output = Result<u64, A2aError>> + Send + 'a>>

Returns the total number of tasks in the store. Read more
Source§

fn event_log_covers<'a>( &'a self, task_id: &'a TaskId, after_seq: u64, ) -> Pin<Box<dyn Future<Output = Result<bool, A2aError>> + Send + 'a>>

Whether a replay starting after after_seq can be served in full. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> FutureExt for T

Source§

fn with_context(self, otel_cx: Context) -> WithContext<Self> ⓘ

Attaches the provided Context to this type, returning a WithContext wrapper. Read more
Source§

fn with_current_context(self) -> WithContext<Self> ⓘ

Attaches the current Context to this type, returning a WithContext wrapper. Read more
Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> IntoRequest<T> for T

Source§

fn into_request(self) -> Request<T>

Wrap the input message T in a tonic::Request
Source§

impl<L> LayerExt<L> for L

Source§

fn named_layer<S>(&self, service: S) -> Layered<<L as Layer<S>>::Service, S>
where L: Layer<S>,

Applies the layer to a service and wraps it in Layered.
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more