#![cfg(feature = "postgres")]
use a2a_protocol_server::push::{
PostgresPushConfigStore, PushConfigStore, TenantAwarePostgresPushConfigStore,
};
use a2a_protocol_server::store::ArtifactDelta;
use a2a_protocol_server::store::tenant::TenantContext;
use a2a_protocol_server::store::{
BUILTIN_PG_MIGRATIONS, PgMigrationRunner, PostgresTaskStore, RecordedEvent, RetentionPolicy,
TaskStore, TenantAwarePostgresTaskStore,
};
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
use a2a_protocol_types::message::MessageId;
use a2a_protocol_types::params::ListTasksParams;
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
use std::time::Duration;
const URL_ENV: &str = "A2A_TEST_POSTGRES_URL";
struct TestDb {
admin_url: String,
name: String,
url: String,
}
impl TestDb {
async fn create(prefix: &str) -> Self {
let admin_url = std::env::var(URL_ENV).unwrap_or_else(|_| {
panic!(
"{URL_ENV} must point at a live PostgreSQL server \
(e.g. postgres://postgres:postgres@localhost:5432/postgres) \
to run the ignored postgres integration tests"
)
});
let (base, admin_db) = admin_url
.rsplit_once('/')
.expect("admin URL must include a database path, e.g. .../postgres");
assert!(
!admin_db.is_empty() && !admin_db.contains('@'),
"admin URL must end in a database name, e.g. .../postgres"
);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock before unix epoch")
.as_nanos();
let name = format!("a2a_test_{prefix}_{nanos}");
let admin = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&admin_url)
.await
.expect("connect to admin database");
sqlx::query(&format!("CREATE DATABASE \"{name}\""))
.execute(&admin)
.await
.expect("create scratch database");
admin.close().await;
let url = format!("{base}/{name}");
Self {
admin_url,
name,
url,
}
}
async fn drop_db(self) {
let admin = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&self.admin_url)
.await
.expect("connect to admin database");
sqlx::query(&format!(
"DROP DATABASE IF EXISTS \"{}\" WITH (FORCE)",
self.name
))
.execute(&admin)
.await
.expect("drop scratch database");
admin.close().await;
}
}
fn make_task(id: &str, context_id: &str) -> Task {
Task {
id: TaskId(id.to_string()),
context_id: ContextId(context_id.to_string()),
status: TaskStatus::new(TaskState::Submitted),
artifacts: None,
history: None,
metadata: None,
}
}
fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
task_id: Some(task_id.to_string()),
id: None,
tenant: None,
url: "https://example.com/push".to_string(),
token: Some("tok".to_string()),
authentication: None,
}
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_store_advertises_idempotency_support() {
let db = TestDb::create("tenant_idem_support").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
assert!(
store.supports_idempotency(),
"the tenant-aware Postgres store implements claim_idempotency_key; \
it must advertise support"
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_save_and_get() -> A2aResult<()> {
let db = TestDb::create("save_get").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
let got = store.get(&TaskId("t1".into())).await?;
assert!(got.is_some());
let got = got.unwrap();
assert_eq!(got.id.0, "t1");
assert_eq!(got.context_id.0, "ctx1");
assert_eq!(got.status.state, TaskState::Submitted);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_get_missing() -> A2aResult<()> {
let db = TestDb::create("get_missing").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
assert!(store.get(&TaskId("nope".into())).await?.is_none());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_save_upsert() -> A2aResult<()> {
let db = TestDb::create("upsert").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let mut task = make_task("t1", "ctx1");
store.save(&task).await?;
task.status = TaskStatus::new(TaskState::Working);
store.save(&task).await?;
let got = store.get(&TaskId("t1".into())).await?.unwrap();
assert_eq!(got.status.state, TaskState::Working);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_insert_if_absent() -> A2aResult<()> {
let db = TestDb::create("insert_absent").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
assert!(store.insert_if_absent(&task).await?);
assert!(!store.insert_if_absent(&task).await?);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_delete() -> A2aResult<()> {
let db = TestDb::create("delete").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
store.save(&make_task("t1", "ctx1")).await?;
store.delete(&TaskId("t1".into())).await?;
assert!(store.get(&TaskId("t1".into())).await?.is_none());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_count() -> A2aResult<()> {
let db = TestDb::create("count").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
assert_eq!(store.count().await?, 0);
store.save(&make_task("t1", "ctx1")).await?;
store.save(&make_task("t2", "ctx1")).await?;
assert_eq!(store.count().await?, 2);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_list_basic() -> A2aResult<()> {
let db = TestDb::create("list_basic").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
store.save(&make_task("a", "ctx1")).await?;
store.save(&make_task("b", "ctx1")).await?;
store.save(&make_task("c", "ctx2")).await?;
let all = store.list(&ListTasksParams::default()).await?;
assert_eq!(all.tasks.len(), 3);
let filtered = store
.list(&ListTasksParams {
context_id: Some("ctx1".into()),
..Default::default()
})
.await?;
assert_eq!(filtered.tasks.len(), 2);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_list_orders_most_recently_updated_first() -> A2aResult<()> {
let db = TestDb::create("list_order").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
for id in ["c", "a", "b"] {
store.save(&make_task(id, "ctx1")).await?;
tokio::time::sleep(std::time::Duration::from_millis(3)).await;
}
let ordered = store.list(&ListTasksParams::default()).await?;
let ids: Vec<&str> = ordered.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(ids, vec!["b", "a", "c"], "most-recently-updated first");
tokio::time::sleep(std::time::Duration::from_millis(3)).await;
store.save(&make_task("a", "ctx1")).await?;
let reordered = store.list(&ListTasksParams::default()).await?;
let ids: Vec<&str> = reordered.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "updated task moves to the front");
let mut seen = std::collections::HashSet::new();
let mut token: Option<String> = None;
loop {
let page = store
.list(&ListTasksParams {
page_size: Some(2),
page_token: token.clone(),
..Default::default()
})
.await?;
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;
}
token = Some(page.next_page_token);
}
assert_eq!(seen.len(), 3, "every task visited exactly once");
let forged = store
.list(&ListTasksParams {
page_token: Some("forged-no-separator".into()),
..Default::default()
})
.await?;
assert!(forged.tasks.is_empty(), "forged cursor yields empty page");
db.drop_db().await;
Ok(())
}
fn make_task_with_ts(id: &str, context_id: &str, ts: &str) -> Task {
let mut task = make_task(id, context_id);
task.status.timestamp = Some(ts.to_owned());
task
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_list_status_timestamp_ordering_and_filter() -> A2aResult<()> {
let db = TestDb::create("status_ts").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
for (id, ts) in [
("middle", "2026-01-02T00:00:00.000Z"),
("newest", "2026-01-03T00:00:00.500Z"),
("oldest", "2026-01-01T00:00:00.000Z"),
] {
store.save(&make_task_with_ts(id, "ctx1", ts)).await?;
}
let ordered = store.list(&ListTasksParams::default()).await?;
let ids: Vec<&str> = ordered.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newest", "middle", "oldest"],
"list must sort by status timestamp descending"
);
store
.save(&make_task_with_ts(
"oldest",
"ctx1",
"2026-01-01T00:00:00.000Z",
))
.await?;
let after_resave = store.list(&ListTasksParams::default()).await?;
let ids: Vec<&str> = after_resave.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(
ids,
vec!["newest", "middle", "oldest"],
"a status-preserving re-save must not reorder the list"
);
let filtered = store
.list(&ListTasksParams {
status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
..Default::default()
})
.await?;
let ids: Vec<&str> = filtered.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(ids, vec!["newest"], "boundary task must be excluded");
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn task_list_pagination() -> A2aResult<()> {
let db = TestDb::create("pagination").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
for i in 0..5 {
store.save(&make_task(&format!("t{i:02}"), "ctx")).await?;
}
let page1 = store
.list(&ListTasksParams {
page_size: Some(2),
..Default::default()
})
.await?;
assert_eq!(page1.tasks.len(), 2);
assert!(!page1.next_page_token.is_empty());
let page2 = store
.list(&ListTasksParams {
page_size: Some(2),
page_token: Some(page1.next_page_token),
..Default::default()
})
.await?;
assert_eq!(page2.tasks.len(), 2);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn migrations_apply_in_order_and_are_idempotent() {
let db = TestDb::create("migrations").await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect to scratch database");
let runner = PgMigrationRunner::new(pool.clone());
assert_eq!(
runner.current_version().await.expect("current_version"),
0,
"fresh database starts at version 0"
);
let expected: Vec<u32> = BUILTIN_PG_MIGRATIONS.iter().map(|m| m.version).collect();
let head = *expected.last().expect("there is at least one migration");
assert_eq!(
expected,
(1..=head).collect::<Vec<u32>>(),
"versions must be contiguous and ascending from 1 — a gap or a \
duplicate would make `current_version` a number that cannot be \
reasoned about"
);
assert_eq!(
runner
.pending_migrations()
.await
.expect("pending_migrations")
.len(),
expected.len(),
"every built-in migration should be pending on a fresh database"
);
let applied = runner.run_pending().await.expect("run_pending");
assert_eq!(applied, expected, "migrations apply in version order");
assert_eq!(
runner.current_version().await.expect("current_version"),
head
);
assert!(
runner
.pending_migrations()
.await
.expect("pending_migrations after migrating")
.is_empty(),
"a fully migrated database has nothing pending"
);
let reapplied = runner.run_pending().await.expect("run_pending again");
assert!(reapplied.is_empty(), "second run applies nothing");
let store = PostgresTaskStore::from_pool(pool)
.await
.expect("store from migrated pool");
store
.save(&make_task("t1", "ctx1"))
.await
.expect("save on migrated schema");
assert!(
store
.get(&TaskId("t1".into()))
.await
.expect("get on migrated schema")
.is_some()
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn push_set_get_list_delete() -> A2aResult<()> {
let db = TestDb::create("push_crud").await;
let store = PostgresPushConfigStore::new(&db.url)
.await
.expect("open postgres push store");
let config = store.set(make_push_config("t1")).await?;
let id = config.id.clone().expect("id auto-generated");
let got = store.get("t1", &id).await?;
assert!(got.is_some());
assert_eq!(got.unwrap().task_id.as_deref(), Some("t1"));
assert!(store.get("t1", "nope").await?.is_none());
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t2")).await?;
assert_eq!(store.list("t1").await?.len(), 2);
assert_eq!(store.list("t2").await?.len(), 1);
store.delete("t1", &id).await?;
assert!(store.get("t1", &id).await?.is_none());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn push_count_spans_tasks() -> A2aResult<()> {
let db = TestDb::create("push_count").await;
let store = PostgresPushConfigStore::new(&db.url)
.await
.expect("open postgres push store");
assert_eq!(store.count().await?, Some(0));
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t2")).await?;
assert_eq!(store.count().await?, Some(3));
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_push_count_is_per_tenant() -> A2aResult<()> {
let db = TestDb::create("tenant_push_count").await;
let store = TenantAwarePostgresPushConfigStore::new(&db.url)
.await
.expect("open tenant postgres push store");
TenantContext::scope("acme", async {
store.set(make_push_config("t1")).await?;
store.set(make_push_config("t2")).await?;
A2aResult::Ok(())
})
.await?;
TenantContext::scope("globex", store.set(make_push_config("t1"))).await?;
let acme = TenantContext::scope("acme", store.count()).await?;
let globex = TenantContext::scope("globex", store.count()).await?;
let unseen = TenantContext::scope("initech", store.count()).await?;
assert_eq!((acme, globex, unseen), (Some(2), Some(1), Some(0)));
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn push_upsert() -> A2aResult<()> {
let db = TestDb::create("push_upsert").await;
let store = PostgresPushConfigStore::new(&db.url)
.await
.expect("open postgres push store");
let mut config = make_push_config("t1");
config.id = Some("fixed-id".into());
store.set(config.clone()).await?;
config.url = "https://example.com/v2".to_string();
store.set(config).await?;
let configs = store.list("t1").await?;
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].url, "https://example.com/v2");
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_task_store_isolates_tenants() {
let db = TestDb::create("tenant_tasks").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant postgres store");
TenantContext::scope("acme", async {
store
.save(&make_task("t1", "ctx1"))
.await
.expect("save under acme");
assert!(
store
.get(&TaskId("t1".into()))
.await
.expect("get under acme")
.is_some()
);
})
.await;
TenantContext::scope("globex", async {
assert!(
store
.get(&TaskId("t1".into()))
.await
.expect("get under globex")
.is_none(),
"tenant globex must not see acme's task"
);
let list = store
.list(&ListTasksParams::default())
.await
.expect("list under globex");
assert!(list.tasks.is_empty(), "tenant globex must list no tasks");
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_task_insert_if_absent_reports_insertion_and_is_tenant_scoped() {
let db = TestDb::create("tenant_insert_if_absent").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant postgres store");
TenantContext::scope("acme", async {
assert!(
store
.insert_if_absent(&make_task("t1", "ctx1"))
.await
.expect("first insert"),
"a fresh key must report that it was inserted"
);
assert!(
!store
.insert_if_absent(&make_task("t1", "ctx1"))
.await
.expect("duplicate insert"),
"a duplicate must report that nothing was inserted; reporting \
true would make the method useless as a claim primitive"
);
})
.await;
TenantContext::scope("globex", async {
assert!(
store
.insert_if_absent(&make_task("t1", "ctx1"))
.await
.expect("insert under a second tenant"),
"task ids are scoped per tenant, so globex must be able to claim \
an id acme already holds"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_push_store_isolates_tenants() {
let db = TestDb::create("tenant_push").await;
let store = TenantAwarePostgresPushConfigStore::new(&db.url)
.await
.expect("open tenant postgres push store");
let id = TenantContext::scope("acme", async {
let saved = store
.set(make_push_config("task-1"))
.await
.expect("set under acme");
let id = saved.id.expect("id auto-generated");
assert!(
store
.get("task-1", &id)
.await
.expect("get under acme")
.is_some()
);
id
})
.await;
TenantContext::scope("globex", async {
assert!(
store
.get("task-1", &id)
.await
.expect("get under globex")
.is_none(),
"tenant globex must not see acme's push config"
);
assert!(
store
.list("task-1")
.await
.expect("list under globex")
.is_empty(),
"tenant globex must list no push configs"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_task_store_filters_paginates_deletes_and_counts() -> A2aResult<()> {
let db = TestDb::create("tenant_list").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant postgres store");
TenantContext::scope("acme", async {
for (id, ctx, ts) in [
("a1", "ctx-a", "2026-01-01T00:00:00.000Z"),
("a2", "ctx-a", "2026-01-02T00:00:00.000Z"),
("b1", "ctx-b", "2026-01-03T00:00:00.000Z"),
] {
store.save(&make_task_with_ts(id, ctx, ts)).await?;
}
let mut working = make_task_with_ts("b2", "ctx-b", "2026-01-04T00:00:00.000Z");
working.status.state = TaskState::Working;
store.save(&working).await?;
let by_ctx = store
.list(&ListTasksParams {
context_id: Some("ctx-a".into()),
..ListTasksParams::default()
})
.await?;
let mut ids: Vec<&str> = by_ctx.tasks.iter().map(|t| t.id.0.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["a1", "a2"], "context filter");
let by_status = store
.list(&ListTasksParams {
status: Some(TaskState::Working),
..ListTasksParams::default()
})
.await?;
let ids: Vec<&str> = by_status.tasks.iter().map(|t| t.id.0.as_str()).collect();
assert_eq!(ids, vec!["b2"], "status filter");
let after = store
.list(&ListTasksParams {
status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
..ListTasksParams::default()
})
.await?;
let mut ids: Vec<&str> = after.tasks.iter().map(|t| t.id.0.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["b1", "b2"], "strictly after the second timestamp");
let garbage = store
.list(&ListTasksParams {
status_timestamp_after: Some("not-a-timestamp".into()),
..ListTasksParams::default()
})
.await?;
assert!(
garbage.tasks.is_empty(),
"unparseable filter matches nothing"
);
let mut seen = Vec::new();
let mut token: Option<String> = None;
let mut pages = 0;
loop {
let page = store
.list(&ListTasksParams {
page_size: Some(3),
page_token: token.clone(),
..ListTasksParams::default()
})
.await?;
pages += 1;
assert!(page.tasks.len() <= 3, "page size honoured");
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.clone());
}
assert_eq!(pages, 2, "4 tasks at page_size 3 is two pages");
seen.sort_unstable();
assert_eq!(
seen,
vec!["a1", "a2", "b1", "b2"],
"every task exactly once"
);
assert_eq!(store.count().await?, 4);
store.delete(&TaskId("a1".into())).await?;
assert_eq!(store.count().await?, 3);
assert!(store.get(&TaskId("a1".into())).await?.is_none());
Ok::<(), a2a_protocol_types::error::A2aError>(())
})
.await?;
TenantContext::scope("globex", async {
assert_eq!(store.count().await?, 0, "another tenant counts nothing");
store.delete(&TaskId("a2".into())).await?;
Ok::<(), a2a_protocol_types::error::A2aError>(())
})
.await?;
TenantContext::scope("acme", async {
assert!(
store.get(&TaskId("a2".into())).await?.is_some(),
"globex's delete must not reach acme's task"
);
Ok::<(), a2a_protocol_types::error::A2aError>(())
})
.await?;
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn push_config_stores_list_delete_and_count() -> A2aResult<()> {
let db = TestDb::create("push_list_count").await;
let tenant_store = TenantAwarePostgresPushConfigStore::new(&db.url)
.await
.expect("open tenant postgres push store");
let (first, second) = TenantContext::scope("acme", async {
let first = tenant_store.set(make_push_config("task-1")).await?;
let second = tenant_store.set(make_push_config("task-1")).await?;
tenant_store.set(make_push_config("task-2")).await?;
let listed = tenant_store.list("task-1").await?;
assert_eq!(listed.len(), 2, "two configs on task-1");
assert_eq!(tenant_store.count().await?, Some(3), "three in the tenant");
let first_id = first.id.clone().expect("id");
tenant_store.delete("task-1", &first_id).await?;
assert_eq!(tenant_store.list("task-1").await?.len(), 1);
assert_eq!(tenant_store.count().await?, Some(2));
Ok::<_, a2a_protocol_types::error::A2aError>((first_id, second.id.expect("id")))
})
.await?;
TenantContext::scope("globex", async {
assert_eq!(
tenant_store.count().await?,
Some(0),
"another tenant counts nothing"
);
tenant_store.delete("task-1", &second).await?;
assert!(tenant_store.get("task-1", &first).await?.is_none());
Ok::<(), a2a_protocol_types::error::A2aError>(())
})
.await?;
TenantContext::scope("acme", async {
assert!(
tenant_store.get("task-1", &second).await?.is_some(),
"globex's delete must not reach acme's config"
);
Ok::<(), a2a_protocol_types::error::A2aError>(())
})
.await?;
let plain = PostgresPushConfigStore::new(&db.url)
.await
.expect("open postgres push store");
assert_eq!(plain.count().await?, Some(0));
plain.set(make_push_config("task-9")).await?;
plain.set(make_push_config("task-9")).await?;
assert_eq!(plain.count().await?, Some(2));
db.drop_db().await;
Ok(())
}
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::message::Part;
fn artifact(id: &str, parts: usize) -> Artifact {
Artifact::new(
id,
(0..parts).map(|i| Part::text(format!("p{i}"))).collect(),
)
}
fn task_with_artifacts(id: &str, artifacts: Option<Vec<Artifact>>) -> Task {
let mut task = make_task(id, "ctx");
task.artifacts = artifacts;
task
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_appending_matches_full_save() -> A2aResult<()> {
let db = TestDb::create("delta_append").await;
let delta_store = PostgresTaskStore::new(&db.url).await.expect("delta store");
let save_db = TestDb::create("delta_append_ref").await;
let save_store = PostgresTaskStore::new(&save_db.url)
.await
.expect("save store");
let mut task = task_with_artifacts("t", Some(vec![artifact("a", 1)]));
delta_store.save(&task).await?;
save_store.save(&task).await?;
for i in 0..80 {
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text(format!("chunk{i}")));
delta_store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await?;
save_store.save(&task).await?;
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await?,
save_store.get(&id).await?,
"diverged after {i} appends"
);
}
db.drop_db().await;
save_db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_multi_part_append_preserves_order() -> A2aResult<()> {
let db = TestDb::create("delta_multi").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let mut task = task_with_artifacts("t", Some(vec![artifact("a", 1)]));
store.save(&task).await?;
task.artifacts.as_mut().unwrap()[0].parts.extend(vec![
Part::text("first"),
Part::text("second"),
Part::text("third"),
]);
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
.await?;
assert_eq!(store.get(&TaskId::new("t")).await?, Some(task));
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_pushing_matches_full_save() -> A2aResult<()> {
let db = TestDb::create("delta_push").await;
let delta_store = PostgresTaskStore::new(&db.url).await.expect("delta store");
let save_db = TestDb::create("delta_push_ref").await;
let save_store = PostgresTaskStore::new(&save_db.url)
.await
.expect("save store");
let mut task = task_with_artifacts("t", Some(vec![]));
delta_store.save(&task).await?;
save_store.save(&task).await?;
for i in 0..40 {
task.artifacts
.as_mut()
.unwrap()
.push(artifact(&format!("a{i}"), 2));
let index = task.artifacts.as_ref().unwrap().len() - 1;
delta_store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index })
.await?;
save_store.save(&task).await?;
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await?,
save_store.get(&id).await?,
"diverged after {i} pushes"
);
}
db.drop_db().await;
save_db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_push_at_a_non_last_index_falls_back() -> A2aResult<()> {
let db = TestDb::create("delta_push_wrong_index").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let mut task = task_with_artifacts("t", Some(vec![artifact("a0", 1)]));
store.save(&task).await?;
task.artifacts.as_mut().unwrap().push(artifact("a1", 1));
store.save(&task).await?;
store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
.await?;
let stored = store
.get(&TaskId::new("t"))
.await?
.expect("task should still exist");
let artifacts = stored.artifacts.as_ref().expect("artifacts");
assert_eq!(
artifacts.len(),
2,
"a mis-indexed push must fall back, not append a duplicate; got {:?}",
artifacts.iter().map(|a| &a.id).collect::<Vec<_>>()
);
assert_eq!(stored, task, "the fallback must persist the task unchanged");
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_falls_back_when_the_row_is_not_ready() -> A2aResult<()> {
let db = TestDb::create("delta_absent").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let fresh = task_with_artifacts("never-saved", Some(vec![artifact("a", 3)]));
store
.save_artifact_delta(&fresh, ArtifactDelta::Pushed { index: 0 })
.await?;
assert_eq!(
store.get(&TaskId::new("never-saved")).await?,
Some(fresh),
"an absent row must still be persisted"
);
let mut later = task_with_artifacts("no-artifacts", None);
store.save(&later).await?;
later.artifacts = Some(vec![artifact("a", 2)]);
store
.save_artifact_delta(&later, ArtifactDelta::Pushed { index: 0 })
.await?;
assert_eq!(
store.get(&TaskId::new("no-artifacts")).await?,
Some(later),
"a document with no artifacts array must take the fallback"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_inconsistent_deltas_fall_back() -> A2aResult<()> {
for (label, delta) in [
(
"index out of range",
ArtifactDelta::AppendedParts { index: 9, count: 1 },
),
(
"more parts than exist",
ArtifactDelta::AppendedParts {
index: 0,
count: 99,
},
),
(
"nothing appended",
ArtifactDelta::AppendedParts { index: 0, count: 0 },
),
("not the last position", ArtifactDelta::Pushed { index: 7 }),
] {
let db = TestDb::create("delta_bad").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let mut task = task_with_artifacts("t", Some(vec![artifact("a", 1)]));
store.save(&task).await?;
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("added"));
store.save_artifact_delta(&task, delta).await?;
assert_eq!(
store.get(&TaskId::new("t")).await?,
Some(task),
"wrong result after refusing: {label}"
);
db.drop_db().await;
}
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_delta_preserves_list_position() -> A2aResult<()> {
let db = TestDb::create("delta_order").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let older = task_with_artifacts("older", Some(vec![artifact("a", 1)]));
store.save(&older).await?;
let newer = task_with_artifacts("newer", None);
store.save(&newer).await?;
let ids = |r: a2a_protocol_types::responses::TaskListResponse| {
r.tasks.iter().map(|t| t.id.clone()).collect::<Vec<_>>()
};
let before = ids(store.list(&ListTasksParams::default()).await?);
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?;
let after = ids(store.list(&ListTasksParams::default()).await?);
assert_eq!(before, after, "appending an artifact reordered the list");
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn artifact_push_preserves_list_position() -> A2aResult<()> {
let db = TestDb::create("push_order").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let older = task_with_artifacts("older", Some(vec![artifact("a", 1)]));
store.save(&older).await?;
let newer = task_with_artifacts("newer", None);
store.save(&newer).await?;
let ids = |r: a2a_protocol_types::responses::TaskListResponse| {
r.tasks.iter().map(|t| t.id.clone()).collect::<Vec<_>>()
};
let before = ids(store.list(&ListTasksParams::default()).await?);
let mut grown = older.clone();
grown.artifacts.as_mut().unwrap().push(artifact("b", 1));
store
.save_artifact_delta(&grown, ArtifactDelta::Pushed { index: 1 })
.await?;
let after = ids(store.list(&ListTasksParams::default()).await?);
assert_eq!(before, after, "pushing an artifact reordered the list");
assert_eq!(
store.get(&TaskId::new("older")).await?,
Some(grown),
"the pushed artifact is stored"
);
db.drop_db().await;
Ok(())
}
async fn backdate(url: &str, table: &str, seconds: i64, tenant: Option<&str>) {
let pool = sqlx::PgPool::connect(url).await.expect("connect");
let sql = match tenant {
Some(_) => format!(
"UPDATE {table} SET updated_at = now() - ($1 || ' seconds')::interval \
WHERE tenant_id = $2"
),
None => format!("UPDATE {table} SET updated_at = now() - ($1 || ' seconds')::interval"),
};
let mut q = sqlx::query(&sql).bind(seconds.to_string());
if let Some(t) = tenant {
q = q.bind(t);
}
q.execute(&pool).await.expect("backdate");
pool.close().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn retention_deletes_only_aged_terminal_tasks() -> A2aResult<()> {
let db = TestDb::create("retention_basic").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
for (id, state) in [
("done-old", TaskState::Completed),
("failed-old", TaskState::Failed),
("working-old", TaskState::Working),
("input-old", TaskState::InputRequired),
] {
let mut t = make_task(id, "ctx");
t.status = TaskStatus::new(state);
store.save(&t).await?;
}
let mut fresh = make_task("done-new", "ctx");
fresh.status = TaskStatus::new(TaskState::Completed);
backdate(&db.url, "tasks", 7_200, None).await;
store.save(&fresh).await?;
let report = store
.purge_expired(&RetentionPolicy::new(Duration::from_secs(3_600)))
.await
.expect("purge");
assert_eq!(report.tasks_deleted, 2, "the two aged terminal tasks");
assert!(report.complete);
assert_eq!(
report.orphan_rows_deleted, 0,
"PostgreSQL has no journal table"
);
assert!(store.get(&TaskId("done-old".into())).await?.is_none());
assert!(store.get(&TaskId("failed-old".into())).await?.is_none());
assert!(
store.get(&TaskId("working-old".into())).await?.is_some(),
"a Working task is never eligible, however old"
);
assert!(
store.get(&TaskId("input-old".into())).await?.is_some(),
"an InputRequired task is a workflow waiting on a human, not a leak"
);
assert!(store.get(&TaskId("done-new".into())).await?.is_some());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn retention_batches_and_reports_an_incomplete_sweep() -> A2aResult<()> {
let db = TestDb::create("retention_batch").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
for i in 0..5 {
let mut t = make_task(&format!("t{i}"), "ctx");
t.status = TaskStatus::new(TaskState::Completed);
store.save(&t).await?;
}
backdate(&db.url, "tasks", 7_200, None).await;
let policy = RetentionPolicy::new(Duration::from_secs(3_600))
.with_batch_size(2)
.with_max_batches(2);
let first = store.purge_expired(&policy).await.expect("purge");
assert_eq!(first.batches, 2);
assert_eq!(first.tasks_deleted, 4);
assert!(
!first.complete,
"a bounded sweep must say it ran out of budget rather than leave the \
caller to infer it from a count"
);
let second = store.purge_expired(&policy).await.expect("purge");
assert_eq!(second.tasks_deleted, 1);
assert!(second.complete);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn retention_does_not_cross_tenants() -> A2aResult<()> {
let db = TestDb::create("retention_tenant").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant store");
for tenant in ["acme", "globex"] {
let mut t = make_task("shared-id", "ctx");
t.status = TaskStatus::new(TaskState::Completed);
TenantContext::scope(tenant, async { store.save(&t).await }).await?;
}
backdate(&db.url, "tenant_tasks", 7_200, Some("acme")).await;
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");
let globex = TenantContext::scope("globex", async {
store.get(&TaskId("shared-id".into())).await
})
.await?;
assert!(
globex.is_some(),
"tenant_tasks is keyed on (tenant_id, id): deleting by id alone would \
have taken every tenant's task of the same name"
);
db.drop_db().await;
Ok(())
}
const IDEM_KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_claim_replay_and_conflict() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let db = TestDb::create("idem_claim").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
assert!(store.supports_idempotency());
assert_eq!(
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t1".into()))
.await
.expect("claim"),
IdempotencyClaim::Claimed
);
assert_eq!(
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t2".into()))
.await
.expect("replay"),
IdempotencyClaim::Replay(TaskId("t1".into()))
);
assert_eq!(
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m2"), &TaskId("t3".into()))
.await
.expect("conflict"),
IdempotencyClaim::Conflict {
held_by: MessageId::new("m1")
}
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_release_frees_the_key() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let db = TestDb::create("idem_release").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t1".into()))
.await
.expect("claim");
store
.release_idempotency_key(IDEM_KEY)
.await
.expect("release");
assert_eq!(
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m2"), &TaskId("t2".into()))
.await
.expect("reclaim"),
IdempotencyClaim::Claimed,
"a released key must be free for anyone"
);
store
.release_idempotency_key("0123456789abcdef0123456789abcdef")
.await
.expect("release of an unheld key");
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_key_survives_deletion_of_its_task() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let db = TestDb::create("idem_survives").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await.expect("save");
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &task.id)
.await
.expect("claim");
store.delete(&task.id).await.expect("delete");
assert_eq!(
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t2".into()))
.await
.expect("claim after delete"),
IdempotencyClaim::Replay(TaskId("t1".into())),
"the key must still name the swept task, not be free to re-run"
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_concurrent_claims_produce_exactly_one_winner() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
use std::sync::Arc;
let db = TestDb::create("idem_race").await;
let store = Arc::new(
PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store"),
);
let mut claims = Vec::new();
for i in 0..16 {
let store = Arc::clone(&store);
claims.push(tokio::spawn(async move {
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId(format!("t{i}")))
.await
}));
}
let mut claimed = 0;
let mut replays = 0;
for c in claims {
match c.await.expect("join").expect("claim must not error") {
IdempotencyClaim::Claimed => claimed += 1,
IdempotencyClaim::Replay(_) => replays += 1,
IdempotencyClaim::Conflict { held_by } => {
panic!("one message cannot conflict with itself (held_by {held_by})")
}
other => panic!("unexpected claim outcome: {other:?}"),
}
}
assert_eq!(claimed, 1, "exactly one claim may win");
assert_eq!(replays, 15);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_table_exists_on_both_schema_paths() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let migrated = TestDb::create("idem_migrated").await;
let via_migrations = PostgresTaskStore::with_migrations(&migrated.url)
.await
.expect("migrated store");
assert_eq!(
via_migrations
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t1".into()))
.await
.expect("claim on migrated schema"),
IdempotencyClaim::Claimed
);
migrated.drop_db().await;
let pooled = TestDb::create("idem_pooled").await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&pooled.url)
.await
.expect("connect");
let via_from_pool = PostgresTaskStore::from_pool(pool)
.await
.expect("from_pool store");
assert_eq!(
via_from_pool
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t1".into()))
.await
.expect("claim on from_pool schema"),
IdempotencyClaim::Claimed
);
pooled.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn idempotency_keys_are_scoped_per_tenant() {
use a2a_protocol_server::store::TenantContext;
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let db = TestDb::create("idem_tenant").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant store");
let a = TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("task-a".into()))
.await
.expect("tenant-a claim")
})
.await;
assert_eq!(a, IdempotencyClaim::Claimed);
let b = TenantContext::scope("tenant-b", async {
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("task-b".into()))
.await
.expect("tenant-b claim")
})
.await;
assert_eq!(
b,
IdempotencyClaim::Claimed,
"tenant-b must claim its own key, not replay tenant-a's task"
);
TenantContext::scope("tenant-b", async {
store
.release_idempotency_key(IDEM_KEY)
.await
.expect("tenant-b release");
})
.await;
let still_held = TenantContext::scope("tenant-a", async {
store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("ignored".into()))
.await
.expect("tenant-a reclaim")
})
.await;
assert_eq!(
still_held,
IdempotencyClaim::Replay(TaskId("task-a".into())),
"a release must not reach across the tenant boundary"
);
db.drop_db().await;
}
fn log_event(task_id: &str, state: TaskState) -> StreamResponse {
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId(task_id.to_string()),
context_id: ContextId("ctx1".to_string()),
status: TaskStatus::new(state),
metadata: None,
})
}
fn logged_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]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_store_advertises_event_log_support() {
let db = TestDb::create("evlog_support").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
assert!(
store.supports_event_log(),
"the Postgres store implements append_event; it must advertise support"
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_events_round_trip_in_order_with_their_positions() -> A2aResult<()> {
let db = TestDb::create("evlog_roundtrip").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
for (seq, state) in [
(1, TaskState::Submitted),
(2, TaskState::Working),
(3, TaskState::Completed),
] {
store
.append_event(&task.id, seq, &log_event("t1", state))
.await?;
}
let all = store.read_events(&task.id, 0, 100).await?;
assert_eq!(all.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
logged_states(&all),
vec![
TaskState::Submitted,
TaskState::Working,
TaskState::Completed
],
"the payload must survive the JSONB round trip, not just the position"
);
assert_eq!(store.last_event_seq(&task.id).await?, 3);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_appending_the_same_position_twice_leaves_one_row() -> A2aResult<()> {
let db = TestDb::create("evlog_replay").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Working))
.await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Completed))
.await?;
let all = store.read_events(&task.id, 0, 10).await?;
assert_eq!(all.len(), 1, "one position, one row");
assert_eq!(
logged_states(&all),
vec![TaskState::Working],
"the first write wins; a replay must not rewrite history"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_reading_after_an_offset_is_exclusive_and_honours_the_limit() -> A2aResult<()> {
let db = TestDb::create("evlog_offset").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
for seq in 1..=5 {
store
.append_event(&task.id, seq, &log_event("t1", TaskState::Working))
.await?;
}
let after_two = store.read_events(&task.id, 2, 100).await?;
assert_eq!(after_two.first().map(|r| r.seq), Some(3), "exclusive");
assert_eq!(after_two.len(), 3);
assert_eq!(store.read_events(&task.id, 0, 2).await?.len(), 2);
assert!(
store.read_events(&task.id, 99, 10).await?.is_empty(),
"a subscriber past the end gets nothing, not an error"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_a_task_with_no_events_reports_zero_rather_than_failing() -> A2aResult<()> {
let db = TestDb::create("evlog_empty").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let missing = TaskId("never-existed".into());
assert_eq!(store.last_event_seq(&missing).await?, 0);
assert!(store.read_events(&missing, 0, 10).await?.is_empty());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_the_log_reports_its_earliest_position() -> A2aResult<()> {
let db = TestDb::create("evlog_earliest").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
assert_eq!(store.earliest_event_seq(&task.id).await?, None);
for seq in [5, 6, 7] {
store
.append_event(&task.id, seq, &log_event("t1", TaskState::Working))
.await?;
}
assert_eq!(store.earliest_event_seq(&task.id).await?, Some(5));
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_deleting_a_task_removes_its_log() -> A2aResult<()> {
let db = TestDb::create("evlog_delete").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Working))
.await?;
store.delete(&task.id).await?;
assert_eq!(store.last_event_seq(&task.id).await?, 0);
store.save(&task).await?;
assert!(store.read_events(&task.id, 0, 10).await?.is_empty());
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_a_snapshot_rewrite_leaves_the_log_alone() -> A2aResult<()> {
let db = TestDb::create("evlog_snapshot").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let mut task = make_task("t1", "ctx1");
store.save(&task).await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Working))
.await?;
task.status = TaskStatus::new(TaskState::Completed);
store.save(&task).await?;
assert_eq!(store.read_events(&task.id, 0, 10).await?.len(), 1);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_new_creates_the_event_table_too() -> A2aResult<()> {
let db = TestDb::create("evlog_new").await;
let store = PostgresTaskStore::new(&db.url)
.await
.expect("open postgres store without the migration runner");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Working))
.await?;
assert_eq!(store.last_event_seq(&task.id).await?, 1);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_migration_runner_creates_the_event_table_too() -> A2aResult<()> {
let db = TestDb::create("evlog_migrated").await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect to scratch database");
PgMigrationRunner::new(pool.clone())
.run_pending()
.await
.expect("run_pending");
let store = PostgresTaskStore::from_pool(pool)
.await
.expect("store from migrated pool");
let task = make_task("t1", "ctx1");
store.save(&task).await?;
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Working))
.await?;
assert_eq!(store.last_event_seq(&task.id).await?, 1);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_store_advertises_event_log_support() {
let db = TestDb::create("t_evlog_support").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
assert!(
store.supports_event_log(),
"the tenant-aware Postgres store implements append_event; \
it must advertise support"
);
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_release_frees_the_key_in_its_own_tenant_only() {
use a2a_protocol_server::store::task_store::IdempotencyClaim;
use a2a_protocol_types::message::MessageId;
let db = TestDb::create("t_idem_release").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
for tenant in ["tenant-a", "tenant-b"] {
TenantContext::scope(tenant, async {
let claim = store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m1"), &TaskId("t1".into()))
.await
.expect("claim");
assert_eq!(claim, IdempotencyClaim::Claimed);
})
.await;
}
TenantContext::scope("tenant-a", async {
store
.release_idempotency_key(IDEM_KEY)
.await
.expect("release");
let reclaim = store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m2"), &TaskId("t2".into()))
.await
.expect("reclaim");
assert_eq!(reclaim, IdempotencyClaim::Claimed, "released, so free");
})
.await;
TenantContext::scope("tenant-b", async {
let held = store
.claim_idempotency_key(IDEM_KEY, &MessageId::new("m2"), &TaskId("t2".into()))
.await
.expect("claim b");
assert_ne!(
held,
IdempotencyClaim::Claimed,
"tenant-a's release must not free tenant-b's key"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_the_log_reports_its_earliest_position_per_tenant() {
let db = TestDb::create("t_evlog_earliest").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
let id = TaskId("shared".into());
TenantContext::scope("tenant-a", async {
store
.save(&make_task("shared", "ctx1"))
.await
.expect("save a");
assert_eq!(store.earliest_event_seq(&id).await.expect("empty"), None);
for seq in [5, 6, 7] {
store
.append_event(&id, seq, &log_event("shared", TaskState::Working))
.await
.expect("append a");
}
})
.await;
TenantContext::scope("tenant-b", async {
store
.save(&make_task("shared", "ctx1"))
.await
.expect("save b");
assert_eq!(store.earliest_event_seq(&id).await.expect("b empty"), None);
store
.append_event(&id, 3, &log_event("shared", TaskState::Working))
.await
.expect("append b");
assert_eq!(store.earliest_event_seq(&id).await.expect("b"), Some(3));
})
.await;
TenantContext::scope("tenant-a", async {
assert_eq!(
store.earliest_event_seq(&id).await.expect("a"),
Some(5),
"tenant-b's earlier position is not tenant-a's"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_event_log_is_scoped_per_tenant() {
let db = TestDb::create("t_evlog_scope").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
TenantContext::scope("tenant-a", async {
store
.save(&make_task("shared", "ctx1"))
.await
.expect("save a");
store
.append_event(
&TaskId("shared".into()),
1,
&log_event("shared", TaskState::Working),
)
.await
.expect("append a");
})
.await;
TenantContext::scope("tenant-b", async {
store
.save(&make_task("shared", "ctx1"))
.await
.expect("save b");
assert_eq!(
store
.last_event_seq(&TaskId("shared".into()))
.await
.expect("last b"),
0,
"tenant-b's log for this id is its own, and it is empty"
);
assert!(
store
.read_events(&TaskId("shared".into()), 0, 10)
.await
.expect("read b")
.is_empty(),
"tenant-b must not be handed tenant-a's events"
);
store
.append_event(
&TaskId("shared".into()),
1,
&log_event("shared", TaskState::Completed),
)
.await
.expect("append b");
})
.await;
TenantContext::scope("tenant-a", async {
let all = store
.read_events(&TaskId("shared".into()), 0, 10)
.await
.expect("read a");
assert_eq!(
logged_states(&all),
vec![TaskState::Working],
"tenant-b's write must not have overwritten tenant-a's position 1"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_events_round_trip_and_delete_is_tenant_scoped() {
let db = TestDb::create("t_evlog_rt").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
for tenant in ["tenant-a", "tenant-b"] {
TenantContext::scope(tenant, async {
store.save(&make_task("t1", "ctx1")).await.expect("save");
for (seq, state) in [(1, TaskState::Working), (2, TaskState::Completed)] {
store
.append_event(&TaskId("t1".into()), seq, &log_event("t1", state))
.await
.expect("append");
}
})
.await;
}
TenantContext::scope("tenant-a", async {
let all = store
.read_events(&TaskId("t1".into()), 0, 10)
.await
.expect("read");
assert_eq!(all.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(
logged_states(&all),
vec![TaskState::Working, TaskState::Completed]
);
assert_eq!(
store
.read_events(&TaskId("t1".into()), 1, 10)
.await
.expect("read")
.len(),
1,
"after_seq is exclusive"
);
store.delete(&TaskId("t1".into())).await.expect("delete");
assert_eq!(
store
.last_event_seq(&TaskId("t1".into()))
.await
.expect("last"),
0
);
})
.await;
TenantContext::scope("tenant-b", async {
assert_eq!(
store
.last_event_seq(&TaskId("t1".into()))
.await
.expect("last"),
2,
"a delete in one tenant must not reach across the boundary"
);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_retention_takes_the_event_log_with_it() {
let db = TestDb::create("t_evlog_retention").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant-aware postgres store");
TenantContext::scope("tenant-a", async {
let mut done = make_task("t1", "ctx1");
done.status = TaskStatus::new(TaskState::Completed);
store.save(&done).await.expect("save");
store
.append_event(
&TaskId("t1".into()),
1,
&log_event("t1", TaskState::Completed),
)
.await
.expect("append");
})
.await;
backdate(&db.url, "tenant_tasks", 7_200, Some("tenant-a")).await;
let report = store
.purge_expired(&RetentionPolicy::new(Duration::from_secs(3_600)))
.await
.expect("purge");
assert_eq!(report.tasks_deleted, 1);
assert_eq!(
report.orphan_rows_deleted, 0,
"PostgreSQL enforces the cascade, so the sweep finds nothing stranded"
);
TenantContext::scope("tenant-a", async {
assert_eq!(
store
.last_event_seq(&TaskId("t1".into()))
.await
.expect("last"),
0,
"the log went with the task"
);
})
.await;
db.drop_db().await;
}
#[derive(Default)]
struct RecordingMetrics {
seen: std::sync::Mutex<Vec<(String, String)>>,
}
impl RecordingMetrics {
fn persistence_errors(&self) -> usize {
self.seen.lock().expect("recorder mutex").len()
}
}
impl a2a_protocol_server::Metrics for RecordingMetrics {
fn on_persistence_error(&self, operation: &str, error_kind: &str) {
self.seen
.lock()
.expect("recorder mutex")
.push((operation.to_owned(), error_kind.to_owned()));
}
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_a_non_positive_position_is_refused_by_the_schema() {
let db = TestDb::create("evlog_check").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store
.save(&task)
.await
.expect("save the task the events belong to");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect to scratch database");
for bad in [0_i64, -1] {
let err = sqlx::query(
"INSERT INTO task_events (task_id, seq, payload) VALUES ($1, $2, $3::jsonb)",
)
.bind("t1")
.bind(bad)
.bind("{}")
.execute(&pool)
.await
.expect_err("a non-positive position must be refused by the schema");
let text = err.to_string();
assert!(
text.contains("violates check constraint"),
"seq = {bad} must be refused by the CHECK constraint specifically — \
a foreign-key or not-null rejection would prove nothing about it. \
Got: {err}"
);
assert!(
text.contains("seq_positive"),
"and by the seq constraint by name, so a future unrelated CHECK \
cannot satisfy this test. Got: {err}"
);
}
sqlx::query("INSERT INTO task_events (task_id, seq, payload) VALUES ($1, $2, $3::jsonb)")
.bind("t1")
.bind(1_i64)
.bind("{}")
.execute(&pool)
.await
.expect("position 1 is valid and must be accepted");
pool.close().await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_the_seq_constraint_migration_is_idempotent() {
let db = TestDb::create("evlog_mig6").await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect to scratch database");
let runner = PgMigrationRunner::new(pool.clone());
runner.run_pending().await.expect("first run");
let second = runner.run_pending().await.expect("second run");
assert!(
second.is_empty(),
"a fully migrated database must have nothing pending, got {second:?}"
);
let constraints: Vec<String> = sqlx::query_scalar(
"SELECT conname FROM pg_constraint \
WHERE conrelid = 'task_events'::regclass AND contype = 'c'",
)
.fetch_all(&pool)
.await
.expect("read check constraints");
assert_eq!(
constraints
.iter()
.filter(|c| c.contains("seq_positive"))
.count(),
1,
"exactly one seq CHECK constraint, not a duplicate per run: {constraints:?}"
);
pool.close().await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_a_replay_is_not_reported_but_a_second_writer_is() -> A2aResult<()> {
let db = TestDb::create("evlog_classify").await;
let recorder = std::sync::Arc::new(RecordingMetrics::default());
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store")
.with_metrics(a2a_protocol_server::metrics::MetricsHandle::from_arc(
std::sync::Arc::clone(&recorder) as std::sync::Arc<dyn a2a_protocol_server::Metrics>,
));
let task = make_task("t1", "ctx1");
store.save(&task).await?;
let first = log_event("t1", TaskState::Working);
store.append_event(&task.id, 1, &first).await?;
store.append_event(&task.id, 1, &first).await?;
assert_eq!(
recorder.persistence_errors(),
0,
"an identical replay is not a lost append and must not be counted"
);
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Completed))
.await?;
assert_eq!(
recorder.persistence_errors(),
1,
"a different event on a taken position is a lost append and must be counted"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_a_replay_is_not_reported_but_a_second_writer_is() {
let db = TestDb::create("tenant_evlog_classify").await;
let recorder = std::sync::Arc::new(RecordingMetrics::default());
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant postgres store")
.with_metrics(a2a_protocol_server::metrics::MetricsHandle::from_arc(
std::sync::Arc::clone(&recorder) as std::sync::Arc<dyn a2a_protocol_server::Metrics>,
));
TenantContext::scope("alpha", async {
let task = make_task("t1", "ctx1");
store.save(&task).await.expect("save");
let first = log_event("t1", TaskState::Working);
store
.append_event(&task.id, 1, &first)
.await
.expect("append");
store
.append_event(&task.id, 1, &first)
.await
.expect("replay");
assert_eq!(
recorder.persistence_errors(),
0,
"an identical replay is not a lost append and must not be counted"
);
store
.append_event(&task.id, 1, &log_event("t1", TaskState::Completed))
.await
.expect("a collision must not fail the agent");
assert_eq!(
recorder.persistence_errors(),
1,
"a different event on a taken position is a lost append and must be counted"
);
let events = store.read_events(&task.id, 0, 10).await.expect("read");
assert_eq!(events.len(), 1, "ON CONFLICT DO NOTHING leaves one row");
assert_eq!(events[0].seq, 1);
})
.await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn postgres_the_sweep_expires_old_keys_and_keeps_recent_ones() {
const OLD_KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
const NEW_KEY: &str = "0123456789abcdef0123456789abcdef";
let db = TestDb::create("key_expiry").await;
let store = PostgresTaskStore::with_migrations(&db.url)
.await
.expect("open postgres store");
let task = make_task("t1", "ctx1");
store.save(&task).await.expect("save");
for key in [OLD_KEY, NEW_KEY] {
store
.claim_idempotency_key(key, &MessageId::new("m1"), &task.id)
.await
.expect("claim");
}
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect");
sqlx::query(
"UPDATE idempotency_keys SET created_at = now() - interval '2 hours' WHERE key = $1",
)
.bind(OLD_KEY)
.execute(&pool)
.await
.expect("backdate");
let report = store
.purge_expired(
&RetentionPolicy::new(Duration::from_secs(1))
.with_idempotency_key_max_age(Some(Duration::from_secs(3600))),
)
.await
.expect("purge");
assert_eq!(
report.idempotency_keys_deleted, 1,
"exactly the expired key, and the report says so"
);
assert_eq!(report.tasks_deleted, 0, "the task is not terminal");
assert_eq!(
report.batches, 1,
"the one key pass that deleted a row is charged for"
);
let remaining: Vec<String> =
sqlx::query_scalar::<_, String>("SELECT key FROM idempotency_keys")
.fetch_all(&pool)
.await
.expect("remaining");
assert_eq!(
remaining,
vec![NEW_KEY.to_owned()],
"the recent key must survive: expiring it early is a send that runs twice"
);
pool.close().await;
db.drop_db().await;
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn tenant_postgres_key_expiry_is_scoped_to_age_not_tenant() {
const KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
let db = TestDb::create("tenant_key_expiry").await;
let store = TenantAwarePostgresTaskStore::new(&db.url)
.await
.expect("open tenant postgres store");
for tenant in ["alpha", "beta"] {
TenantContext::scope(tenant, async {
let task = make_task("t1", "ctx1");
store.save(&task).await.expect("save");
store
.claim_idempotency_key(KEY, &MessageId::new("m1"), &task.id)
.await
.expect("claim");
})
.await;
}
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db.url)
.await
.expect("connect");
let before = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM tenant_idempotency_keys")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(before, 2, "precondition: one key per tenant");
sqlx::query(
"UPDATE tenant_idempotency_keys SET created_at = now() - interval '2 hours' \
WHERE tenant_id = $1",
)
.bind("alpha")
.execute(&pool)
.await
.expect("backdate");
let report = store
.purge_expired(
&RetentionPolicy::new(Duration::from_secs(1))
.with_idempotency_key_max_age(Some(Duration::from_secs(3600))),
)
.await
.expect("purge");
assert_eq!(report.idempotency_keys_deleted, 1);
let survivors =
sqlx::query_scalar::<_, String>("SELECT tenant_id FROM tenant_idempotency_keys")
.fetch_all(&pool)
.await
.expect("survivors");
assert_eq!(
survivors,
vec!["beta".to_owned()],
"the sweep expires by age across every tenant, and takes only the aged one"
);
pool.close().await;
db.drop_db().await;
}
fn task_with_history_pg(id: &str, messages: usize) -> Task {
use a2a_protocol_types::message::Message;
let mut task = make_task(id, "ctx");
task.status = TaskStatus::with_timestamp(TaskState::Working);
task.history = Some(
(0..messages)
.map(|i| Message::user_text(format!("m-{i}"), format!("turn-{i}")))
.collect(),
);
task
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn status_delta_matches_full_save() -> A2aResult<()> {
let db = TestDb::create("status_delta").await;
let delta_store = PostgresTaskStore::new(&db.url).await.expect("delta store");
let save_db = TestDb::create("status_delta_ref").await;
let save_store = PostgresTaskStore::new(&save_db.url)
.await
.expect("save store");
let initial = task_with_history_pg("t", 12);
delta_store.save(&initial).await?;
save_store.save(&initial).await?;
for state in [TaskState::Working, TaskState::Completed] {
let mut moved = initial.clone();
moved.status = TaskStatus::with_timestamp(state);
delta_store.save_status_delta(&moved).await?;
save_store.save(&moved).await?;
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await?,
save_store.get(&id).await?,
"diverged after a delta to {state}; history is the field a \
status-only rewrite is most likely to drop"
);
}
db.drop_db().await;
save_db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn status_delta_updates_the_state_column_list_filters_on() -> A2aResult<()> {
let db = TestDb::create("status_delta_state").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let task = task_with_history_pg("t", 3);
store.save(&task).await?;
let mut done = task.clone();
done.status = TaskStatus::with_timestamp(TaskState::Completed);
store.save_status_delta(&done).await?;
let completed = store
.list(&ListTasksParams {
status: Some(TaskState::Completed),
..Default::default()
})
.await?;
assert_eq!(
completed.tasks.len(),
1,
"the state column still holds the old value, so a filtered list \
cannot see the task the delta just completed"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn status_delta_moves_the_record_to_the_front_of_list_order() -> A2aResult<()> {
let db = TestDb::create("status_delta_order").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let older = task_with_history_pg("t-older", 2);
store.save(&older).await?;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let newer = task_with_history_pg("t-newer", 2);
store.save(&newer).await?;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let mut moved = older.clone();
moved.status = TaskStatus::with_timestamp(TaskState::Working);
store.save_status_delta(&moved).await?;
let listed = store.list(&ListTasksParams::default()).await?;
assert_eq!(
listed.tasks[0].id,
TaskId::new("t-older"),
"§3.1.4 orders by status timestamp, so a status change moves the \
record; a delta that skipped `updated_at` would leave it mis-ordered"
);
db.drop_db().await;
Ok(())
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn status_delta_for_an_absent_task_falls_back_to_a_save() -> A2aResult<()> {
let db = TestDb::create("status_delta_absent").await;
let store = PostgresTaskStore::new(&db.url).await.expect("store");
let task = task_with_history_pg("t-absent", 1);
store.save_status_delta(&task).await?;
let stored = store.get(&TaskId::new("t-absent")).await?.expect(
"the fallback must have inserted it; dropping a transition is \
the one outcome worse than a slow one",
);
assert_eq!(stored.status.state, TaskState::Working);
db.drop_db().await;
Ok(())
}
type StartResult = (&'static str, Result<(), String>);
async fn start_everything_at_once(url: &str, per_kind: usize) -> Vec<StartResult> {
let mut set = tokio::task::JoinSet::new();
for _ in 0..per_kind {
let u = url.to_owned();
set.spawn(async move {
let r = PostgresTaskStore::with_migrations(&u).await;
(
"PostgresTaskStore::with_migrations",
r.map(drop).map_err(|e| e.to_string()),
)
});
let u = url.to_owned();
set.spawn(async move {
let r = PostgresTaskStore::new(&u).await;
(
"PostgresTaskStore::new",
r.map(drop).map_err(|e| e.to_string()),
)
});
let u = url.to_owned();
set.spawn(async move {
let r = TenantAwarePostgresTaskStore::new(&u).await;
(
"TenantAwarePostgresTaskStore::new",
r.map(drop).map_err(|e| e.to_string()),
)
});
let u = url.to_owned();
set.spawn(async move {
let r = PostgresPushConfigStore::new(&u).await;
(
"PostgresPushConfigStore::new",
r.map(drop).map_err(|e| e.to_string()),
)
});
let u = url.to_owned();
set.spawn(async move {
let r = TenantAwarePostgresPushConfigStore::new(&u).await;
(
"TenantAwarePostgresPushConfigStore::new",
r.map(drop).map_err(|e| e.to_string()),
)
});
let u = url.to_owned();
set.spawn(async move {
let r = a2a_protocol_server::rate_limit::PostgresRateLimitCounter::new(&u).await;
(
"PostgresRateLimitCounter::new",
r.map(drop).map_err(|e| e.to_string()),
)
});
}
let mut results = Vec::new();
while let Some(joined) = set.join_next().await {
results.push(joined.expect("a constructor task panicked"));
}
results
}
#[tokio::test]
#[ignore = "requires a live PostgreSQL server (set A2A_TEST_POSTGRES_URL)"]
async fn replicas_starting_together_on_an_empty_database_all_come_up() {
for round in 0..5 {
let db = TestDb::create("startup_race").await;
let results = tokio::time::timeout(
Duration::from_secs(30),
start_everything_at_once(&db.url, 8),
)
.await
.expect("constructors did not finish within 30s — is the schema lock deadlocking?");
db.drop_db().await;
let failures: Vec<String> = results
.iter()
.filter_map(|(who, r)| r.as_ref().err().map(|e| format!("{who}: {e}")))
.collect();
assert_eq!(
results.len(),
48,
"round {round}: every constructor reports"
);
assert!(
failures.is_empty(),
"round {round}: {} of {} constructors failed against a fresh database:\n{}",
failures.len(),
results.len(),
failures.join("\n")
);
}
}