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 super::{TenantAwarePostgresTaskStore, to_a2a_error};
use crate::store::event_log_sql::{
decode_json_row, encode_json, limit_to_i64, report_no_op_append, seq_from_i64, seq_to_i64,
};
use crate::store::task_store::{RecordedEvent, TaskStore};
use crate::store::tenant::TenantContext;
use crate::store::tenant_event_log as evlog;
use crate::store::tenant_idempotency as idem;
#[allow(clippy::manual_async_fn)]
impl TaskStore for TenantAwarePostgresTaskStore {
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::PG_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::PG_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::PG_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_value(task)
.map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
let status_ts =
crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
let written = sqlx::query(crate::store::terminal::sql_write_allowed!(
"INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, 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
WHERE ",
"tenant_tasks.state",
"EXCLUDED.state"
))
.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))?
.rows_affected();
if written == 0 {
let stored: Option<(String,)> = sqlx::query_as(
"SELECT state FROM tenant_tasks WHERE tenant_id = $1 AND id = $2",
)
.bind(&tenant)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_a2a_error(&e))?;
return Err(crate::store::terminal::refusal(
task,
stored.map(|(s,)| s).as_deref(),
));
}
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<(serde_json::Value,)> =
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_value(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 {
bind_values.push(ctx.clone());
conditions.push(format!("context_id = ${}", bind_values.len()));
}
if let Some(ref status) = params.status {
bind_values.push(status.to_string());
conditions.push(format!("state = ${}", bind_values.len()));
}
if let Some(ref after) = params.status_timestamp_after {
let Some(after_ts) = crate::store::status_timestamp_rfc3339(Some(after)) else {
return Ok(TaskListResponse::new(Vec::new()));
};
bind_values.push(after_ts);
conditions.push(format!(
"updated_at > (${})::timestamptz",
bind_values.len()
));
}
if let Some(ref token) = params.page_token {
let Some((cursor_ua, cursor_id)) = crate::store::cursor::decode(token) else {
return Ok(TaskListResponse::new(Vec::new()));
};
bind_values.push(cursor_ua.to_string());
let ua_idx = bind_values.len();
bind_values.push(cursor_id.to_string());
let id_idx = bind_values.len();
conditions.push(format!(
"(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
));
}
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 = crate::store::pagination::fetch_limit(page_size);
let sql = format!(
"SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
data FROM tenant_tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
);
let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
for val in &bind_values {
query = query.bind(val);
}
let rows: Vec<(String, serde_json::Value)> = 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_value::<Task>(data)
.map(|task| (updated_at, task))
.map_err(|e| A2aError::internal(format!("deserialize: {e}")))
})
.collect::<A2aResult<Vec<_>>>()?;
let next_page_token =
if crate::store::pagination::has_next_page(rows.len(), page_size as usize) {
rows.truncate(page_size as usize);
rows.last()
.map(|(ua, task)| crate::store::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_value(task)
.map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
let status_ts =
crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
let result = sqlx::query(
"INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, now()))
ON CONFLICT(tenant_id, id) DO NOTHING",
)
.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::PG_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_json(event)?;
let changed_nothing = sqlx::query(evlog::PG_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<(serde_json::Value,)> = sqlx::query_as(evlog::PG_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::PG_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::PG_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, serde_json::Value)> = sqlx::query_as(evlog::PG_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_json_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)
})
}
}