a2a_protocol_server/store/
tenant_postgres_store.rs1use std::future::Future;
33use std::pin::Pin;
34
35use a2a_protocol_types::error::{A2aError, A2aResult};
36use a2a_protocol_types::params::ListTasksParams;
37use a2a_protocol_types::responses::TaskListResponse;
38use a2a_protocol_types::task::{Task, TaskId};
39use sqlx::postgres::{PgPool, PgPoolOptions};
40
41use super::task_store::TaskStore;
42use super::tenant::TenantContext;
43
44#[derive(Debug, Clone)]
50pub struct TenantAwarePostgresTaskStore {
51 pool: PgPool,
52 max_page_size: u32,
55}
56
57impl TenantAwarePostgresTaskStore {
58 #[must_use]
66 pub const fn with_max_page_size(mut self, max: u32) -> Self {
67 self.max_page_size = max;
68 self
69 }
70 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
76 let pool = PgPoolOptions::new()
77 .max_connections(10)
78 .connect(url)
79 .await?;
80 Self::from_pool(pool).await
81 }
82
83 pub async fn from_pool(pool: PgPool) -> Result<Self, sqlx::Error> {
89 sqlx::query(
90 "CREATE TABLE IF NOT EXISTS tenant_tasks (
91 tenant_id TEXT NOT NULL DEFAULT '',
92 id TEXT NOT NULL,
93 context_id TEXT NOT NULL,
94 state TEXT NOT NULL,
95 data JSONB NOT NULL,
96 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
97 updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
98 PRIMARY KEY (tenant_id, id)
99 )",
100 )
101 .execute(&pool)
102 .await?;
103
104 sqlx::query(
105 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
106 )
107 .execute(&pool)
108 .await?;
109
110 sqlx::query(
111 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
112 )
113 .execute(&pool)
114 .await?;
115
116 sqlx::query(
119 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
120 )
121 .execute(&pool)
122 .await?;
123
124 Ok(Self {
125 pool,
126 max_page_size: crate::store::DEFAULT_MAX_PAGE_SIZE,
127 })
128 }
129
130 pub async fn purge_expired(
154 &self,
155 policy: &super::retention::RetentionPolicy,
156 ) -> A2aResult<super::retention::PurgeReport> {
157 super::retention::postgres::purge(&self.pool, "tenant_tasks", policy)
158 .await
159 .map_err(|e| to_a2a_error(&e))
160 }
161}
162
163fn to_a2a_error(e: &sqlx::Error) -> A2aError {
164 A2aError::internal(format!("postgres error: {e}"))
165}
166
167#[allow(clippy::manual_async_fn)]
168impl TaskStore for TenantAwarePostgresTaskStore {
169 fn save<'a>(
170 &'a self,
171 task: &'a Task,
172 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
173 Box::pin(async move {
174 let tenant = TenantContext::current();
175 let id = task.id.0.as_str();
176 let context_id = task.context_id.0.as_str();
177 let state = task.status.state.to_string();
178 let data = serde_json::to_value(task)
179 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
180 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
184
185 sqlx::query(
186 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
187 VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, now()))
188 ON CONFLICT(tenant_id, id) DO UPDATE SET
189 context_id = EXCLUDED.context_id,
190 state = EXCLUDED.state,
191 data = EXCLUDED.data,
192 updated_at = EXCLUDED.updated_at",
193 )
194 .bind(&tenant)
195 .bind(id)
196 .bind(context_id)
197 .bind(&state)
198 .bind(&data)
199 .bind(&status_ts)
200 .execute(&self.pool)
201 .await
202 .map_err(|e| to_a2a_error(&e))?;
203
204 Ok(())
205 })
206 }
207
208 fn get<'a>(
209 &'a self,
210 id: &'a TaskId,
211 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
212 Box::pin(async move {
213 let tenant = TenantContext::current();
214 let row: Option<(serde_json::Value,)> =
215 sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = $1 AND id = $2")
216 .bind(&tenant)
217 .bind(id.0.as_str())
218 .fetch_optional(&self.pool)
219 .await
220 .map_err(|e| to_a2a_error(&e))?;
221
222 match row {
223 Some((data,)) => {
224 let task: Task = serde_json::from_value(data)
225 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
226 Ok(Some(task))
227 }
228 None => Ok(None),
229 }
230 })
231 }
232
233 #[allow(clippy::too_many_lines)]
234 fn list<'a>(
235 &'a self,
236 params: &'a ListTasksParams,
237 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
238 Box::pin(async move {
239 let tenant = TenantContext::current();
240 let mut conditions = vec!["tenant_id = $1".to_string()];
241 let mut bind_values: Vec<String> = vec![tenant];
242
243 if let Some(ref ctx) = params.context_id {
244 bind_values.push(ctx.clone());
245 conditions.push(format!("context_id = ${}", bind_values.len()));
246 }
247 if let Some(ref status) = params.status {
248 bind_values.push(status.to_string());
249 conditions.push(format!("state = ${}", bind_values.len()));
250 }
251 if let Some(ref after) = params.status_timestamp_after {
256 let Some(after_ts) = super::status_timestamp_rfc3339(Some(after)) else {
257 return Ok(TaskListResponse::new(Vec::new()));
258 };
259 bind_values.push(after_ts);
260 conditions.push(format!(
261 "updated_at > (${})::timestamptz",
262 bind_values.len()
263 ));
264 }
265 if let Some(ref token) = params.page_token {
271 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
272 return Ok(TaskListResponse::new(Vec::new()));
273 };
274 bind_values.push(cursor_ua.to_string());
275 let ua_idx = bind_values.len();
276 bind_values.push(cursor_id.to_string());
277 let id_idx = bind_values.len();
278 conditions.push(format!(
279 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
280 ));
281 }
282
283 let where_clause = format!("WHERE {}", conditions.join(" AND "));
284
285 let page_size = match params.page_size {
286 Some(0) | None => 50_u32,
287 Some(n) => n.min(self.max_page_size),
288 };
289
290 let limit = super::pagination::fetch_limit(page_size);
291 let sql = format!(
292 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
293 data FROM tenant_tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
294 );
295
296 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
297 for val in &bind_values {
298 query = query.bind(val);
299 }
300
301 let rows: Vec<(String, serde_json::Value)> = query
302 .fetch_all(&self.pool)
303 .await
304 .map_err(|e| to_a2a_error(&e))?;
305
306 let mut rows: Vec<(String, Task)> = rows
307 .into_iter()
308 .map(|(updated_at, data)| {
309 serde_json::from_value::<Task>(data)
310 .map(|task| (updated_at, task))
311 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
312 })
313 .collect::<A2aResult<Vec<_>>>()?;
314
315 let next_page_token =
316 if super::pagination::has_next_page(rows.len(), page_size as usize) {
317 rows.truncate(page_size as usize);
318 rows.last()
319 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
320 .unwrap_or_default()
321 } else {
322 String::new()
323 };
324
325 #[allow(clippy::cast_possible_truncation)]
326 let page_len = rows.len() as u32;
327 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
328 let mut response = TaskListResponse::new(tasks);
329 response.next_page_token = next_page_token;
330 response.page_size = page_len;
331 Ok(response)
332 })
333 }
334
335 fn insert_if_absent<'a>(
336 &'a self,
337 task: &'a Task,
338 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
339 Box::pin(async move {
340 let tenant = TenantContext::current();
341 let id = task.id.0.as_str();
342 let context_id = task.context_id.0.as_str();
343 let state = task.status.state.to_string();
344 let data = serde_json::to_value(task)
345 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
346 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
347
348 let result = sqlx::query(
349 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
350 VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, now()))
351 ON CONFLICT(tenant_id, id) DO NOTHING",
352 )
353 .bind(&tenant)
354 .bind(id)
355 .bind(context_id)
356 .bind(&state)
357 .bind(&data)
358 .bind(&status_ts)
359 .execute(&self.pool)
360 .await
361 .map_err(|e| to_a2a_error(&e))?;
362
363 Ok(result.rows_affected() > 0)
364 })
365 }
366
367 fn delete<'a>(
368 &'a self,
369 id: &'a TaskId,
370 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
371 Box::pin(async move {
372 let tenant = TenantContext::current();
373 sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = $1 AND id = $2")
374 .bind(&tenant)
375 .bind(id.0.as_str())
376 .execute(&self.pool)
377 .await
378 .map_err(|e| to_a2a_error(&e))?;
379 Ok(())
380 })
381 }
382
383 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
384 Box::pin(async move {
385 let tenant = TenantContext::current();
386 let row: (i64,) =
387 sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = $1")
388 .bind(&tenant)
389 .fetch_one(&self.pool)
390 .await
391 .map_err(|e| to_a2a_error(&e))?;
392 #[allow(clippy::cast_sign_loss)]
393 Ok(row.0 as u64)
394 })
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn to_a2a_error_formats_message() {
404 let pg_err = sqlx::Error::RowNotFound;
405 let a2a_err = to_a2a_error(&pg_err);
406 let msg = format!("{a2a_err}");
407 assert!(
408 msg.contains("postgres error"),
409 "error message should contain 'postgres error': {msg}"
410 );
411 }
412}