1use std::future::Future;
32use std::pin::Pin;
33
34use a2a_protocol_types::error::{A2aError, A2aResult};
35use a2a_protocol_types::params::ListTasksParams;
36use a2a_protocol_types::responses::TaskListResponse;
37use a2a_protocol_types::task::{Task, TaskId};
38use sqlx::sqlite::SqlitePool;
39
40use super::task_store::TaskStore;
41use super::tenant::TenantContext;
42
43#[derive(Debug, Clone)]
65pub struct TenantAwareSqliteTaskStore {
66 pool: SqlitePool,
67 max_page_size: u32,
70}
71
72impl TenantAwareSqliteTaskStore {
73 #[must_use]
81 pub const fn with_max_page_size(mut self, max: u32) -> Self {
82 self.max_page_size = max;
83 self
84 }
85 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
91 let pool = sqlite_pool(url).await?;
92 Self::from_pool(pool).await
93 }
94
95 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
101 sqlx::query(
102 "CREATE TABLE IF NOT EXISTS tenant_tasks (
103 tenant_id TEXT NOT NULL DEFAULT '',
104 id TEXT NOT NULL,
105 context_id TEXT NOT NULL,
106 state TEXT NOT NULL,
107 data TEXT NOT NULL,
108 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
109 created_at TEXT NOT NULL DEFAULT (datetime('now')),
110 PRIMARY KEY (tenant_id, id)
111 )",
112 )
113 .execute(&pool)
114 .await?;
115
116 sqlx::query(
117 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
118 )
119 .execute(&pool)
120 .await?;
121
122 sqlx::query(
123 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
124 )
125 .execute(&pool)
126 .await?;
127
128 sqlx::query(
129 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx_state ON tenant_tasks(tenant_id, context_id, state)",
130 )
131 .execute(&pool)
132 .await?;
133
134 sqlx::query(
137 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
138 )
139 .execute(&pool)
140 .await?;
141
142 Ok(Self {
143 pool,
144 max_page_size: crate::store::DEFAULT_MAX_PAGE_SIZE,
145 })
146 }
147
148 pub async fn purge_expired(
172 &self,
173 policy: &super::retention::RetentionPolicy,
174 ) -> A2aResult<super::retention::PurgeReport> {
175 super::retention::sqlite::purge(&self.pool, "tenant_tasks", None, policy)
176 .await
177 .map_err(|e| to_a2a_error(&e))
178 }
179}
180
181use crate::sqlite_pool::sqlite_pool;
182
183fn to_a2a_error(e: &sqlx::Error) -> A2aError {
184 A2aError::internal(format!("sqlite error: {e}"))
185}
186
187#[allow(clippy::manual_async_fn)]
188impl TaskStore for TenantAwareSqliteTaskStore {
189 fn save<'a>(
190 &'a self,
191 task: &'a Task,
192 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
193 Box::pin(async move {
194 let tenant = TenantContext::current();
195 let id = task.id.0.as_str();
196 let context_id = task.context_id.0.as_str();
197 let state = task.status.state.to_string();
198 let data = serde_json::to_string(task)
199 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
200 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
204
205 sqlx::query(
206 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
207 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))
208 ON CONFLICT(tenant_id, id) DO UPDATE SET
209 context_id = excluded.context_id,
210 state = excluded.state,
211 data = excluded.data,
212 updated_at = excluded.updated_at",
213 )
214 .bind(&tenant)
215 .bind(id)
216 .bind(context_id)
217 .bind(&state)
218 .bind(&data)
219 .bind(&status_ts)
220 .execute(&self.pool)
221 .await
222 .map_err(|e| to_a2a_error(&e))?;
223
224 Ok(())
225 })
226 }
227
228 fn get<'a>(
229 &'a self,
230 id: &'a TaskId,
231 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
232 Box::pin(async move {
233 let tenant = TenantContext::current();
234 let row: Option<(String,)> =
235 sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
236 .bind(&tenant)
237 .bind(id.0.as_str())
238 .fetch_optional(&self.pool)
239 .await
240 .map_err(|e| to_a2a_error(&e))?;
241
242 match row {
243 Some((data,)) => {
244 let task: Task = serde_json::from_str(&data)
245 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
246 Ok(Some(task))
247 }
248 None => Ok(None),
249 }
250 })
251 }
252
253 #[allow(clippy::too_many_lines)]
254 fn list<'a>(
255 &'a self,
256 params: &'a ListTasksParams,
257 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
258 Box::pin(async move {
259 let tenant = TenantContext::current();
260 let mut conditions = vec!["tenant_id = ?1".to_string()];
261 let mut bind_values: Vec<String> = vec![tenant];
262
263 if let Some(ref ctx) = params.context_id {
264 conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
265 bind_values.push(ctx.clone());
266 }
267 if let Some(ref status) = params.status {
268 conditions.push(format!("state = ?{}", bind_values.len() + 1));
269 bind_values.push(status.to_string());
270 }
271 if let Some(ref after) = params.status_timestamp_after {
276 let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
277 return Ok(TaskListResponse::new(Vec::new()));
278 };
279 conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
280 bind_values.push(after_dt);
281 }
282 if let Some(ref token) = params.page_token {
286 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
287 return Ok(TaskListResponse::new(Vec::new()));
288 };
289 let p = bind_values.len();
290 conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
291 bind_values.push(cursor_ua.to_string());
292 bind_values.push(cursor_id.to_string());
293 }
294
295 let where_clause = format!("WHERE {}", conditions.join(" AND "));
296
297 let page_size = match params.page_size {
298 Some(0) | None => 50_u32,
299 Some(n) => n.min(self.max_page_size),
300 };
301
302 let limit = super::pagination::fetch_limit(page_size);
303 let sql = format!(
304 "SELECT updated_at, data FROM tenant_tasks {where_clause} \
305 ORDER BY updated_at DESC, id DESC LIMIT {limit}"
306 );
307
308 let mut query = sqlx::query_as::<_, (String, String)>(&sql);
309 for val in &bind_values {
310 query = query.bind(val);
311 }
312
313 let rows: Vec<(String, String)> = query
314 .fetch_all(&self.pool)
315 .await
316 .map_err(|e| to_a2a_error(&e))?;
317
318 let mut rows: Vec<(String, Task)> = rows
319 .into_iter()
320 .map(|(updated_at, data)| {
321 serde_json::from_str::<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 super::pagination::has_next_page(rows.len(), page_size as usize) {
329 rows.truncate(page_size as usize);
330 rows.last()
331 .map(|(ua, task)| super::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 tenant = TenantContext::current();
353 let id = task.id.0.as_str();
354 let context_id = task.context_id.0.as_str();
355 let state = task.status.state.to_string();
356 let data = serde_json::to_string(task)
357 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
358 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
359
360 let result = sqlx::query(
361 "INSERT OR IGNORE INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
362 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))",
363 )
364 .bind(&tenant)
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(|e| to_a2a_error(&e))?;
373
374 Ok(result.rows_affected() > 0)
375 })
376 }
377
378 fn delete<'a>(
379 &'a self,
380 id: &'a TaskId,
381 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
382 Box::pin(async move {
383 let tenant = TenantContext::current();
384 sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
385 .bind(&tenant)
386 .bind(id.0.as_str())
387 .execute(&self.pool)
388 .await
389 .map_err(|e| to_a2a_error(&e))?;
390 Ok(())
391 })
392 }
393
394 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
395 Box::pin(async move {
396 let tenant = TenantContext::current();
397 let row: (i64,) =
398 sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = ?1")
399 .bind(&tenant)
400 .fetch_one(&self.pool)
401 .await
402 .map_err(|e| to_a2a_error(&e))?;
403 #[allow(clippy::cast_sign_loss)]
404 Ok(row.0 as u64)
405 })
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
413
414 async fn make_store() -> TenantAwareSqliteTaskStore {
415 TenantAwareSqliteTaskStore::new("sqlite::memory:")
416 .await
417 .expect("failed to create in-memory tenant store")
418 }
419
420 fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
421 Task {
422 id: TaskId::new(id),
423 context_id: ContextId::new(ctx),
424 status: TaskStatus::new(state),
425 history: None,
426 artifacts: None,
427 metadata: None,
428 }
429 }
430
431 #[tokio::test]
440 async fn purging_one_tenant_leaves_the_same_id_under_another() {
441 use crate::store::retention::RetentionPolicy;
442 use std::time::Duration;
443
444 let store = make_store().await;
445 for tenant in ["acme", "globex"] {
446 TenantContext::scope(tenant, async {
447 store
448 .save(&make_task("shared-id", "ctx", TaskState::Completed))
449 .await
450 .unwrap();
451 })
452 .await;
453 }
454
455 sqlx::query(
457 "UPDATE tenant_tasks \
458 SET updated_at = strftime('%Y-%m-%d %H:%M:%f','now','-7200 seconds') \
459 WHERE tenant_id = 'acme'",
460 )
461 .execute(&store.pool)
462 .await
463 .expect("backdate");
464
465 let report = store
466 .purge_expired(&RetentionPolicy::new(Duration::from_secs(3_600)))
467 .await
468 .expect("purge");
469 assert_eq!(report.tasks_deleted, 1, "only acme's copy was old enough");
470
471 TenantContext::scope("globex", async {
472 assert!(
473 store
474 .get(&TaskId::new("shared-id"))
475 .await
476 .unwrap()
477 .is_some(),
478 "globex must still have its own task of the same id"
479 );
480 })
481 .await;
482 TenantContext::scope("acme", async {
483 assert!(
484 store
485 .get(&TaskId::new("shared-id"))
486 .await
487 .unwrap()
488 .is_none(),
489 "acme's expired copy should be gone"
490 );
491 })
492 .await;
493 }
494
495 #[tokio::test]
496 async fn save_and_get_within_tenant() {
497 let store = make_store().await;
498 TenantContext::scope("acme", async {
499 store
500 .save(&make_task("t1", "ctx1", TaskState::Submitted))
501 .await
502 .unwrap();
503 let task = store.get(&TaskId::new("t1")).await.unwrap();
504 assert!(
505 task.is_some(),
506 "task should be retrievable within its tenant"
507 );
508 assert_eq!(task.unwrap().id, TaskId::new("t1"));
509 })
510 .await;
511 }
512
513 #[tokio::test]
514 async fn tenant_isolation_get() {
515 let store = make_store().await;
516 TenantContext::scope("tenant-a", async {
517 store
518 .save(&make_task("t1", "ctx1", TaskState::Submitted))
519 .await
520 .unwrap();
521 })
522 .await;
523
524 TenantContext::scope("tenant-b", async {
525 let result = store.get(&TaskId::new("t1")).await.unwrap();
526 assert!(result.is_none(), "tenant-b should not see tenant-a's task");
527 })
528 .await;
529 }
530
531 #[tokio::test]
532 async fn tenant_isolation_list() {
533 let store = make_store().await;
534 TenantContext::scope("tenant-a", async {
535 store
536 .save(&make_task("t1", "ctx1", TaskState::Submitted))
537 .await
538 .unwrap();
539 store
540 .save(&make_task("t2", "ctx1", TaskState::Working))
541 .await
542 .unwrap();
543 })
544 .await;
545
546 TenantContext::scope("tenant-b", async {
547 store
548 .save(&make_task("t3", "ctx1", TaskState::Submitted))
549 .await
550 .unwrap();
551 })
552 .await;
553
554 TenantContext::scope("tenant-a", async {
555 let response = store.list(&ListTasksParams::default()).await.unwrap();
556 assert_eq!(
557 response.tasks.len(),
558 2,
559 "tenant-a should see only its 2 tasks"
560 );
561 })
562 .await;
563
564 TenantContext::scope("tenant-b", async {
565 let response = store.list(&ListTasksParams::default()).await.unwrap();
566 assert_eq!(
567 response.tasks.len(),
568 1,
569 "tenant-b should see only its 1 task"
570 );
571 })
572 .await;
573 }
574
575 #[tokio::test]
576 async fn tenant_isolation_count() {
577 let store = make_store().await;
578 TenantContext::scope("tenant-a", async {
579 store
580 .save(&make_task("t1", "ctx1", TaskState::Submitted))
581 .await
582 .unwrap();
583 store
584 .save(&make_task("t2", "ctx1", TaskState::Working))
585 .await
586 .unwrap();
587 })
588 .await;
589
590 TenantContext::scope("tenant-b", async {
591 let count = store.count().await.unwrap();
592 assert_eq!(count, 0, "tenant-b should have zero tasks");
593 })
594 .await;
595
596 TenantContext::scope("tenant-a", async {
597 let count = store.count().await.unwrap();
598 assert_eq!(count, 2, "tenant-a should have 2 tasks");
599 })
600 .await;
601 }
602
603 #[tokio::test]
604 async fn tenant_isolation_delete() {
605 let store = make_store().await;
606 TenantContext::scope("tenant-a", async {
607 store
608 .save(&make_task("t1", "ctx1", TaskState::Submitted))
609 .await
610 .unwrap();
611 })
612 .await;
613
614 TenantContext::scope("tenant-b", async {
616 store.delete(&TaskId::new("t1")).await.unwrap();
617 })
618 .await;
619
620 TenantContext::scope("tenant-a", async {
621 let task = store.get(&TaskId::new("t1")).await.unwrap();
622 assert!(
623 task.is_some(),
624 "tenant-a's task should still exist after tenant-b's delete"
625 );
626 })
627 .await;
628 }
629
630 #[tokio::test]
631 async fn same_task_id_different_tenants() {
632 let store = make_store().await;
633 TenantContext::scope("tenant-a", async {
634 store
635 .save(&make_task("t1", "ctx-a", TaskState::Submitted))
636 .await
637 .unwrap();
638 })
639 .await;
640
641 TenantContext::scope("tenant-b", async {
642 store
643 .save(&make_task("t1", "ctx-b", TaskState::Working))
644 .await
645 .unwrap();
646 })
647 .await;
648
649 TenantContext::scope("tenant-a", async {
650 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
651 assert_eq!(
652 task.context_id,
653 ContextId::new("ctx-a"),
654 "tenant-a should get its own version of t1"
655 );
656 assert_eq!(task.status.state, TaskState::Submitted);
657 })
658 .await;
659
660 TenantContext::scope("tenant-b", async {
661 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
662 assert_eq!(
663 task.context_id,
664 ContextId::new("ctx-b"),
665 "tenant-b should get its own version of t1"
666 );
667 assert_eq!(task.status.state, TaskState::Working);
668 })
669 .await;
670 }
671
672 #[tokio::test]
673 async fn insert_if_absent_respects_tenant_scope() {
674 let store = make_store().await;
675 TenantContext::scope("tenant-a", async {
676 let inserted = store
677 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Submitted))
678 .await
679 .unwrap();
680 assert!(inserted, "first insert should succeed");
681
682 let inserted = store
683 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
684 .await
685 .unwrap();
686 assert!(!inserted, "duplicate insert in same tenant should fail");
687 })
688 .await;
689
690 TenantContext::scope("tenant-b", async {
692 let inserted = store
693 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
694 .await
695 .unwrap();
696 assert!(
697 inserted,
698 "insert of same task id in different tenant should succeed"
699 );
700 })
701 .await;
702 }
703
704 #[tokio::test]
705 async fn list_pagination_within_tenant() {
706 let store = make_store().await;
707 TenantContext::scope("tenant-a", async {
708 for i in 0..5 {
709 store
710 .save(&make_task(
711 &format!("task-{i:03}"),
712 "ctx1",
713 TaskState::Submitted,
714 ))
715 .await
716 .unwrap();
717 }
718
719 let params = ListTasksParams {
720 page_size: Some(2),
721 ..Default::default()
722 };
723 let response = store.list(¶ms).await.unwrap();
724 assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
725 assert!(
726 !response.next_page_token.is_empty(),
727 "should have a next page token"
728 );
729
730 let params2 = ListTasksParams {
731 page_size: Some(2),
732 page_token: Some(response.next_page_token),
733 ..Default::default()
734 };
735 let response2 = store.list(¶ms2).await.unwrap();
736 assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
737 })
738 .await;
739 }
740
741 #[test]
743 fn to_a2a_error_formats_message() {
744 let sqlite_err = sqlx::Error::RowNotFound;
745 let a2a_err = to_a2a_error(&sqlite_err);
746 let msg = format!("{a2a_err}");
747 assert!(
748 msg.contains("sqlite error"),
749 "error message should contain 'sqlite error': {msg}"
750 );
751 }
752
753 #[tokio::test]
754 async fn default_tenant_context_uses_empty_string() {
755 let store = make_store().await;
756 store
758 .save(&make_task("t1", "ctx1", TaskState::Submitted))
759 .await
760 .unwrap();
761 let task = store.get(&TaskId::new("t1")).await.unwrap();
762 assert!(task.is_some(), "default (empty) tenant should work");
763 }
764}