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};
31
32#[allow(clippy::manual_async_fn)]
33impl TaskStore for PostgresTaskStore {
34 fn supports_idempotency(&self) -> bool {
35 true
36 }
37
38 fn claim_idempotency_key<'a>(
39 &'a self,
40 key: &'a str,
41 message_id: &'a MessageId,
42 task_id: &'a TaskId,
43 ) -> Pin<Box<dyn Future<Output = A2aResult<IdempotencyClaim>> + Send + 'a>> {
44 Box::pin(async move {
45 let mut tx = self.pool.begin().await.map_err(to_a2a_error)?;
51
52 let inserted = sqlx::query(idempotency::CLAIM_SQL)
53 .bind(key)
54 .bind(message_id.0.as_str())
55 .bind(task_id.0.as_str())
56 .execute(&mut *tx)
57 .await
58 .map_err(to_a2a_error)?
59 .rows_affected();
60
61 if inserted == 1 {
62 tx.commit().await.map_err(to_a2a_error)?;
63 return Ok(IdempotencyClaim::Claimed);
64 }
65
66 let holder = sqlx::query(idempotency::HOLDER_SQL)
67 .bind(key)
68 .fetch_optional(&mut *tx)
69 .await
70 .map_err(to_a2a_error)?;
71 tx.commit().await.map_err(to_a2a_error)?;
72
73 let Some(row) = holder else {
74 return Err(A2aError::internal(
75 "idempotency key was neither claimed nor held; the row vanished \
76 inside the claiming transaction",
77 ));
78 };
79
80 let held_by: String = row.try_get("message_id").map_err(to_a2a_error)?;
81 let held_task: String = row.try_get("task_id").map_err(to_a2a_error)?;
82
83 if held_by == message_id.0 {
84 Ok(IdempotencyClaim::Replay(TaskId::new(held_task)))
85 } else {
86 Ok(IdempotencyClaim::Conflict {
87 held_by: MessageId::new(held_by),
88 })
89 }
90 })
91 }
92
93 fn release_idempotency_key<'a>(
94 &'a self,
95 key: &'a str,
96 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
97 Box::pin(async move {
98 sqlx::query(idempotency::RELEASE_SQL)
99 .bind(key)
100 .execute(&self.pool)
101 .await
102 .map_err(to_a2a_error)?;
103 Ok(())
104 })
105 }
106
107 fn save<'a>(
108 &'a self,
109 task: &'a Task,
110 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
111 Box::pin(async move {
112 let id = task.id.0.as_str();
113 let context_id = task.context_id.0.as_str();
114 let state = task.status.state.to_string();
115 let data = serde_json::to_value(task)
116 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
117 let status_ts =
121 crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
122
123 sqlx::query(
124 "INSERT INTO tasks (id, context_id, state, data, updated_at)
125 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
126 ON CONFLICT(id) DO UPDATE SET
127 context_id = EXCLUDED.context_id,
128 state = EXCLUDED.state,
129 data = EXCLUDED.data,
130 updated_at = EXCLUDED.updated_at",
131 )
132 .bind(id)
133 .bind(context_id)
134 .bind(&state)
135 .bind(&data)
136 .bind(&status_ts)
137 .execute(&self.pool)
138 .await
139 .map_err(to_a2a_error)?;
140
141 Ok(())
142 })
143 }
144
145 fn save_artifact_delta<'a>(
180 &'a self,
181 task: &'a Task,
182 delta: ArtifactDelta,
183 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
184 Box::pin(async move {
185 let Some(artifacts) = task.artifacts.as_ref() else {
186 return self.save(task).await;
187 };
188
189 let affected = match delta {
190 ArtifactDelta::AppendedParts { index, count } => {
191 match self.append_parts(task, artifacts, index, count).await? {
192 Some(rows) => rows,
193 None => return self.save(task).await,
194 }
195 }
196 ArtifactDelta::Pushed { index } => {
197 match self.push_artifact(task, artifacts, index).await? {
198 Some(rows) => rows,
199 None => return self.save(task).await,
200 }
201 }
202 };
203
204 if affected == 0 {
208 return self.save(task).await;
209 }
210
211 Ok(())
212 })
213 }
214
215 fn get<'a>(
216 &'a self,
217 id: &'a TaskId,
218 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
219 Box::pin(async move {
220 let row: Option<(serde_json::Value,)> =
221 sqlx::query_as("SELECT data FROM tasks WHERE id = $1")
222 .bind(id.0.as_str())
223 .fetch_optional(&self.pool)
224 .await
225 .map_err(to_a2a_error)?;
226
227 match row {
228 Some((data,)) => {
229 let task: Task = serde_json::from_value(data).map_err(|e| {
230 A2aError::internal(format!("failed to deserialize task: {e}"))
231 })?;
232 Ok(Some(task))
233 }
234 None => Ok(None),
235 }
236 })
237 }
238
239 #[allow(clippy::too_many_lines)]
240 fn list<'a>(
241 &'a self,
242 params: &'a ListTasksParams,
243 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
244 Box::pin(async move {
245 let mut conditions = Vec::new();
247 let mut bind_values: Vec<String> = Vec::new();
248
249 if let Some(ref ctx) = params.context_id {
250 bind_values.push(ctx.clone());
251 conditions.push(format!("context_id = ${}", bind_values.len()));
252 }
253 if let Some(ref status) = params.status {
254 bind_values.push(status.to_string());
255 conditions.push(format!("state = ${}", bind_values.len()));
256 }
257 if let Some(ref after) = params.status_timestamp_after {
262 let Some(after_ts) = crate::store::status_timestamp_rfc3339(Some(after)) else {
263 return Ok(TaskListResponse::new(Vec::new()));
264 };
265 bind_values.push(after_ts);
266 conditions.push(format!(
267 "updated_at > (${})::timestamptz",
268 bind_values.len()
269 ));
270 }
271 if let Some(ref token) = params.page_token {
278 let Some((cursor_ua, cursor_id)) = crate::store::cursor::decode(token) else {
279 return Ok(TaskListResponse::new(Vec::new()));
280 };
281 bind_values.push(cursor_ua.to_string());
282 let ua_idx = bind_values.len();
283 bind_values.push(cursor_id.to_string());
284 let id_idx = bind_values.len();
285 conditions.push(format!(
286 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
287 ));
288 }
289
290 let where_clause = if conditions.is_empty() {
291 String::new()
292 } else {
293 format!("WHERE {}", conditions.join(" AND "))
294 };
295
296 let page_size = match params.page_size {
297 Some(0) | None => 50_u32,
298 Some(n) => n.min(self.max_page_size),
299 };
300
301 let limit = crate::store::pagination::fetch_limit(page_size);
305 let sql = format!(
306 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
307 data FROM tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
308 );
309
310 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
311 for val in &bind_values {
312 query = query.bind(val);
313 }
314
315 let rows: Vec<(String, serde_json::Value)> =
316 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
317
318 let mut rows: Vec<(String, Task)> = rows
319 .into_iter()
320 .map(|(updated_at, data)| {
321 serde_json::from_value::<Task>(data)
322 .map(|task| (updated_at, task))
323 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
324 })
325 .collect::<A2aResult<Vec<_>>>()?;
326
327 let next_page_token =
328 if crate::store::pagination::has_next_page(rows.len(), page_size as usize) {
329 rows.truncate(page_size as usize);
330 rows.last()
331 .map(|(ua, task)| crate::store::cursor::encode(ua, task.id.0.as_str()))
332 .unwrap_or_default()
333 } else {
334 String::new()
335 };
336
337 #[allow(clippy::cast_possible_truncation)]
338 let page_len = rows.len() as u32;
339 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
340 let mut response = TaskListResponse::new(tasks);
341 response.next_page_token = next_page_token;
342 response.page_size = page_len;
343 Ok(response)
344 })
345 }
346
347 fn insert_if_absent<'a>(
348 &'a self,
349 task: &'a Task,
350 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
351 Box::pin(async move {
352 let id = task.id.0.as_str();
353 let context_id = task.context_id.0.as_str();
354 let state = task.status.state.to_string();
355 let data = serde_json::to_value(task)
356 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
357
358 let status_ts =
359 crate::store::status_timestamp_rfc3339(task.status.timestamp.as_deref());
360 let result = sqlx::query(
361 "INSERT INTO tasks (id, context_id, state, data, updated_at)
362 VALUES ($1, $2, $3, $4, COALESCE(($5)::timestamptz, now()))
363 ON CONFLICT(id) DO NOTHING",
364 )
365 .bind(id)
366 .bind(context_id)
367 .bind(&state)
368 .bind(&data)
369 .bind(&status_ts)
370 .execute(&self.pool)
371 .await
372 .map_err(to_a2a_error)?;
373
374 Ok(result.rows_affected() > 0)
375 })
376 }
377
378 fn supports_event_log(&self) -> bool {
379 true
380 }
381
382 fn append_event<'a>(
383 &'a self,
384 task_id: &'a TaskId,
385 seq: u64,
386 event: &'a StreamResponse,
387 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
388 Box::pin(async move {
389 let position = seq_to_i64(seq)?;
394 let payload = encode_json(event)?;
395 let changed_nothing = sqlx::query(event_log::APPEND_SQL)
396 .bind(task_id.0.as_str())
397 .bind(position)
398 .bind(&payload)
399 .execute(&self.pool)
400 .await
401 .map_err(to_a2a_error)?
402 .rows_affected()
403 == 0;
404 if changed_nothing {
405 let stored: Option<(serde_json::Value,)> =
408 sqlx::query_as(event_log::SELECT_PAYLOAD_SQL)
409 .bind(task_id.0.as_str())
410 .bind(position)
411 .fetch_optional(&self.pool)
412 .await
413 .unwrap_or(None);
414 let same = stored.is_some_and(|(h,)| h == payload);
415 report_no_op_append(&*self.metrics, task_id, seq, same);
416 }
417 Ok(())
418 })
419 }
420
421 fn last_event_seq<'a>(
422 &'a self,
423 task_id: &'a TaskId,
424 ) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
425 Box::pin(async move {
426 let (max,): (i64,) = sqlx::query_as(event_log::LAST_SEQ_SQL)
427 .bind(task_id.0.as_str())
428 .fetch_one(&self.pool)
429 .await
430 .map_err(to_a2a_error)?;
431 seq_from_i64(max)
432 })
433 }
434
435 fn earliest_event_seq<'a>(
436 &'a self,
437 task_id: &'a TaskId,
438 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<u64>>> + Send + 'a>> {
439 Box::pin(async move {
440 let (min,): (Option<i64>,) = sqlx::query_as(event_log::EARLIEST_SEQ_SQL)
441 .bind(task_id.0.as_str())
442 .fetch_one(&self.pool)
443 .await
444 .map_err(to_a2a_error)?;
445 min.map(seq_from_i64).transpose()
446 })
447 }
448
449 fn read_events<'a>(
450 &'a self,
451 task_id: &'a TaskId,
452 after_seq: u64,
453 limit: usize,
454 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<RecordedEvent>>> + Send + 'a>> {
455 Box::pin(async move {
456 let rows: Vec<(i64, serde_json::Value)> = sqlx::query_as(event_log::SELECT_AFTER_SQL)
457 .bind(task_id.0.as_str())
458 .bind(seq_to_i64(after_seq)?)
459 .bind(limit_to_i64(limit))
460 .fetch_all(&self.pool)
461 .await
462 .map_err(to_a2a_error)?;
463 rows.into_iter().map(decode_json_row).collect()
464 })
465 }
466
467 fn delete<'a>(
468 &'a self,
469 id: &'a TaskId,
470 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
471 Box::pin(async move {
472 sqlx::query(event_log::DELETE_FOR_TASK_SQL)
476 .bind(id.0.as_str())
477 .execute(&self.pool)
478 .await
479 .map_err(to_a2a_error)?;
480
481 sqlx::query("DELETE FROM tasks WHERE id = $1")
482 .bind(id.0.as_str())
483 .execute(&self.pool)
484 .await
485 .map_err(to_a2a_error)?;
486 Ok(())
487 })
488 }
489
490 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
491 Box::pin(async move {
492 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
493 .fetch_one(&self.pool)
494 .await
495 .map_err(to_a2a_error)?;
496 #[allow(clippy::cast_sign_loss)]
497 Ok(row.0 as u64)
498 })
499 }
500}