use std::future::Future;
use std::pin::Pin;
use a2a_protocol_types::error::{A2aError, A2aResult};
use a2a_protocol_types::params::ListTasksParams;
use a2a_protocol_types::responses::TaskListResponse;
use a2a_protocol_types::task::{Task, TaskId};
use sqlx::sqlite::SqlitePool;
use super::event_log_sql::{
decode_text_row, encode_text, limit_to_i64, report_no_op_append, seq_from_i64, seq_to_i64,
};
use super::task_store::{RecordedEvent, TaskStore};
use super::tenant::TenantContext;
use super::tenant_event_log as evlog;
use super::tenant_idempotency as idem;
use crate::metrics::MetricsHandle;
#[derive(Debug, Clone)]
pub struct TenantAwareSqliteTaskStore {
pool: SqlitePool,
max_page_size: u32,
metrics: MetricsHandle,
}
impl TenantAwareSqliteTaskStore {
#[must_use]
pub const fn with_max_page_size(mut self, max: u32) -> Self {
self.max_page_size = max;
self
}
#[must_use]
pub fn with_metrics(mut self, metrics: MetricsHandle) -> Self {
self.metrics = metrics;
self
}
pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
let pool = sqlite_pool(url).await?;
Self::from_pool(pool).await
}
pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS tenant_tasks (
tenant_id TEXT NOT NULL DEFAULT '',
id TEXT NOT NULL,
context_id TEXT NOT NULL,
state TEXT NOT NULL,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (tenant_id, id)
)",
)
.execute(&pool)
.await?;
sqlx::query(idem::SQLITE_CREATE_TABLE)
.execute(&pool)
.await?;
sqlx::query(evlog::SQLITE_CREATE_TABLE)
.execute(&pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
)
.execute(&pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
)
.execute(&pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx_state ON tenant_tasks(tenant_id, context_id, state)",
)
.execute(&pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
)
.execute(&pool)
.await?;
Ok(Self {
pool,
max_page_size: crate::store::DEFAULT_MAX_PAGE_SIZE,
metrics: MetricsHandle::default(),
})
}
pub async fn purge_expired(
&self,
policy: &super::retention::RetentionPolicy,
) -> A2aResult<super::retention::PurgeReport> {
super::retention::sqlite::purge(
&self.pool,
"tenant_tasks",
&[evlog::SQLITE_DELETE_ORPHANS],
super::tenant_idempotency::SQLITE_EXPIRE,
policy,
)
.await
.map_err(|e| to_a2a_error(&e))
}
}
use crate::sqlite_pool::sqlite_pool;
fn to_a2a_error(e: &sqlx::Error) -> A2aError {
A2aError::internal(format!("sqlite error: {e}"))
}
#[allow(clippy::manual_async_fn)]
impl TaskStore for TenantAwareSqliteTaskStore {
fn supports_idempotency(&self) -> bool {
true
}
fn claim_idempotency_key<'a>(
&'a self,
key: &'a str,
message_id: &'a a2a_protocol_types::message::MessageId,
task_id: &'a TaskId,
) -> Pin<
Box<dyn Future<Output = A2aResult<crate::store::task_store::IdempotencyClaim>> + Send + 'a>,
> {
use crate::store::task_store::IdempotencyClaim;
use sqlx::Row as _;
Box::pin(async move {
let tenant = TenantContext::current();
let mut tx = self.pool.begin().await.map_err(|e| to_a2a_error(&e))?;
let inserted = sqlx::query(idem::SQLITE_CLAIM)
.bind(&tenant)
.bind(key)
.bind(message_id.0.as_str())
.bind(task_id.0.as_str())
.execute(&mut *tx)
.await
.map_err(|e| to_a2a_error(&e))?
.rows_affected();
if inserted == 1 {
tx.commit().await.map_err(|e| to_a2a_error(&e))?;
return Ok(IdempotencyClaim::Claimed);
}
let holder = sqlx::query(idem::SQLITE_HOLDER)
.bind(&tenant)
.bind(key)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_a2a_error(&e))?;
tx.commit().await.map_err(|e| to_a2a_error(&e))?;
let Some(row) = holder else {
return Err(A2aError::internal(
"idempotency key was neither claimed nor held; the row vanished \
inside the claiming transaction",
));
};
let held_by: String = row.try_get("message_id").map_err(|e| to_a2a_error(&e))?;
let held_task: String = row.try_get("task_id").map_err(|e| to_a2a_error(&e))?;
Ok(idem::outcome(held_by, held_task, message_id))
})
}
fn release_idempotency_key<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
sqlx::query(idem::SQLITE_RELEASE)
.bind(&tenant)
.bind(key)
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
Ok(())
})
}
fn save<'a>(
&'a self,
task: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
let id = task.id.0.as_str();
let context_id = task.context_id.0.as_str();
let state = task.status.state.to_string();
let data = serde_json::to_string(task)
.map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
sqlx::query(
"INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))
ON CONFLICT(tenant_id, id) DO UPDATE SET
context_id = excluded.context_id,
state = excluded.state,
data = excluded.data,
updated_at = excluded.updated_at",
)
.bind(&tenant)
.bind(id)
.bind(context_id)
.bind(&state)
.bind(&data)
.bind(&status_ts)
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
Ok(())
})
}
fn get<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
let row: Option<(String,)> =
sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
.bind(&tenant)
.bind(id.0.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
match row {
Some((data,)) => {
let task: Task = serde_json::from_str(&data)
.map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
Ok(Some(task))
}
None => Ok(None),
}
})
}
#[allow(clippy::too_many_lines)]
fn list<'a>(
&'a self,
params: &'a ListTasksParams,
) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
let mut conditions = vec!["tenant_id = ?1".to_string()];
let mut bind_values: Vec<String> = vec![tenant];
if let Some(ref ctx) = params.context_id {
conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
bind_values.push(ctx.clone());
}
if let Some(ref status) = params.status {
conditions.push(format!("state = ?{}", bind_values.len() + 1));
bind_values.push(status.to_string());
}
if let Some(ref after) = params.status_timestamp_after {
let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
return Ok(TaskListResponse::new(Vec::new()));
};
conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
bind_values.push(after_dt);
}
if let Some(ref token) = params.page_token {
let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
return Ok(TaskListResponse::new(Vec::new()));
};
let p = bind_values.len();
conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
bind_values.push(cursor_ua.to_string());
bind_values.push(cursor_id.to_string());
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let page_size = match params.page_size {
Some(0) | None => 50_u32,
Some(n) => n.min(self.max_page_size),
};
let limit = super::pagination::fetch_limit(page_size);
let sql = format!(
"SELECT updated_at, data FROM tenant_tasks {where_clause} \
ORDER BY updated_at DESC, id DESC LIMIT {limit}"
);
let mut query = sqlx::query_as::<_, (String, String)>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows: Vec<(String, String)> = query
.fetch_all(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
let mut rows: Vec<(String, Task)> = rows
.into_iter()
.map(|(updated_at, data)| {
serde_json::from_str::<Task>(&data)
.map(|task| (updated_at, task))
.map_err(|e| A2aError::internal(format!("deserialize: {e}")))
})
.collect::<A2aResult<Vec<_>>>()?;
let next_page_token =
if super::pagination::has_next_page(rows.len(), page_size as usize) {
rows.truncate(page_size as usize);
rows.last()
.map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
.unwrap_or_default()
} else {
String::new()
};
#[allow(clippy::cast_possible_truncation)]
let page_len = rows.len() as u32;
let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
let mut response = TaskListResponse::new(tasks);
response.next_page_token = next_page_token;
response.page_size = page_len;
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 tenant = TenantContext::current();
let id = task.id.0.as_str();
let context_id = task.context_id.0.as_str();
let state = task.status.state.to_string();
let data = serde_json::to_string(task)
.map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
let result = sqlx::query(
"INSERT OR IGNORE INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))",
)
.bind(&tenant)
.bind(id)
.bind(context_id)
.bind(&state)
.bind(&data)
.bind(&status_ts)
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
Ok(result.rows_affected() > 0)
})
}
fn delete<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
sqlx::query(evlog::SQLITE_DELETE_FOR_TASK)
.bind(&tenant)
.bind(id.0.as_str())
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
.bind(&tenant)
.bind(id.0.as_str())
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
Ok(())
})
}
fn supports_event_log(&self) -> bool {
true
}
fn append_event<'a>(
&'a self,
task_id: &'a TaskId,
seq: u64,
event: &'a a2a_protocol_types::events::StreamResponse,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
let position = seq_to_i64(seq)?;
let payload = encode_text(event)?;
let changed_nothing = sqlx::query(evlog::SQLITE_APPEND)
.bind(&tenant)
.bind(task_id.0.as_str())
.bind(position)
.bind(&payload)
.execute(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?
.rows_affected()
== 0;
if changed_nothing {
let stored: Option<(String,)> = sqlx::query_as(evlog::SQLITE_SELECT_PAYLOAD)
.bind(&tenant)
.bind(task_id.0.as_str())
.bind(position)
.fetch_optional(&self.pool)
.await
.unwrap_or(None);
let same = stored.is_some_and(|(held,)| held == payload);
report_no_op_append(&*self.metrics, task_id, seq, same);
}
Ok(())
})
}
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 tenant = TenantContext::current();
let (max,): (i64,) = sqlx::query_as(evlog::SQLITE_LAST_SEQ)
.bind(&tenant)
.bind(task_id.0.as_str())
.fetch_one(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
seq_from_i64(max)
})
}
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 tenant = TenantContext::current();
let (min,): (Option<i64>,) = sqlx::query_as(evlog::SQLITE_EARLIEST_SEQ)
.bind(&tenant)
.bind(task_id.0.as_str())
.fetch_one(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
min.map(seq_from_i64).transpose()
})
}
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 tenant = TenantContext::current();
let rows: Vec<(i64, String)> = sqlx::query_as(evlog::SQLITE_SELECT_AFTER)
.bind(&tenant)
.bind(task_id.0.as_str())
.bind(seq_to_i64(after_seq)?)
.bind(limit_to_i64(limit))
.fetch_all(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
rows.into_iter().map(decode_text_row).collect()
})
}
fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
Box::pin(async move {
let tenant = TenantContext::current();
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = ?1")
.bind(&tenant)
.fetch_one(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
#[allow(clippy::cast_sign_loss)]
Ok(row.0 as u64)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
async fn make_store() -> TenantAwareSqliteTaskStore {
TenantAwareSqliteTaskStore::new("sqlite::memory:")
.await
.expect("failed to create in-memory tenant store")
}
fn make_task(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 sqlite_tenant_store_advertises_idempotency_support() {
let store = make_store().await;
assert!(
store.supports_idempotency(),
"the tenant-aware SQLite store implements claim_idempotency_key; \
it must advertise support"
);
}
#[tokio::test]
async fn purging_one_tenant_leaves_the_same_id_under_another() {
use crate::store::retention::RetentionPolicy;
use std::time::Duration;
let store = make_store().await;
for tenant in ["acme", "globex"] {
TenantContext::scope(tenant, async {
store
.save(&make_task("shared-id", "ctx", TaskState::Completed))
.await
.unwrap();
})
.await;
}
sqlx::query(
"UPDATE tenant_tasks \
SET updated_at = strftime('%Y-%m-%d %H:%M:%f','now','-7200 seconds') \
WHERE tenant_id = 'acme'",
)
.execute(&store.pool)
.await
.expect("backdate");
let report = store
.purge_expired(&RetentionPolicy::new(Duration::from_secs(3_600)))
.await
.expect("purge");
assert_eq!(report.tasks_deleted, 1, "only acme's copy was old enough");
TenantContext::scope("globex", async {
assert!(
store
.get(&TaskId::new("shared-id"))
.await
.unwrap()
.is_some(),
"globex must still have its own task of the same id"
);
})
.await;
TenantContext::scope("acme", async {
assert!(
store
.get(&TaskId::new("shared-id"))
.await
.unwrap()
.is_none(),
"acme's expired copy should be gone"
);
})
.await;
}
#[tokio::test]
async fn save_and_get_within_tenant() {
let store = make_store().await;
TenantContext::scope("acme", async {
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
let task = store.get(&TaskId::new("t1")).await.unwrap();
assert!(
task.is_some(),
"task should be retrievable within its tenant"
);
assert_eq!(task.unwrap().id, TaskId::new("t1"));
})
.await;
}
#[tokio::test]
async fn tenant_isolation_get() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
let result = store.get(&TaskId::new("t1")).await.unwrap();
assert!(result.is_none(), "tenant-b should not see tenant-a's task");
})
.await;
}
#[tokio::test]
async fn tenant_isolation_list() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx1", TaskState::Working))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
store
.save(&make_task("t3", "ctx1", TaskState::Submitted))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-a", async {
let response = store.list(&ListTasksParams::default()).await.unwrap();
assert_eq!(
response.tasks.len(),
2,
"tenant-a should see only its 2 tasks"
);
})
.await;
TenantContext::scope("tenant-b", async {
let response = store.list(&ListTasksParams::default()).await.unwrap();
assert_eq!(
response.tasks.len(),
1,
"tenant-b should see only its 1 task"
);
})
.await;
}
#[tokio::test]
async fn tenant_isolation_count() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
store
.save(&make_task("t2", "ctx1", TaskState::Working))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
let count = store.count().await.unwrap();
assert_eq!(count, 0, "tenant-b should have zero tasks");
})
.await;
TenantContext::scope("tenant-a", async {
let count = store.count().await.unwrap();
assert_eq!(count, 2, "tenant-a should have 2 tasks");
})
.await;
}
#[tokio::test]
async fn tenant_isolation_delete() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
store.delete(&TaskId::new("t1")).await.unwrap();
})
.await;
TenantContext::scope("tenant-a", async {
let task = store.get(&TaskId::new("t1")).await.unwrap();
assert!(
task.is_some(),
"tenant-a's task should still exist after tenant-b's delete"
);
})
.await;
}
#[tokio::test]
async fn same_task_id_different_tenants() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
store
.save(&make_task("t1", "ctx-a", TaskState::Submitted))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
store
.save(&make_task("t1", "ctx-b", TaskState::Working))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-a", async {
let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
task.context_id,
ContextId::new("ctx-a"),
"tenant-a should get its own version of t1"
);
assert_eq!(task.status.state, TaskState::Submitted);
})
.await;
TenantContext::scope("tenant-b", async {
let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
assert_eq!(
task.context_id,
ContextId::new("ctx-b"),
"tenant-b should get its own version of t1"
);
assert_eq!(task.status.state, TaskState::Working);
})
.await;
}
#[tokio::test]
async fn insert_if_absent_respects_tenant_scope() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
let inserted = store
.insert_if_absent(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
assert!(inserted, "first insert should succeed");
let inserted = store
.insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
.await
.unwrap();
assert!(!inserted, "duplicate insert in same tenant should fail");
})
.await;
TenantContext::scope("tenant-b", async {
let inserted = store
.insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
.await
.unwrap();
assert!(
inserted,
"insert of same task id in different tenant should succeed"
);
})
.await;
}
#[tokio::test]
async fn list_pagination_within_tenant() {
let store = make_store().await;
TenantContext::scope("tenant-a", async {
for i in 0..5 {
store
.save(&make_task(
&format!("task-{i:03}"),
"ctx1",
TaskState::Submitted,
))
.await
.unwrap();
}
let params = ListTasksParams {
page_size: Some(2),
..Default::default()
};
let response = store.list(¶ms).await.unwrap();
assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
assert!(
!response.next_page_token.is_empty(),
"should have a next page token"
);
let params2 = ListTasksParams {
page_size: Some(2),
page_token: Some(response.next_page_token),
..Default::default()
};
let response2 = store.list(¶ms2).await.unwrap();
assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
})
.await;
}
#[test]
fn to_a2a_error_formats_message() {
let sqlite_err = sqlx::Error::RowNotFound;
let a2a_err = to_a2a_error(&sqlite_err);
let msg = format!("{a2a_err}");
assert!(
msg.contains("sqlite error"),
"error message should contain 'sqlite error': {msg}"
);
}
#[tokio::test]
async fn default_tenant_context_uses_empty_string() {
let store = make_store().await;
store
.save(&make_task("t1", "ctx1", TaskState::Submitted))
.await
.unwrap();
let task = store.get(&TaskId::new("t1")).await.unwrap();
assert!(task.is_some(), "default (empty) tenant should work");
}
}
#[cfg(test)]
mod idempotency_tests {
use super::*;
use crate::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
const KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
async fn store() -> TenantAwareSqliteTaskStore {
TenantAwareSqliteTaskStore::new("sqlite::memory:")
.await
.expect("in-memory tenant store")
}
#[tokio::test]
async fn one_tenants_key_never_names_another_tenants_task() {
let store = store().await;
let a = TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
.await
.unwrap()
})
.await;
assert_eq!(a, IdempotencyClaim::Claimed);
let b = TenantContext::scope("tenant-b", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-b"))
.await
.unwrap()
})
.await;
assert_eq!(
b,
IdempotencyClaim::Claimed,
"tenant-b must claim its own key, not replay tenant-a's task"
);
}
#[tokio::test]
async fn a_retry_within_one_tenant_replays() {
let store = store().await;
TenantContext::scope("tenant-a", async {
assert_eq!(
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
.await
.unwrap(),
IdempotencyClaim::Claimed
);
assert_eq!(
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("other"))
.await
.unwrap(),
IdempotencyClaim::Replay(TaskId::new("task-a"))
);
})
.await;
}
#[tokio::test]
async fn a_reused_key_conflicts_within_its_own_tenant() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
.await
.unwrap();
assert_eq!(
store
.claim_idempotency_key(KEY, &MessageId::new("m2"), &TaskId::new("task-b"))
.await
.unwrap(),
IdempotencyClaim::Conflict {
held_by: MessageId::new("m1")
}
);
})
.await;
}
#[tokio::test]
async fn releasing_in_one_tenant_leaves_another_tenants_key_held() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
.await
.unwrap();
})
.await;
TenantContext::scope("tenant-b", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-b"))
.await
.unwrap();
store.release_idempotency_key(KEY).await.unwrap();
})
.await;
let still_held = TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("ignored"))
.await
.unwrap()
})
.await;
assert_eq!(
still_held,
IdempotencyClaim::Replay(TaskId::new("task-a")),
"a release must not reach across the tenant boundary"
);
}
}
#[cfg(test)]
mod event_log_tests {
use super::*;
use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
async fn store() -> TenantAwareSqliteTaskStore {
TenantAwareSqliteTaskStore::new("sqlite::memory:")
.await
.expect("in-memory tenant store")
}
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(id: &str, state: TaskState) -> StreamResponse {
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new(id),
context_id: ContextId::new("c-1"),
status: TaskStatus::new(state),
metadata: None,
})
}
fn states(events: &[RecordedEvent]) -> Vec<TaskState> {
events
.iter()
.map(|r| match &r.event {
StreamResponse::StatusUpdate(u) => u.status.state,
other => panic!("only status events are written here, got {other:?}"),
})
.collect()
}
#[tokio::test]
async fn the_tenant_store_reports_that_it_keeps_a_log() {
assert!(store().await.supports_event_log());
}
#[tokio::test]
async fn one_tenants_task_id_never_reaches_another_tenants_log() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store.save(&task("shared-id")).await.expect("save a");
store
.append_event(
&TaskId::new("shared-id"),
1,
&event("shared-id", TaskState::Working),
)
.await
.expect("append a");
})
.await;
TenantContext::scope("tenant-b", async {
store.save(&task("shared-id")).await.expect("save b");
assert_eq!(
store
.last_event_seq(&TaskId::new("shared-id"))
.await
.expect("last b"),
0,
"tenant-b's log for this id is its own, and it is empty"
);
assert!(
store
.read_events(&TaskId::new("shared-id"), 0, 10)
.await
.expect("read b")
.is_empty(),
"tenant-b must not be handed tenant-a's events"
);
store
.append_event(
&TaskId::new("shared-id"),
1,
&event("shared-id", TaskState::Completed),
)
.await
.expect("append b");
})
.await;
TenantContext::scope("tenant-a", async {
assert_eq!(
states(
&store
.read_events(&TaskId::new("shared-id"), 0, 10)
.await
.expect("read a")
),
vec![TaskState::Working],
);
})
.await;
}
#[tokio::test]
async fn events_round_trip_in_order_with_their_positions() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store.save(&task("t-1")).await.expect("save");
for (seq, state) in [
(1, TaskState::Submitted),
(2, TaskState::Working),
(3, TaskState::Completed),
] {
store
.append_event(&TaskId::new("t-1"), seq, &event("t-1", state))
.await
.expect("append");
}
let all = store
.read_events(&TaskId::new("t-1"), 0, 100)
.await
.expect("read");
assert_eq!(all.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
states(&all),
vec![
TaskState::Submitted,
TaskState::Working,
TaskState::Completed
],
);
assert_eq!(
store
.last_event_seq(&TaskId::new("t-1"))
.await
.expect("last"),
3
);
})
.await;
}
#[tokio::test]
async fn appending_the_same_position_twice_leaves_one_row() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store.save(&task("t-1")).await.expect("save");
store
.append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Working))
.await
.expect("append");
store
.append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Completed))
.await
.expect("replay must not error");
let all = store
.read_events(&TaskId::new("t-1"), 0, 10)
.await
.expect("read");
assert_eq!(all.len(), 1, "one position, one row");
assert_eq!(
states(&all),
vec![TaskState::Working],
"the first write wins; a replay must not rewrite history"
);
})
.await;
}
#[tokio::test]
async fn reading_after_an_offset_is_exclusive_and_honours_the_limit() {
let store = store().await;
TenantContext::scope("tenant-a", async {
store.save(&task("t-1")).await.expect("save");
for seq in 1..=5 {
store
.append_event(&TaskId::new("t-1"), seq, &event("t-1", TaskState::Working))
.await
.expect("append");
}
let after_two = store
.read_events(&TaskId::new("t-1"), 2, 100)
.await
.expect("read");
assert_eq!(after_two.first().map(|r| r.seq), Some(3), "exclusive");
assert_eq!(after_two.len(), 3);
assert_eq!(
store
.read_events(&TaskId::new("t-1"), 0, 2)
.await
.expect("read")
.len(),
2
);
})
.await;
}
#[tokio::test]
async fn deleting_a_task_removes_its_log_and_leaves_other_tenants_alone() {
let store = store().await;
for tenant in ["tenant-a", "tenant-b"] {
TenantContext::scope(tenant, async {
store.save(&task("t-1")).await.expect("save");
store
.append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Working))
.await
.expect("append");
})
.await;
}
TenantContext::scope("tenant-a", async {
store.delete(&TaskId::new("t-1")).await.expect("delete");
assert_eq!(
store
.last_event_seq(&TaskId::new("t-1"))
.await
.expect("last"),
0
);
})
.await;
TenantContext::scope("tenant-b", async {
assert_eq!(
store
.last_event_seq(&TaskId::new("t-1"))
.await
.expect("last"),
1,
"a delete in one tenant must not reach across the boundary"
);
})
.await;
}
#[tokio::test]
async fn a_retention_sweep_reclaims_orphaned_events() {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr as _;
let opts = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("options")
.pragma("foreign_keys", "OFF")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.expect("pool");
let store = TenantAwareSqliteTaskStore::from_pool(pool.clone())
.await
.expect("store from a pool without the pragma");
TenantContext::scope("tenant-a", async {
let mut done = task("t-1");
done.status = TaskStatus::new(TaskState::Completed);
store.save(&done).await.expect("save");
store
.append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Completed))
.await
.expect("append");
})
.await;
sqlx::query("UPDATE tenant_tasks SET updated_at = '2000-01-01 00:00:00.000'")
.execute(&pool)
.await
.expect("backdate");
let report = store
.purge_expired(&crate::store::RetentionPolicy::new(
std::time::Duration::from_secs(60),
))
.await
.expect("purge");
assert_eq!(report.tasks_deleted, 1);
assert_eq!(
report.orphan_rows_deleted, 1,
"without the pragma the cascade does not fire, so the sweep must \
reclaim the event row itself"
);
TenantContext::scope("tenant-a", async {
assert_eq!(
store
.last_event_seq(&TaskId::new("t-1"))
.await
.expect("last"),
0
);
})
.await;
}
}