a2a_protocol_server/store/postgres_store/
mod.rs1mod artifact_delta;
22
23use std::future::Future;
24use std::pin::Pin;
25
26use a2a_protocol_types::error::{A2aError, A2aResult};
27use a2a_protocol_types::params::ListTasksParams;
28use a2a_protocol_types::responses::TaskListResponse;
29use a2a_protocol_types::task::{Task, TaskId};
30use sqlx::postgres::{PgPool, PgPoolOptions};
31
32use super::task_store::{ArtifactDelta, TaskStore};
33
34#[derive(Debug, Clone)]
59pub struct PostgresTaskStore {
60 pool: PgPool,
61}
62
63impl PostgresTaskStore {
64 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
70 let pool = pg_pool(url).await?;
71 Self::from_pool(pool).await
72 }
73
74 pub async fn with_migrations(url: &str) -> Result<Self, sqlx::Error> {
84 let pool = pg_pool(url).await?;
85
86 let runner = super::pg_migration::PgMigrationRunner::new(pool.clone());
87 runner.run_pending().await?;
88
89 Ok(Self { pool })
90 }
91
92 pub async fn from_pool(pool: PgPool) -> Result<Self, sqlx::Error> {
98 sqlx::query(
99 "CREATE TABLE IF NOT EXISTS tasks (
100 id TEXT PRIMARY KEY,
101 context_id TEXT NOT NULL,
102 state TEXT NOT NULL,
103 data JSONB NOT NULL,
104 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
105 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
106 )",
107 )
108 .execute(&pool)
109 .await?;
110
111 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id)")
112 .execute(&pool)
113 .await?;
114
115 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state)")
116 .execute(&pool)
117 .await?;
118
119 sqlx::query(
120 "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state)",
121 )
122 .execute(&pool)
123 .await?;
124
125 sqlx::query(
128 "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC)",
129 )
130 .execute(&pool)
131 .await?;
132
133 Ok(Self { pool })
134 }
135}
136
137async fn pg_pool(url: &str) -> Result<PgPool, sqlx::Error> {
139 pg_pool_with_size(url, 10).await
140}
141
142async fn pg_pool_with_size(url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
144 PgPoolOptions::new()
145 .max_connections(max_connections)
146 .connect(url)
147 .await
148}
149
150#[allow(clippy::needless_pass_by_value)]
152pub(super) fn to_a2a_error(e: sqlx::Error) -> A2aError {
153 A2aError::internal(format!("postgres error: {e}"))
154}
155
156#[allow(clippy::manual_async_fn)]
157impl TaskStore for PostgresTaskStore {
158 fn save<'a>(
159 &'a self,
160 task: &'a Task,
161 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
162 Box::pin(async move {
163 let id = task.id.0.as_str();
164 let context_id = task.context_id.0.as_str();
165 let state = task.status.state.to_string();
166 let data = serde_json::to_value(task)
167 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
168 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
172
173 sqlx::query(
174 "INSERT INTO tasks (id, context_id, state, data, updated_at)
175 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
176 ON CONFLICT(id) DO UPDATE SET
177 context_id = EXCLUDED.context_id,
178 state = EXCLUDED.state,
179 data = EXCLUDED.data,
180 updated_at = EXCLUDED.updated_at",
181 )
182 .bind(id)
183 .bind(context_id)
184 .bind(&state)
185 .bind(&data)
186 .bind(&status_ts)
187 .execute(&self.pool)
188 .await
189 .map_err(to_a2a_error)?;
190
191 Ok(())
192 })
193 }
194
195 fn save_artifact_delta<'a>(
230 &'a self,
231 task: &'a Task,
232 delta: ArtifactDelta,
233 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
234 Box::pin(async move {
235 let Some(artifacts) = task.artifacts.as_ref() else {
236 return self.save(task).await;
237 };
238
239 let affected = match delta {
240 ArtifactDelta::AppendedParts { index, count } => {
241 match self.append_parts(task, artifacts, index, count).await? {
242 Some(rows) => rows,
243 None => return self.save(task).await,
244 }
245 }
246 ArtifactDelta::Pushed { index } => {
247 match self.push_artifact(task, artifacts, index).await? {
248 Some(rows) => rows,
249 None => return self.save(task).await,
250 }
251 }
252 };
253
254 if affected == 0 {
258 return self.save(task).await;
259 }
260
261 Ok(())
262 })
263 }
264
265 fn get<'a>(
266 &'a self,
267 id: &'a TaskId,
268 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
269 Box::pin(async move {
270 let row: Option<(serde_json::Value,)> =
271 sqlx::query_as("SELECT data FROM tasks WHERE id = $1")
272 .bind(id.0.as_str())
273 .fetch_optional(&self.pool)
274 .await
275 .map_err(to_a2a_error)?;
276
277 match row {
278 Some((data,)) => {
279 let task: Task = serde_json::from_value(data).map_err(|e| {
280 A2aError::internal(format!("failed to deserialize task: {e}"))
281 })?;
282 Ok(Some(task))
283 }
284 None => Ok(None),
285 }
286 })
287 }
288
289 #[allow(clippy::too_many_lines)]
290 fn list<'a>(
291 &'a self,
292 params: &'a ListTasksParams,
293 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
294 Box::pin(async move {
295 let mut conditions = Vec::new();
297 let mut bind_values: Vec<String> = Vec::new();
298
299 if let Some(ref ctx) = params.context_id {
300 bind_values.push(ctx.clone());
301 conditions.push(format!("context_id = ${}", bind_values.len()));
302 }
303 if let Some(ref status) = params.status {
304 bind_values.push(status.to_string());
305 conditions.push(format!("state = ${}", bind_values.len()));
306 }
307 if let Some(ref after) = params.status_timestamp_after {
312 let Some(after_ts) = super::status_timestamp_rfc3339(Some(after)) else {
313 return Ok(TaskListResponse::new(Vec::new()));
314 };
315 bind_values.push(after_ts);
316 conditions.push(format!(
317 "updated_at > (${})::timestamptz",
318 bind_values.len()
319 ));
320 }
321 if let Some(ref token) = params.page_token {
328 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
329 return Ok(TaskListResponse::new(Vec::new()));
330 };
331 bind_values.push(cursor_ua.to_string());
332 let ua_idx = bind_values.len();
333 bind_values.push(cursor_id.to_string());
334 let id_idx = bind_values.len();
335 conditions.push(format!(
336 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
337 ));
338 }
339
340 let where_clause = if conditions.is_empty() {
341 String::new()
342 } else {
343 format!("WHERE {}", conditions.join(" AND "))
344 };
345
346 let page_size = match params.page_size {
347 Some(0) | None => 50_u32,
348 Some(n) => n.min(1000),
349 };
350
351 let limit = super::pagination::fetch_limit(page_size);
355 let sql = format!(
356 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
357 data FROM tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
358 );
359
360 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
361 for val in &bind_values {
362 query = query.bind(val);
363 }
364
365 let rows: Vec<(String, serde_json::Value)> =
366 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
367
368 let mut rows: Vec<(String, Task)> = rows
369 .into_iter()
370 .map(|(updated_at, data)| {
371 serde_json::from_value::<Task>(data)
372 .map(|task| (updated_at, task))
373 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
374 })
375 .collect::<A2aResult<Vec<_>>>()?;
376
377 let next_page_token =
378 if super::pagination::has_next_page(rows.len(), page_size as usize) {
379 rows.truncate(page_size as usize);
380 rows.last()
381 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
382 .unwrap_or_default()
383 } else {
384 String::new()
385 };
386
387 #[allow(clippy::cast_possible_truncation)]
388 let page_len = rows.len() as u32;
389 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
390 let mut response = TaskListResponse::new(tasks);
391 response.next_page_token = next_page_token;
392 response.page_size = page_len;
393 Ok(response)
394 })
395 }
396
397 fn insert_if_absent<'a>(
398 &'a self,
399 task: &'a Task,
400 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
401 Box::pin(async move {
402 let id = task.id.0.as_str();
403 let context_id = task.context_id.0.as_str();
404 let state = task.status.state.to_string();
405 let data = serde_json::to_value(task)
406 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
407
408 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
409 let result = sqlx::query(
410 "INSERT INTO tasks (id, context_id, state, data, updated_at)
411 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
412 ON CONFLICT(id) DO NOTHING",
413 )
414 .bind(id)
415 .bind(context_id)
416 .bind(&state)
417 .bind(&data)
418 .bind(&status_ts)
419 .execute(&self.pool)
420 .await
421 .map_err(to_a2a_error)?;
422
423 Ok(result.rows_affected() > 0)
424 })
425 }
426
427 fn delete<'a>(
428 &'a self,
429 id: &'a TaskId,
430 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
431 Box::pin(async move {
432 sqlx::query("DELETE FROM tasks WHERE id = $1")
433 .bind(id.0.as_str())
434 .execute(&self.pool)
435 .await
436 .map_err(to_a2a_error)?;
437 Ok(())
438 })
439 }
440
441 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
442 Box::pin(async move {
443 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
444 .fetch_one(&self.pool)
445 .await
446 .map_err(to_a2a_error)?;
447 #[allow(clippy::cast_sign_loss)]
448 Ok(row.0 as u64)
449 })
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn to_a2a_error_formats_message() {
459 let pg_err = sqlx::Error::RowNotFound;
460 let a2a_err = to_a2a_error(pg_err);
461 let msg = format!("{a2a_err}");
462 assert!(
463 msg.contains("postgres error"),
464 "error message should contain 'postgres error': {msg}"
465 );
466 }
467}