a2a_protocol_server/store/
postgres_store.rs1use std::future::Future;
22use std::pin::Pin;
23
24use a2a_protocol_types::error::{A2aError, A2aResult};
25use a2a_protocol_types::params::ListTasksParams;
26use a2a_protocol_types::responses::TaskListResponse;
27use a2a_protocol_types::task::{Task, TaskId};
28use sqlx::postgres::{PgPool, PgPoolOptions};
29
30use super::task_store::TaskStore;
31
32#[derive(Debug, Clone)]
57pub struct PostgresTaskStore {
58 pool: PgPool,
59}
60
61impl PostgresTaskStore {
62 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
68 let pool = pg_pool(url).await?;
69 Self::from_pool(pool).await
70 }
71
72 pub async fn with_migrations(url: &str) -> Result<Self, sqlx::Error> {
82 let pool = pg_pool(url).await?;
83
84 let runner = super::pg_migration::PgMigrationRunner::new(pool.clone());
85 runner.run_pending().await?;
86
87 Ok(Self { pool })
88 }
89
90 pub async fn from_pool(pool: PgPool) -> Result<Self, sqlx::Error> {
96 sqlx::query(
97 "CREATE TABLE IF NOT EXISTS tasks (
98 id TEXT PRIMARY KEY,
99 context_id TEXT NOT NULL,
100 state TEXT NOT NULL,
101 data JSONB NOT NULL,
102 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
103 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
104 )",
105 )
106 .execute(&pool)
107 .await?;
108
109 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id)")
110 .execute(&pool)
111 .await?;
112
113 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state)")
114 .execute(&pool)
115 .await?;
116
117 sqlx::query(
118 "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state)",
119 )
120 .execute(&pool)
121 .await?;
122
123 sqlx::query(
126 "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC)",
127 )
128 .execute(&pool)
129 .await?;
130
131 Ok(Self { pool })
132 }
133}
134
135async fn pg_pool(url: &str) -> Result<PgPool, sqlx::Error> {
137 pg_pool_with_size(url, 10).await
138}
139
140async fn pg_pool_with_size(url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
142 PgPoolOptions::new()
143 .max_connections(max_connections)
144 .connect(url)
145 .await
146}
147
148#[allow(clippy::needless_pass_by_value)]
150fn to_a2a_error(e: sqlx::Error) -> A2aError {
151 A2aError::internal(format!("postgres error: {e}"))
152}
153
154#[allow(clippy::manual_async_fn)]
155impl TaskStore for PostgresTaskStore {
156 fn save<'a>(
157 &'a self,
158 task: &'a Task,
159 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
160 Box::pin(async move {
161 let id = task.id.0.as_str();
162 let context_id = task.context_id.0.as_str();
163 let state = task.status.state.to_string();
164 let data = serde_json::to_value(task)
165 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
166 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
170
171 sqlx::query(
172 "INSERT INTO tasks (id, context_id, state, data, updated_at)
173 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
174 ON CONFLICT(id) DO UPDATE SET
175 context_id = EXCLUDED.context_id,
176 state = EXCLUDED.state,
177 data = EXCLUDED.data,
178 updated_at = EXCLUDED.updated_at",
179 )
180 .bind(id)
181 .bind(context_id)
182 .bind(&state)
183 .bind(&data)
184 .bind(&status_ts)
185 .execute(&self.pool)
186 .await
187 .map_err(to_a2a_error)?;
188
189 Ok(())
190 })
191 }
192
193 fn get<'a>(
194 &'a self,
195 id: &'a TaskId,
196 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
197 Box::pin(async move {
198 let row: Option<(serde_json::Value,)> =
199 sqlx::query_as("SELECT data FROM tasks WHERE id = $1")
200 .bind(id.0.as_str())
201 .fetch_optional(&self.pool)
202 .await
203 .map_err(to_a2a_error)?;
204
205 match row {
206 Some((data,)) => {
207 let task: Task = serde_json::from_value(data).map_err(|e| {
208 A2aError::internal(format!("failed to deserialize task: {e}"))
209 })?;
210 Ok(Some(task))
211 }
212 None => Ok(None),
213 }
214 })
215 }
216
217 #[allow(clippy::too_many_lines)]
218 fn list<'a>(
219 &'a self,
220 params: &'a ListTasksParams,
221 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
222 Box::pin(async move {
223 let mut conditions = Vec::new();
225 let mut bind_values: Vec<String> = Vec::new();
226
227 if let Some(ref ctx) = params.context_id {
228 bind_values.push(ctx.clone());
229 conditions.push(format!("context_id = ${}", bind_values.len()));
230 }
231 if let Some(ref status) = params.status {
232 bind_values.push(status.to_string());
233 conditions.push(format!("state = ${}", bind_values.len()));
234 }
235 if let Some(ref after) = params.status_timestamp_after {
240 let Some(after_ts) = super::status_timestamp_rfc3339(Some(after)) else {
241 return Ok(TaskListResponse::new(Vec::new()));
242 };
243 bind_values.push(after_ts);
244 conditions.push(format!(
245 "updated_at > (${})::timestamptz",
246 bind_values.len()
247 ));
248 }
249 if let Some(ref token) = params.page_token {
256 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
257 return Ok(TaskListResponse::new(Vec::new()));
258 };
259 bind_values.push(cursor_ua.to_string());
260 let ua_idx = bind_values.len();
261 bind_values.push(cursor_id.to_string());
262 let id_idx = bind_values.len();
263 conditions.push(format!(
264 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
265 ));
266 }
267
268 let where_clause = if conditions.is_empty() {
269 String::new()
270 } else {
271 format!("WHERE {}", conditions.join(" AND "))
272 };
273
274 let page_size = match params.page_size {
275 Some(0) | None => 50_u32,
276 Some(n) => n.min(1000),
277 };
278
279 let limit = super::pagination::fetch_limit(page_size);
283 let sql = format!(
284 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
285 data FROM tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
286 );
287
288 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
289 for val in &bind_values {
290 query = query.bind(val);
291 }
292
293 let rows: Vec<(String, serde_json::Value)> =
294 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
295
296 let mut rows: Vec<(String, Task)> = rows
297 .into_iter()
298 .map(|(updated_at, data)| {
299 serde_json::from_value::<Task>(data)
300 .map(|task| (updated_at, task))
301 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
302 })
303 .collect::<A2aResult<Vec<_>>>()?;
304
305 let next_page_token =
306 if super::pagination::has_next_page(rows.len(), page_size as usize) {
307 rows.truncate(page_size as usize);
308 rows.last()
309 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
310 .unwrap_or_default()
311 } else {
312 String::new()
313 };
314
315 #[allow(clippy::cast_possible_truncation)]
316 let page_len = rows.len() as u32;
317 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
318 let mut response = TaskListResponse::new(tasks);
319 response.next_page_token = next_page_token;
320 response.page_size = page_len;
321 Ok(response)
322 })
323 }
324
325 fn insert_if_absent<'a>(
326 &'a self,
327 task: &'a Task,
328 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
329 Box::pin(async move {
330 let id = task.id.0.as_str();
331 let context_id = task.context_id.0.as_str();
332 let state = task.status.state.to_string();
333 let data = serde_json::to_value(task)
334 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
335
336 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
337 let result = sqlx::query(
338 "INSERT INTO tasks (id, context_id, state, data, updated_at)
339 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
340 ON CONFLICT(id) DO NOTHING",
341 )
342 .bind(id)
343 .bind(context_id)
344 .bind(&state)
345 .bind(&data)
346 .bind(&status_ts)
347 .execute(&self.pool)
348 .await
349 .map_err(to_a2a_error)?;
350
351 Ok(result.rows_affected() > 0)
352 })
353 }
354
355 fn delete<'a>(
356 &'a self,
357 id: &'a TaskId,
358 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
359 Box::pin(async move {
360 sqlx::query("DELETE FROM tasks WHERE id = $1")
361 .bind(id.0.as_str())
362 .execute(&self.pool)
363 .await
364 .map_err(to_a2a_error)?;
365 Ok(())
366 })
367 }
368
369 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
370 Box::pin(async move {
371 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
372 .fetch_one(&self.pool)
373 .await
374 .map_err(to_a2a_error)?;
375 #[allow(clippy::cast_sign_loss)]
376 Ok(row.0 as u64)
377 })
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn to_a2a_error_formats_message() {
387 let pg_err = sqlx::Error::RowNotFound;
388 let a2a_err = to_a2a_error(pg_err);
389 let msg = format!("{a2a_err}");
390 assert!(
391 msg.contains("postgres error"),
392 "error message should contain 'postgres error': {msg}"
393 );
394 }
395}