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}
53
54impl TenantAwarePostgresTaskStore {
55 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
61 let pool = PgPoolOptions::new()
62 .max_connections(10)
63 .connect(url)
64 .await?;
65 Self::from_pool(pool).await
66 }
67
68 pub async fn from_pool(pool: PgPool) -> Result<Self, sqlx::Error> {
74 sqlx::query(
75 "CREATE TABLE IF NOT EXISTS tenant_tasks (
76 tenant_id TEXT NOT NULL DEFAULT '',
77 id TEXT NOT NULL,
78 context_id TEXT NOT NULL,
79 state TEXT NOT NULL,
80 data JSONB NOT NULL,
81 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
82 updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
83 PRIMARY KEY (tenant_id, id)
84 )",
85 )
86 .execute(&pool)
87 .await?;
88
89 sqlx::query(
90 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
91 )
92 .execute(&pool)
93 .await?;
94
95 sqlx::query(
96 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
97 )
98 .execute(&pool)
99 .await?;
100
101 sqlx::query(
104 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
105 )
106 .execute(&pool)
107 .await?;
108
109 Ok(Self { pool })
110 }
111}
112
113fn to_a2a_error(e: &sqlx::Error) -> A2aError {
114 A2aError::internal(format!("postgres error: {e}"))
115}
116
117#[allow(clippy::manual_async_fn)]
118impl TaskStore for TenantAwarePostgresTaskStore {
119 fn save<'a>(
120 &'a self,
121 task: &'a Task,
122 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
123 Box::pin(async move {
124 let tenant = TenantContext::current();
125 let id = task.id.0.as_str();
126 let context_id = task.context_id.0.as_str();
127 let state = task.status.state.to_string();
128 let data = serde_json::to_value(task)
129 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
130 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
134
135 sqlx::query(
136 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
137 VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, now()))
138 ON CONFLICT(tenant_id, id) DO UPDATE SET
139 context_id = EXCLUDED.context_id,
140 state = EXCLUDED.state,
141 data = EXCLUDED.data,
142 updated_at = EXCLUDED.updated_at",
143 )
144 .bind(&tenant)
145 .bind(id)
146 .bind(context_id)
147 .bind(&state)
148 .bind(&data)
149 .bind(&status_ts)
150 .execute(&self.pool)
151 .await
152 .map_err(|e| to_a2a_error(&e))?;
153
154 Ok(())
155 })
156 }
157
158 fn get<'a>(
159 &'a self,
160 id: &'a TaskId,
161 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
162 Box::pin(async move {
163 let tenant = TenantContext::current();
164 let row: Option<(serde_json::Value,)> =
165 sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = $1 AND id = $2")
166 .bind(&tenant)
167 .bind(id.0.as_str())
168 .fetch_optional(&self.pool)
169 .await
170 .map_err(|e| to_a2a_error(&e))?;
171
172 match row {
173 Some((data,)) => {
174 let task: Task = serde_json::from_value(data)
175 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
176 Ok(Some(task))
177 }
178 None => Ok(None),
179 }
180 })
181 }
182
183 #[allow(clippy::too_many_lines)]
184 fn list<'a>(
185 &'a self,
186 params: &'a ListTasksParams,
187 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
188 Box::pin(async move {
189 let tenant = TenantContext::current();
190 let mut conditions = vec!["tenant_id = $1".to_string()];
191 let mut bind_values: Vec<String> = vec![tenant];
192
193 if let Some(ref ctx) = params.context_id {
194 bind_values.push(ctx.clone());
195 conditions.push(format!("context_id = ${}", bind_values.len()));
196 }
197 if let Some(ref status) = params.status {
198 bind_values.push(status.to_string());
199 conditions.push(format!("state = ${}", bind_values.len()));
200 }
201 if let Some(ref after) = params.status_timestamp_after {
206 let Some(after_ts) = super::status_timestamp_rfc3339(Some(after)) else {
207 return Ok(TaskListResponse::new(Vec::new()));
208 };
209 bind_values.push(after_ts);
210 conditions.push(format!(
211 "updated_at > (${})::timestamptz",
212 bind_values.len()
213 ));
214 }
215 if let Some(ref token) = params.page_token {
221 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
222 return Ok(TaskListResponse::new(Vec::new()));
223 };
224 bind_values.push(cursor_ua.to_string());
225 let ua_idx = bind_values.len();
226 bind_values.push(cursor_id.to_string());
227 let id_idx = bind_values.len();
228 conditions.push(format!(
229 "(updated_at, id) < ((${ua_idx})::timestamp AT TIME ZONE 'UTC', ${id_idx})"
230 ));
231 }
232
233 let where_clause = format!("WHERE {}", conditions.join(" AND "));
234
235 let page_size = match params.page_size {
236 Some(0) | None => 50_u32,
237 Some(n) => n.min(1000),
238 };
239
240 let limit = super::pagination::fetch_limit(page_size);
241 let sql = format!(
242 "SELECT to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ua, \
243 data FROM tenant_tasks {where_clause} ORDER BY updated_at DESC, id DESC LIMIT {limit}"
244 );
245
246 let mut query = sqlx::query_as::<_, (String, serde_json::Value)>(&sql);
247 for val in &bind_values {
248 query = query.bind(val);
249 }
250
251 let rows: Vec<(String, serde_json::Value)> = query
252 .fetch_all(&self.pool)
253 .await
254 .map_err(|e| to_a2a_error(&e))?;
255
256 let mut rows: Vec<(String, Task)> = rows
257 .into_iter()
258 .map(|(updated_at, data)| {
259 serde_json::from_value::<Task>(data)
260 .map(|task| (updated_at, task))
261 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
262 })
263 .collect::<A2aResult<Vec<_>>>()?;
264
265 let next_page_token =
266 if super::pagination::has_next_page(rows.len(), page_size as usize) {
267 rows.truncate(page_size as usize);
268 rows.last()
269 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
270 .unwrap_or_default()
271 } else {
272 String::new()
273 };
274
275 #[allow(clippy::cast_possible_truncation)]
276 let page_len = rows.len() as u32;
277 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
278 let mut response = TaskListResponse::new(tasks);
279 response.next_page_token = next_page_token;
280 response.page_size = page_len;
281 Ok(response)
282 })
283 }
284
285 fn insert_if_absent<'a>(
286 &'a self,
287 task: &'a Task,
288 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
289 Box::pin(async move {
290 let tenant = TenantContext::current();
291 let id = task.id.0.as_str();
292 let context_id = task.context_id.0.as_str();
293 let state = task.status.state.to_string();
294 let data = serde_json::to_value(task)
295 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
296 let status_ts = super::status_timestamp_rfc3339(task.status.timestamp.as_deref());
297
298 let result = sqlx::query(
299 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
300 VALUES ($1, $2, $3, $4, $5, COALESCE(($6)::timestamptz, now()))
301 ON CONFLICT(tenant_id, id) DO NOTHING",
302 )
303 .bind(&tenant)
304 .bind(id)
305 .bind(context_id)
306 .bind(&state)
307 .bind(&data)
308 .bind(&status_ts)
309 .execute(&self.pool)
310 .await
311 .map_err(|e| to_a2a_error(&e))?;
312
313 Ok(result.rows_affected() > 0)
314 })
315 }
316
317 fn delete<'a>(
318 &'a self,
319 id: &'a TaskId,
320 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
321 Box::pin(async move {
322 let tenant = TenantContext::current();
323 sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = $1 AND id = $2")
324 .bind(&tenant)
325 .bind(id.0.as_str())
326 .execute(&self.pool)
327 .await
328 .map_err(|e| to_a2a_error(&e))?;
329 Ok(())
330 })
331 }
332
333 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
334 Box::pin(async move {
335 let tenant = TenantContext::current();
336 let row: (i64,) =
337 sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = $1")
338 .bind(&tenant)
339 .fetch_one(&self.pool)
340 .await
341 .map_err(|e| to_a2a_error(&e))?;
342 #[allow(clippy::cast_sign_loss)]
343 Ok(row.0 as u64)
344 })
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn to_a2a_error_formats_message() {
354 let pg_err = sqlx::Error::RowNotFound;
355 let a2a_err = to_a2a_error(&pg_err);
356 let msg = format!("{a2a_err}");
357 assert!(
358 msg.contains("postgres error"),
359 "error message should contain 'postgres error': {msg}"
360 );
361 }
362}