a2a_protocol_server/store/postgres_store/
store_impl.rs1use std::future::Future;
13use std::pin::Pin;
14
15use a2a_protocol_types::error::{A2aError, A2aResult};
16use a2a_protocol_types::events::StreamResponse;
17use a2a_protocol_types::params::ListTasksParams;
18use a2a_protocol_types::responses::TaskListResponse;
19use a2a_protocol_types::task::{Task, TaskId};
20
21use super::PostgresTaskStore;
22use super::pool::to_a2a_error;
23use crate::store::event_log_sql::{
24 decode_json_row, encode_json, limit_to_i64, report_no_op_append, seq_from_i64, seq_to_i64,
25};
26use crate::store::task_store::{ArtifactDelta, IdempotencyClaim, RecordedEvent, TaskStore};
27use a2a_protocol_types::message::MessageId;
28use sqlx::Row as _;
29
30use super::{event_log, idempotency};
31use crate::store::terminal::{refusal, sql_write_allowed};
32
33#[allow(clippy::manual_async_fn)]
34impl TaskStore for PostgresTaskStore {
35 fn supports_idempotency(&self) -> bool {
36 true
37 }
38
39 fn claim_idempotency_key<'a>(
40 &'a self,
41 key: &'a str,
42 message_id: &'a MessageId,
43 task_id: &'a TaskId,
44 ) -> Pin<Box<dyn Future<Output = A2aResult<IdempotencyClaim>> + Send + 'a>> {
45 Box::pin(async move {
46 let mut tx = self.pool.begin().await.map_err(to_a2a_error)?;
52
53 let inserted = sqlx::query(idempotency::CLAIM_SQL)
54 .bind(key)
55 .bind(message_id.0.as_str())
56 .bind(task_id.0.as_str())
57 .execute(&mut *tx)
58 .await
59 .map_err(to_a2a_error)?
60 .rows_affected();
61
62 if inserted == 1 {
63 tx.commit().await.map_err(to_a2a_error)?;
64 return Ok(IdempotencyClaim::Claimed);
65 }
66
67 let holder = sqlx::query(idempotency::HOLDER_SQL)
68 .bind(key)
69 .fetch_optional(&mut *tx)
70 .await
71 .map_err(to_a2a_error)?;
72 tx.commit().await.map_err(to_a2a_error)?;
73
74 let Some(row) = holder else {
75 return Err(A2aError::internal(
76 "idempotency key was neither claimed nor held; the row vanished \
77 inside the claiming transaction",
78 ));
79 };
80
81 let held_by: String = row.try_get("message_id").map_err(to_a2a_error)?;
82 let held_task: String = row.try_get("task_id").map_err(to_a2a_error)?;
83
84 if held_by == message_id.0 {
85 Ok(IdempotencyClaim::Replay(TaskId::new(held_task)))
86 } else {
87 Ok(IdempotencyClaim::Conflict {
88 held_by: MessageId::new(held_by),
89 })
90 }
91 })
92 }
93
94 fn release_idempotency_key<'a>(
95 &'a self,
96 key: &'a str,
97 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
98 Box::pin(async move {
99 sqlx::query(idempotency::RELEASE_SQL)
100 .bind(key)
101 .execute(&self.pool)
102 .await
103 .map_err(to_a2a_error)?;
104 Ok(())
105 })
106 }
107
108 fn save<'a>(
109 &'a self,
110 task: &'a Task,
111 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
112 Box::pin(async move {
113 let id = task.id.0.as_str();
114 let context_id = task.context_id.0.as_str();
115 let state = task.status.state.to_string();
116 let data = serde_json::to_value(task)
117 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
118 let status_ts =
122 crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
123
124 let written = sqlx::query(sql_write_allowed!(
130 "INSERT INTO tasks (id, context_id, state, data, updated_at)
131 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
132 ON CONFLICT(id) DO UPDATE SET
133 context_id = EXCLUDED.context_id,
134 state = EXCLUDED.state,
135 data = EXCLUDED.data,
136 updated_at = EXCLUDED.updated_at
137 WHERE ",
138 "tasks.state",
139 "EXCLUDED.state"
140 ))
141 .bind(id)
142 .bind(context_id)
143 .bind(&state)
144 .bind(&data)
145 .bind(&status_ts)
146 .execute(&self.pool)
147 .await
148 .map_err(to_a2a_error)?
149 .rows_affected();
150
151 if written == 0 {
152 let stored: Option<(String,)> =
156 sqlx::query_as("SELECT state FROM tasks WHERE id = $1")
157 .bind(id)
158 .fetch_optional(&self.pool)
159 .await
160 .map_err(to_a2a_error)?;
161 return Err(refusal(task, stored.map(|(s,)| s).as_deref()));
162 }
163
164 Ok(())
165 })
166 }
167
168 fn save_artifact_delta<'a>(
203 &'a self,
204 task: &'a Task,
205 delta: ArtifactDelta,
206 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
207 Box::pin(async move {
208 let Some(artifacts) = task.artifacts.as_ref() else {
209 return self.save(task).await;
210 };
211
212 let affected = match delta {
213 ArtifactDelta::AppendedParts { index, count } => {
214 match self.append_parts(task, artifacts, index, count).await? {
215 Some(rows) => rows,
216 None => return self.save(task).await,
217 }
218 }
219 ArtifactDelta::Pushed { index } => {
220 match self.push_artifact(task, artifacts, index).await? {
221 Some(rows) => rows,
222 None => return self.save(task).await,
223 }
224 }
225 };
226
227 if affected == 0 {
231 return self.save(task).await;
232 }
233
234 Ok(())
235 })
236 }
237
238 fn save_status_delta<'a>(
254 &'a self,
255 task: &'a Task,
256 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
257 Box::pin(async move {
258 let status = serde_json::to_value(&task.status)
259 .map_err(|e| A2aError::internal(format!("failed to serialize status: {e}")))?;
260 let state = task.status.state.to_string();
261 let status_ts =
262 crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
263
264 let affected = sqlx::query(sql_write_allowed!(
268 "UPDATE tasks
269 SET data = jsonb_set(data, ARRAY['status'], $1),
270 state = $2,
271 updated_at = COALESCE(($3)::timestamptz, now())
272 WHERE id = $4 AND ",
273 "state",
274 "$2"
275 ))
276 .bind(&status)
277 .bind(&state)
278 .bind(&status_ts)
279 .bind(task.id.0.as_str())
280 .execute(&self.pool)
281 .await
282 .map_err(to_a2a_error)?
283 .rows_affected();
284
285 if affected == 0 {
286 return self.save(task).await;
287 }
288
289 Ok(())
290 })
291 }
292
293 fn get<'a>(
294 &'a self,
295 id: &'a TaskId,
296 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
297 Box::pin(async move {
298 let row: Option<(serde_json::Value,)> =
299 sqlx::query_as("SELECT data FROM tasks WHERE id = $1")
300 .bind(id.0.as_str())
301 .fetch_optional(&self.pool)
302 .await
303 .map_err(to_a2a_error)?;
304
305 match row {
306 Some((data,)) => {
307 let task: Task = serde_json::from_value(data).map_err(|e| {
308 A2aError::internal(format!("failed to deserialize task: {e}"))
309 })?;
310 Ok(Some(task))
311 }
312 None => Ok(None),
313 }
314 })
315 }
316
317 #[allow(clippy::too_many_lines)]
318 fn list<'a>(
319 &'a self,
320 params: &'a ListTasksParams,
321 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
322 Box::pin(async move {
323 let mut conditions = Vec::new();
325 let mut bind_values: Vec<String> = Vec::new();
326
327 if let Some(ref ctx) = params.context_id {
328 bind_values.push(ctx.clone());
329 conditions.push(format!("context_id = ${}", bind_values.len()));
330 }
331 if let Some(ref status) = params.status {
332 bind_values.push(status.to_string());
333 conditions.push(format!("state = ${}", bind_values.len()));
334 }
335 if let Some(ref after) = params.status_timestamp_after {
340 let Some(after_ts) = crate::store::status_timestamp_rfc3339(Some(after)) else {
341 return Ok(TaskListResponse::new(Vec::new()));
342 };
343 bind_values.push(after_ts);
344 conditions.push(format!(
345 "updated_at > (${})::timestamptz",
346 bind_values.len()
347 ));
348 }
349 if let Some(ref token) = params.page_token {
356 let Some((cursor_ua, cursor_id)) = crate::store::cursor::decode(token) else {
357 return Ok(TaskListResponse::new(Vec::new()));
358 };
359 bind_values.push(cursor_ua.to_string());
360 let ua_idx = bind_values.len();
361 bind_values.push(cursor_id.to_string());
362 let id_idx = bind_values.len();
363 conditions.push(format!(
364 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
365 ));
366 }
367
368 let where_clause = if conditions.is_empty() {
369 String::new()
370 } else {
371 format!("WHERE {}", conditions.join(" AND "))
372 };
373
374 let page_size = match params.page_size {
375 Some(0) | None => 50_u32,
376 Some(n) => n.min(self.max_page_size),
377 };
378
379 let limit = crate::store::pagination::fetch_limit(page_size);
383 let sql = format!(
384 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
385 data FROM tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
386 );
387
388 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
389 for val in &bind_values {
390 query = query.bind(val);
391 }
392
393 let rows: Vec<(String, serde_json::Value)> =
394 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
395
396 let mut rows: Vec<(String, Task)> = rows
397 .into_iter()
398 .map(|(updated_at, data)| {
399 serde_json::from_value::<Task>(data)
400 .map(|task| (updated_at, task))
401 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
402 })
403 .collect::<A2aResult<Vec<_>>>()?;
404
405 let next_page_token =
406 if crate::store::pagination::has_next_page(rows.len(), page_size as usize) {
407 rows.truncate(page_size as usize);
408 rows.last()
409 .map(|(ua, task)| crate::store::cursor::encode(ua, task.id.0.as_str()))
410 .unwrap_or_default()
411 } else {
412 String::new()
413 };
414
415 #[allow(clippy::cast_possible_truncation)]
416 let page_len = rows.len() as u32;
417 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
418 let mut response = TaskListResponse::new(tasks);
419 response.next_page_token = next_page_token;
420 response.page_size = page_len;
421 Ok(response)
422 })
423 }
424
425 fn insert_if_absent<'a>(
426 &'a self,
427 task: &'a Task,
428 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
429 Box::pin(async move {
430 let id = task.id.0.as_str();
431 let context_id = task.context_id.0.as_str();
432 let state = task.status.state.to_string();
433 let data = serde_json::to_value(task)
434 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
435
436 let status_ts =
437 crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
438 let result = sqlx::query(
439 "INSERT INTO tasks (id, context_id, state, data, updated_at)
440 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
441 ON CONFLICT(id) DO NOTHING",
442 )
443 .bind(id)
444 .bind(context_id)
445 .bind(&state)
446 .bind(&data)
447 .bind(&status_ts)
448 .execute(&self.pool)
449 .await
450 .map_err(to_a2a_error)?;
451
452 Ok(result.rows_affected() > 0)
453 })
454 }
455
456 fn supports_event_log(&self) -> bool {
457 true
458 }
459
460 fn append_event<'a>(
461 &'a self,
462 task_id: &'a TaskId,
463 seq: u64,
464 event: &'a StreamResponse,
465 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
466 Box::pin(async move {
467 let position = seq_to_i64(seq)?;
472 let payload = encode_json(event)?;
473 let changed_nothing = sqlx::query(event_log::APPEND_SQL)
474 .bind(task_id.0.as_str())
475 .bind(position)
476 .bind(&payload)
477 .execute(&self.pool)
478 .await
479 .map_err(to_a2a_error)?
480 .rows_affected()
481 == 0;
482 if changed_nothing {
483 let stored: Option<(serde_json::Value,)> =
486 sqlx::query_as(event_log::SELECT_PAYLOAD_SQL)
487 .bind(task_id.0.as_str())
488 .bind(position)
489 .fetch_optional(&self.pool)
490 .await
491 .unwrap_or(None);
492 let same = stored.is_some_and(|(h,)| h == payload);
493 report_no_op_append(&*self.metrics, task_id, seq, same);
494 }
495 Ok(())
496 })
497 }
498
499 fn last_event_seq<'a>(
500 &'a self,
501 task_id: &'a TaskId,
502 ) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
503 Box::pin(async move {
504 let (max,): (i64,) = sqlx::query_as(event_log::LAST_SEQ_SQL)
505 .bind(task_id.0.as_str())
506 .fetch_one(&self.pool)
507 .await
508 .map_err(to_a2a_error)?;
509 seq_from_i64(max)
510 })
511 }
512
513 fn earliest_event_seq<'a>(
514 &'a self,
515 task_id: &'a TaskId,
516 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<u64>>> + Send + 'a>> {
517 Box::pin(async move {
518 let (min,): (Option<i64>,) = sqlx::query_as(event_log::EARLIEST_SEQ_SQL)
519 .bind(task_id.0.as_str())
520 .fetch_one(&self.pool)
521 .await
522 .map_err(to_a2a_error)?;
523 min.map(seq_from_i64).transpose()
524 })
525 }
526
527 fn read_events<'a>(
528 &'a self,
529 task_id: &'a TaskId,
530 after_seq: u64,
531 limit: usize,
532 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<RecordedEvent>>> + Send + 'a>> {
533 Box::pin(async move {
534 let rows: Vec<(i64, serde_json::Value)> = sqlx::query_as(event_log::SELECT_AFTER_SQL)
535 .bind(task_id.0.as_str())
536 .bind(seq_to_i64(after_seq)?)
537 .bind(limit_to_i64(limit))
538 .fetch_all(&self.pool)
539 .await
540 .map_err(to_a2a_error)?;
541 rows.into_iter().map(decode_json_row).collect()
542 })
543 }
544
545 fn delete<'a>(
546 &'a self,
547 id: &'a TaskId,
548 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
549 Box::pin(async move {
550 sqlx::query(event_log::DELETE_FOR_TASK_SQL)
554 .bind(id.0.as_str())
555 .execute(&self.pool)
556 .await
557 .map_err(to_a2a_error)?;
558
559 sqlx::query("DELETE FROM tasks WHERE id = $1")
560 .bind(id.0.as_str())
561 .execute(&self.pool)
562 .await
563 .map_err(to_a2a_error)?;
564 Ok(())
565 })
566 }
567
568 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
569 Box::pin(async move {
570 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
571 .fetch_one(&self.pool)
572 .await
573 .map_err(to_a2a_error)?;
574 #[allow(clippy::cast_sign_loss)]
575 Ok(row.0 as u64)
576 })
577 }
578}