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::event_log_sql::{
41 decode_text_row, encode_text, limit_to_i64, report_no_op_append, seq_from_i64, seq_to_i64,
42};
43use super::task_store::{RecordedEvent, TaskStore};
44use super::tenant::TenantContext;
45use super::tenant_event_log as evlog;
46use super::tenant_idempotency as idem;
47use crate::metrics::MetricsHandle;
48
49#[derive(Debug, Clone)]
71pub struct TenantAwareSqliteTaskStore {
72 pool: SqlitePool,
73 max_page_size: u32,
76 metrics: MetricsHandle,
79}
80
81impl TenantAwareSqliteTaskStore {
82 #[must_use]
90 pub const fn with_max_page_size(mut self, max: u32) -> Self {
91 self.max_page_size = max;
92 self
93 }
94
95 #[must_use]
102 pub fn with_metrics(mut self, metrics: MetricsHandle) -> Self {
103 self.metrics = metrics;
104 self
105 }
106
107 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
113 let pool = sqlite_pool(url).await?;
114 Self::from_pool(pool).await
115 }
116
117 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
123 sqlx::query(
124 "CREATE TABLE IF NOT EXISTS tenant_tasks (
125 tenant_id TEXT NOT NULL DEFAULT '',
126 id TEXT NOT NULL,
127 context_id TEXT NOT NULL,
128 state TEXT NOT NULL,
129 data TEXT NOT NULL,
130 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
131 created_at TEXT NOT NULL DEFAULT (datetime('now')),
132 PRIMARY KEY (tenant_id, id)
133 )",
134 )
135 .execute(&pool)
136 .await?;
137
138 sqlx::query(idem::SQLITE_CREATE_TABLE)
148 .execute(&pool)
149 .await?;
150
151 sqlx::query(evlog::SQLITE_CREATE_TABLE)
157 .execute(&pool)
158 .await?;
159
160 sqlx::query(
161 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
162 )
163 .execute(&pool)
164 .await?;
165
166 sqlx::query(
167 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
168 )
169 .execute(&pool)
170 .await?;
171
172 sqlx::query(
173 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx_state ON tenant_tasks(tenant_id, context_id, state)",
174 )
175 .execute(&pool)
176 .await?;
177
178 sqlx::query(
181 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
182 )
183 .execute(&pool)
184 .await?;
185
186 Ok(Self {
187 pool,
188 max_page_size: crate::store::DEFAULT_MAX_PAGE_SIZE,
189 metrics: MetricsHandle::default(),
190 })
191 }
192
193 pub async fn purge_expired(
217 &self,
218 policy: &super::retention::RetentionPolicy,
219 ) -> A2aResult<super::retention::PurgeReport> {
220 super::retention::sqlite::purge(
221 &self.pool,
222 "tenant_tasks",
223 &[evlog::SQLITE_DELETE_ORPHANS],
224 super::tenant_idempotency::SQLITE_EXPIRE,
225 policy,
226 )
227 .await
228 .map_err(|e| to_a2a_error(&e))
229 }
230}
231
232use crate::sqlite_pool::sqlite_pool;
233
234fn to_a2a_error(e: &sqlx::Error) -> A2aError {
235 A2aError::internal(format!("sqlite error: {e}"))
236}
237
238#[allow(clippy::manual_async_fn)]
239impl TaskStore for TenantAwareSqliteTaskStore {
240 fn supports_idempotency(&self) -> bool {
241 true
242 }
243
244 fn claim_idempotency_key<'a>(
245 &'a self,
246 key: &'a str,
247 message_id: &'a a2a_protocol_types::message::MessageId,
248 task_id: &'a TaskId,
249 ) -> Pin<
250 Box<dyn Future<Output = A2aResult<crate::store::task_store::IdempotencyClaim>> + Send + 'a>,
251 > {
252 use crate::store::task_store::IdempotencyClaim;
253 use sqlx::Row as _;
254
255 Box::pin(async move {
256 let tenant = TenantContext::current();
257 let mut tx = self.pool.begin().await.map_err(|e| to_a2a_error(&e))?;
261
262 let inserted = sqlx::query(idem::SQLITE_CLAIM)
263 .bind(&tenant)
264 .bind(key)
265 .bind(message_id.0.as_str())
266 .bind(task_id.0.as_str())
267 .execute(&mut *tx)
268 .await
269 .map_err(|e| to_a2a_error(&e))?
270 .rows_affected();
271
272 if inserted == 1 {
273 tx.commit().await.map_err(|e| to_a2a_error(&e))?;
274 return Ok(IdempotencyClaim::Claimed);
275 }
276
277 let holder = sqlx::query(idem::SQLITE_HOLDER)
278 .bind(&tenant)
279 .bind(key)
280 .fetch_optional(&mut *tx)
281 .await
282 .map_err(|e| to_a2a_error(&e))?;
283 tx.commit().await.map_err(|e| to_a2a_error(&e))?;
284
285 let Some(row) = holder else {
286 return Err(A2aError::internal(
287 "idempotency key was neither claimed nor held; the row vanished \
288 inside the claiming transaction",
289 ));
290 };
291
292 let held_by: String = row.try_get("message_id").map_err(|e| to_a2a_error(&e))?;
293 let held_task: String = row.try_get("task_id").map_err(|e| to_a2a_error(&e))?;
294
295 Ok(idem::outcome(held_by, held_task, message_id))
296 })
297 }
298
299 fn release_idempotency_key<'a>(
300 &'a self,
301 key: &'a str,
302 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
303 Box::pin(async move {
304 let tenant = TenantContext::current();
305 sqlx::query(idem::SQLITE_RELEASE)
306 .bind(&tenant)
307 .bind(key)
308 .execute(&self.pool)
309 .await
310 .map_err(|e| to_a2a_error(&e))?;
311 Ok(())
312 })
313 }
314
315 fn save<'a>(
316 &'a self,
317 task: &'a Task,
318 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
319 Box::pin(async move {
320 let tenant = TenantContext::current();
321 let id = task.id.0.as_str();
322 let context_id = task.context_id.0.as_str();
323 let state = task.status.state.to_string();
324 let data = serde_json::to_string(task)
325 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
326 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
330
331 sqlx::query(
332 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
333 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))
334 ON CONFLICT(tenant_id, id) DO UPDATE SET
335 context_id = excluded.context_id,
336 state = excluded.state,
337 data = excluded.data,
338 updated_at = excluded.updated_at",
339 )
340 .bind(&tenant)
341 .bind(id)
342 .bind(context_id)
343 .bind(&state)
344 .bind(&data)
345 .bind(&status_ts)
346 .execute(&self.pool)
347 .await
348 .map_err(|e| to_a2a_error(&e))?;
349
350 Ok(())
351 })
352 }
353
354 fn get<'a>(
355 &'a self,
356 id: &'a TaskId,
357 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
358 Box::pin(async move {
359 let tenant = TenantContext::current();
360 let row: Option<(String,)> =
361 sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
362 .bind(&tenant)
363 .bind(id.0.as_str())
364 .fetch_optional(&self.pool)
365 .await
366 .map_err(|e| to_a2a_error(&e))?;
367
368 match row {
369 Some((data,)) => {
370 let task: Task = serde_json::from_str(&data)
371 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
372 Ok(Some(task))
373 }
374 None => Ok(None),
375 }
376 })
377 }
378
379 #[allow(clippy::too_many_lines)]
380 fn list<'a>(
381 &'a self,
382 params: &'a ListTasksParams,
383 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
384 Box::pin(async move {
385 let tenant = TenantContext::current();
386 let mut conditions = vec!["tenant_id = ?1".to_string()];
387 let mut bind_values: Vec<String> = vec![tenant];
388
389 if let Some(ref ctx) = params.context_id {
390 conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
391 bind_values.push(ctx.clone());
392 }
393 if let Some(ref status) = params.status {
394 conditions.push(format!("state = ?{}", bind_values.len() + 1));
395 bind_values.push(status.to_string());
396 }
397 if let Some(ref after) = params.status_timestamp_after {
402 let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
403 return Ok(TaskListResponse::new(Vec::new()));
404 };
405 conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
406 bind_values.push(after_dt);
407 }
408 if let Some(ref token) = params.page_token {
412 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
413 return Ok(TaskListResponse::new(Vec::new()));
414 };
415 let p = bind_values.len();
416 conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
417 bind_values.push(cursor_ua.to_string());
418 bind_values.push(cursor_id.to_string());
419 }
420
421 let where_clause = format!("WHERE {}", conditions.join(" AND "));
422
423 let page_size = match params.page_size {
424 Some(0) | None => 50_u32,
425 Some(n) => n.min(self.max_page_size),
426 };
427
428 let limit = super::pagination::fetch_limit(page_size);
429 let sql = format!(
430 "SELECT updated_at, data FROM tenant_tasks {where_clause} \
431 ORDER BY updated_at DESC, id DESC LIMIT {limit}"
432 );
433
434 let mut query = sqlx::query_as::<_, (String, String)>(&sql);
435 for val in &bind_values {
436 query = query.bind(val);
437 }
438
439 let rows: Vec<(String, String)> = query
440 .fetch_all(&self.pool)
441 .await
442 .map_err(|e| to_a2a_error(&e))?;
443
444 let mut rows: Vec<(String, Task)> = rows
445 .into_iter()
446 .map(|(updated_at, data)| {
447 serde_json::from_str::<Task>(&data)
448 .map(|task| (updated_at, task))
449 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
450 })
451 .collect::<A2aResult<Vec<_>>>()?;
452
453 let next_page_token =
454 if super::pagination::has_next_page(rows.len(), page_size as usize) {
455 rows.truncate(page_size as usize);
456 rows.last()
457 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
458 .unwrap_or_default()
459 } else {
460 String::new()
461 };
462
463 #[allow(clippy::cast_possible_truncation)]
464 let page_len = rows.len() as u32;
465 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
466 let mut response = TaskListResponse::new(tasks);
467 response.next_page_token = next_page_token;
468 response.page_size = page_len;
469 Ok(response)
470 })
471 }
472
473 fn insert_if_absent<'a>(
474 &'a self,
475 task: &'a Task,
476 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
477 Box::pin(async move {
478 let tenant = TenantContext::current();
479 let id = task.id.0.as_str();
480 let context_id = task.context_id.0.as_str();
481 let state = task.status.state.to_string();
482 let data = serde_json::to_string(task)
483 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
484 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
485
486 let result = sqlx::query(
487 "INSERT OR IGNORE INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
488 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))",
489 )
490 .bind(&tenant)
491 .bind(id)
492 .bind(context_id)
493 .bind(&state)
494 .bind(&data)
495 .bind(&status_ts)
496 .execute(&self.pool)
497 .await
498 .map_err(|e| to_a2a_error(&e))?;
499
500 Ok(result.rows_affected() > 0)
501 })
502 }
503
504 fn delete<'a>(
505 &'a self,
506 id: &'a TaskId,
507 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
508 Box::pin(async move {
509 let tenant = TenantContext::current();
510 sqlx::query(evlog::SQLITE_DELETE_FOR_TASK)
515 .bind(&tenant)
516 .bind(id.0.as_str())
517 .execute(&self.pool)
518 .await
519 .map_err(|e| to_a2a_error(&e))?;
520
521 sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
522 .bind(&tenant)
523 .bind(id.0.as_str())
524 .execute(&self.pool)
525 .await
526 .map_err(|e| to_a2a_error(&e))?;
527 Ok(())
528 })
529 }
530
531 fn supports_event_log(&self) -> bool {
532 true
533 }
534
535 fn append_event<'a>(
536 &'a self,
537 task_id: &'a TaskId,
538 seq: u64,
539 event: &'a a2a_protocol_types::events::StreamResponse,
540 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
541 Box::pin(async move {
542 let tenant = TenantContext::current();
543 let position = seq_to_i64(seq)?;
544 let payload = encode_text(event)?;
545 let changed_nothing = sqlx::query(evlog::SQLITE_APPEND)
548 .bind(&tenant)
549 .bind(task_id.0.as_str())
550 .bind(position)
551 .bind(&payload)
552 .execute(&self.pool)
553 .await
554 .map_err(|e| to_a2a_error(&e))?
555 .rows_affected()
556 == 0;
557 if changed_nothing {
558 let stored: Option<(String,)> = sqlx::query_as(evlog::SQLITE_SELECT_PAYLOAD)
559 .bind(&tenant)
560 .bind(task_id.0.as_str())
561 .bind(position)
562 .fetch_optional(&self.pool)
563 .await
564 .unwrap_or(None);
565 let same = stored.is_some_and(|(held,)| held == payload);
566 report_no_op_append(&*self.metrics, task_id, seq, same);
567 }
568 Ok(())
569 })
570 }
571
572 fn last_event_seq<'a>(
573 &'a self,
574 task_id: &'a TaskId,
575 ) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
576 Box::pin(async move {
577 let tenant = TenantContext::current();
578 let (max,): (i64,) = sqlx::query_as(evlog::SQLITE_LAST_SEQ)
579 .bind(&tenant)
580 .bind(task_id.0.as_str())
581 .fetch_one(&self.pool)
582 .await
583 .map_err(|e| to_a2a_error(&e))?;
584 seq_from_i64(max)
585 })
586 }
587
588 fn earliest_event_seq<'a>(
589 &'a self,
590 task_id: &'a TaskId,
591 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<u64>>> + Send + 'a>> {
592 Box::pin(async move {
593 let tenant = TenantContext::current();
594 let (min,): (Option<i64>,) = sqlx::query_as(evlog::SQLITE_EARLIEST_SEQ)
595 .bind(&tenant)
596 .bind(task_id.0.as_str())
597 .fetch_one(&self.pool)
598 .await
599 .map_err(|e| to_a2a_error(&e))?;
600 min.map(seq_from_i64).transpose()
601 })
602 }
603
604 fn read_events<'a>(
605 &'a self,
606 task_id: &'a TaskId,
607 after_seq: u64,
608 limit: usize,
609 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<RecordedEvent>>> + Send + 'a>> {
610 Box::pin(async move {
611 let tenant = TenantContext::current();
612 let rows: Vec<(i64, String)> = sqlx::query_as(evlog::SQLITE_SELECT_AFTER)
613 .bind(&tenant)
614 .bind(task_id.0.as_str())
615 .bind(seq_to_i64(after_seq)?)
616 .bind(limit_to_i64(limit))
617 .fetch_all(&self.pool)
618 .await
619 .map_err(|e| to_a2a_error(&e))?;
620 rows.into_iter().map(decode_text_row).collect()
621 })
622 }
623
624 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
625 Box::pin(async move {
626 let tenant = TenantContext::current();
627 let row: (i64,) =
628 sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = ?1")
629 .bind(&tenant)
630 .fetch_one(&self.pool)
631 .await
632 .map_err(|e| to_a2a_error(&e))?;
633 #[allow(clippy::cast_sign_loss)]
634 Ok(row.0 as u64)
635 })
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
643
644 async fn make_store() -> TenantAwareSqliteTaskStore {
645 TenantAwareSqliteTaskStore::new("sqlite::memory:")
646 .await
647 .expect("failed to create in-memory tenant store")
648 }
649
650 fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
651 Task {
652 id: TaskId::new(id),
653 context_id: ContextId::new(ctx),
654 status: TaskStatus::new(state),
655 history: None,
656 artifacts: None,
657 metadata: None,
658 }
659 }
660
661 #[tokio::test]
665 async fn sqlite_tenant_store_advertises_idempotency_support() {
666 let store = make_store().await;
667 assert!(
668 store.supports_idempotency(),
669 "the tenant-aware SQLite store implements claim_idempotency_key; \
670 it must advertise support"
671 );
672 }
673
674 #[tokio::test]
683 async fn purging_one_tenant_leaves_the_same_id_under_another() {
684 use crate::store::retention::RetentionPolicy;
685 use std::time::Duration;
686
687 let store = make_store().await;
688 for tenant in ["acme", "globex"] {
689 TenantContext::scope(tenant, async {
690 store
691 .save(&make_task("shared-id", "ctx", TaskState::Completed))
692 .await
693 .unwrap();
694 })
695 .await;
696 }
697
698 sqlx::query(
700 "UPDATE tenant_tasks \
701 SET updated_at = strftime('%Y-%m-%d %H:%M:%f','now','-7200 seconds') \
702 WHERE tenant_id = 'acme'",
703 )
704 .execute(&store.pool)
705 .await
706 .expect("backdate");
707
708 let report = store
709 .purge_expired(&RetentionPolicy::new(Duration::from_secs(3_600)))
710 .await
711 .expect("purge");
712 assert_eq!(report.tasks_deleted, 1, "only acme's copy was old enough");
713
714 TenantContext::scope("globex", async {
715 assert!(
716 store
717 .get(&TaskId::new("shared-id"))
718 .await
719 .unwrap()
720 .is_some(),
721 "globex must still have its own task of the same id"
722 );
723 })
724 .await;
725 TenantContext::scope("acme", async {
726 assert!(
727 store
728 .get(&TaskId::new("shared-id"))
729 .await
730 .unwrap()
731 .is_none(),
732 "acme's expired copy should be gone"
733 );
734 })
735 .await;
736 }
737
738 #[tokio::test]
739 async fn save_and_get_within_tenant() {
740 let store = make_store().await;
741 TenantContext::scope("acme", async {
742 store
743 .save(&make_task("t1", "ctx1", TaskState::Submitted))
744 .await
745 .unwrap();
746 let task = store.get(&TaskId::new("t1")).await.unwrap();
747 assert!(
748 task.is_some(),
749 "task should be retrievable within its tenant"
750 );
751 assert_eq!(task.unwrap().id, TaskId::new("t1"));
752 })
753 .await;
754 }
755
756 #[tokio::test]
757 async fn tenant_isolation_get() {
758 let store = make_store().await;
759 TenantContext::scope("tenant-a", async {
760 store
761 .save(&make_task("t1", "ctx1", TaskState::Submitted))
762 .await
763 .unwrap();
764 })
765 .await;
766
767 TenantContext::scope("tenant-b", async {
768 let result = store.get(&TaskId::new("t1")).await.unwrap();
769 assert!(result.is_none(), "tenant-b should not see tenant-a's task");
770 })
771 .await;
772 }
773
774 #[tokio::test]
775 async fn tenant_isolation_list() {
776 let store = make_store().await;
777 TenantContext::scope("tenant-a", async {
778 store
779 .save(&make_task("t1", "ctx1", TaskState::Submitted))
780 .await
781 .unwrap();
782 store
783 .save(&make_task("t2", "ctx1", TaskState::Working))
784 .await
785 .unwrap();
786 })
787 .await;
788
789 TenantContext::scope("tenant-b", async {
790 store
791 .save(&make_task("t3", "ctx1", TaskState::Submitted))
792 .await
793 .unwrap();
794 })
795 .await;
796
797 TenantContext::scope("tenant-a", async {
798 let response = store.list(&ListTasksParams::default()).await.unwrap();
799 assert_eq!(
800 response.tasks.len(),
801 2,
802 "tenant-a should see only its 2 tasks"
803 );
804 })
805 .await;
806
807 TenantContext::scope("tenant-b", async {
808 let response = store.list(&ListTasksParams::default()).await.unwrap();
809 assert_eq!(
810 response.tasks.len(),
811 1,
812 "tenant-b should see only its 1 task"
813 );
814 })
815 .await;
816 }
817
818 #[tokio::test]
819 async fn tenant_isolation_count() {
820 let store = make_store().await;
821 TenantContext::scope("tenant-a", async {
822 store
823 .save(&make_task("t1", "ctx1", TaskState::Submitted))
824 .await
825 .unwrap();
826 store
827 .save(&make_task("t2", "ctx1", TaskState::Working))
828 .await
829 .unwrap();
830 })
831 .await;
832
833 TenantContext::scope("tenant-b", async {
834 let count = store.count().await.unwrap();
835 assert_eq!(count, 0, "tenant-b should have zero tasks");
836 })
837 .await;
838
839 TenantContext::scope("tenant-a", async {
840 let count = store.count().await.unwrap();
841 assert_eq!(count, 2, "tenant-a should have 2 tasks");
842 })
843 .await;
844 }
845
846 #[tokio::test]
847 async fn tenant_isolation_delete() {
848 let store = make_store().await;
849 TenantContext::scope("tenant-a", async {
850 store
851 .save(&make_task("t1", "ctx1", TaskState::Submitted))
852 .await
853 .unwrap();
854 })
855 .await;
856
857 TenantContext::scope("tenant-b", async {
859 store.delete(&TaskId::new("t1")).await.unwrap();
860 })
861 .await;
862
863 TenantContext::scope("tenant-a", async {
864 let task = store.get(&TaskId::new("t1")).await.unwrap();
865 assert!(
866 task.is_some(),
867 "tenant-a's task should still exist after tenant-b's delete"
868 );
869 })
870 .await;
871 }
872
873 #[tokio::test]
874 async fn same_task_id_different_tenants() {
875 let store = make_store().await;
876 TenantContext::scope("tenant-a", async {
877 store
878 .save(&make_task("t1", "ctx-a", TaskState::Submitted))
879 .await
880 .unwrap();
881 })
882 .await;
883
884 TenantContext::scope("tenant-b", async {
885 store
886 .save(&make_task("t1", "ctx-b", TaskState::Working))
887 .await
888 .unwrap();
889 })
890 .await;
891
892 TenantContext::scope("tenant-a", async {
893 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
894 assert_eq!(
895 task.context_id,
896 ContextId::new("ctx-a"),
897 "tenant-a should get its own version of t1"
898 );
899 assert_eq!(task.status.state, TaskState::Submitted);
900 })
901 .await;
902
903 TenantContext::scope("tenant-b", async {
904 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
905 assert_eq!(
906 task.context_id,
907 ContextId::new("ctx-b"),
908 "tenant-b should get its own version of t1"
909 );
910 assert_eq!(task.status.state, TaskState::Working);
911 })
912 .await;
913 }
914
915 #[tokio::test]
916 async fn insert_if_absent_respects_tenant_scope() {
917 let store = make_store().await;
918 TenantContext::scope("tenant-a", async {
919 let inserted = store
920 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Submitted))
921 .await
922 .unwrap();
923 assert!(inserted, "first insert should succeed");
924
925 let inserted = store
926 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
927 .await
928 .unwrap();
929 assert!(!inserted, "duplicate insert in same tenant should fail");
930 })
931 .await;
932
933 TenantContext::scope("tenant-b", async {
935 let inserted = store
936 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
937 .await
938 .unwrap();
939 assert!(
940 inserted,
941 "insert of same task id in different tenant should succeed"
942 );
943 })
944 .await;
945 }
946
947 #[tokio::test]
948 async fn list_pagination_within_tenant() {
949 let store = make_store().await;
950 TenantContext::scope("tenant-a", async {
951 for i in 0..5 {
952 store
953 .save(&make_task(
954 &format!("task-{i:03}"),
955 "ctx1",
956 TaskState::Submitted,
957 ))
958 .await
959 .unwrap();
960 }
961
962 let params = ListTasksParams {
963 page_size: Some(2),
964 ..Default::default()
965 };
966 let response = store.list(¶ms).await.unwrap();
967 assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
968 assert!(
969 !response.next_page_token.is_empty(),
970 "should have a next page token"
971 );
972
973 let params2 = ListTasksParams {
974 page_size: Some(2),
975 page_token: Some(response.next_page_token),
976 ..Default::default()
977 };
978 let response2 = store.list(¶ms2).await.unwrap();
979 assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
980 })
981 .await;
982 }
983
984 #[test]
986 fn to_a2a_error_formats_message() {
987 let sqlite_err = sqlx::Error::RowNotFound;
988 let a2a_err = to_a2a_error(&sqlite_err);
989 let msg = format!("{a2a_err}");
990 assert!(
991 msg.contains("sqlite error"),
992 "error message should contain 'sqlite error': {msg}"
993 );
994 }
995
996 #[tokio::test]
997 async fn default_tenant_context_uses_empty_string() {
998 let store = make_store().await;
999 store
1001 .save(&make_task("t1", "ctx1", TaskState::Submitted))
1002 .await
1003 .unwrap();
1004 let task = store.get(&TaskId::new("t1")).await.unwrap();
1005 assert!(task.is_some(), "default (empty) tenant should work");
1006 }
1007}
1008
1009#[cfg(test)]
1010mod idempotency_tests {
1011 use super::*;
1012 use crate::store::task_store::IdempotencyClaim;
1013 use a2a_protocol_types::message::MessageId;
1014
1015 const KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
1016
1017 async fn store() -> TenantAwareSqliteTaskStore {
1018 TenantAwareSqliteTaskStore::new("sqlite::memory:")
1019 .await
1020 .expect("in-memory tenant store")
1021 }
1022
1023 #[tokio::test]
1024 async fn one_tenants_key_never_names_another_tenants_task() {
1025 let store = store().await;
1029
1030 let a = TenantContext::scope("tenant-a", async {
1031 store
1032 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1033 .await
1034 .unwrap()
1035 })
1036 .await;
1037 assert_eq!(a, IdempotencyClaim::Claimed);
1038
1039 let b = TenantContext::scope("tenant-b", async {
1040 store
1041 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-b"))
1042 .await
1043 .unwrap()
1044 })
1045 .await;
1046 assert_eq!(
1047 b,
1048 IdempotencyClaim::Claimed,
1049 "tenant-b must claim its own key, not replay tenant-a's task"
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn a_retry_within_one_tenant_replays() {
1055 let store = store().await;
1056 TenantContext::scope("tenant-a", async {
1057 assert_eq!(
1058 store
1059 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1060 .await
1061 .unwrap(),
1062 IdempotencyClaim::Claimed
1063 );
1064 assert_eq!(
1065 store
1066 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("other"))
1067 .await
1068 .unwrap(),
1069 IdempotencyClaim::Replay(TaskId::new("task-a"))
1070 );
1071 })
1072 .await;
1073 }
1074
1075 #[tokio::test]
1076 async fn a_reused_key_conflicts_within_its_own_tenant() {
1077 let store = store().await;
1078 TenantContext::scope("tenant-a", async {
1079 store
1080 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1081 .await
1082 .unwrap();
1083 assert_eq!(
1084 store
1085 .claim_idempotency_key(KEY, &MessageId::new("m2"), &TaskId::new("task-b"))
1086 .await
1087 .unwrap(),
1088 IdempotencyClaim::Conflict {
1089 held_by: MessageId::new("m1")
1090 }
1091 );
1092 })
1093 .await;
1094 }
1095
1096 #[tokio::test]
1097 async fn releasing_in_one_tenant_leaves_another_tenants_key_held() {
1098 let store = store().await;
1099 TenantContext::scope("tenant-a", async {
1100 store
1101 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1102 .await
1103 .unwrap();
1104 })
1105 .await;
1106 TenantContext::scope("tenant-b", async {
1107 store
1108 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-b"))
1109 .await
1110 .unwrap();
1111 store.release_idempotency_key(KEY).await.unwrap();
1112 })
1113 .await;
1114
1115 let still_held = TenantContext::scope("tenant-a", async {
1116 store
1117 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("ignored"))
1118 .await
1119 .unwrap()
1120 })
1121 .await;
1122 assert_eq!(
1123 still_held,
1124 IdempotencyClaim::Replay(TaskId::new("task-a")),
1125 "a release must not reach across the tenant boundary"
1126 );
1127 }
1128}
1129
1130#[cfg(test)]
1131mod event_log_tests {
1132 use super::*;
1133 use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
1134 use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
1135
1136 async fn store() -> TenantAwareSqliteTaskStore {
1137 TenantAwareSqliteTaskStore::new("sqlite::memory:")
1138 .await
1139 .expect("in-memory tenant store")
1140 }
1141
1142 fn task(id: &str) -> Task {
1143 Task {
1144 id: TaskId::new(id),
1145 context_id: ContextId::new("c-1"),
1146 status: TaskStatus::new(TaskState::Working),
1147 history: None,
1148 artifacts: None,
1149 metadata: None,
1150 }
1151 }
1152
1153 fn event(id: &str, state: TaskState) -> StreamResponse {
1154 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1155 task_id: TaskId::new(id),
1156 context_id: ContextId::new("c-1"),
1157 status: TaskStatus::new(state),
1158 metadata: None,
1159 })
1160 }
1161
1162 fn states(events: &[RecordedEvent]) -> Vec<TaskState> {
1163 events
1164 .iter()
1165 .map(|r| match &r.event {
1166 StreamResponse::StatusUpdate(u) => u.status.state,
1167 other => panic!("only status events are written here, got {other:?}"),
1168 })
1169 .collect()
1170 }
1171
1172 #[tokio::test]
1173 async fn the_tenant_store_reports_that_it_keeps_a_log() {
1174 assert!(store().await.supports_event_log());
1175 }
1176
1177 #[tokio::test]
1183 async fn one_tenants_task_id_never_reaches_another_tenants_log() {
1184 let store = store().await;
1185
1186 TenantContext::scope("tenant-a", async {
1187 store.save(&task("shared-id")).await.expect("save a");
1188 store
1189 .append_event(
1190 &TaskId::new("shared-id"),
1191 1,
1192 &event("shared-id", TaskState::Working),
1193 )
1194 .await
1195 .expect("append a");
1196 })
1197 .await;
1198
1199 TenantContext::scope("tenant-b", async {
1200 store.save(&task("shared-id")).await.expect("save b");
1201 assert_eq!(
1202 store
1203 .last_event_seq(&TaskId::new("shared-id"))
1204 .await
1205 .expect("last b"),
1206 0,
1207 "tenant-b's log for this id is its own, and it is empty"
1208 );
1209 assert!(
1210 store
1211 .read_events(&TaskId::new("shared-id"), 0, 10)
1212 .await
1213 .expect("read b")
1214 .is_empty(),
1215 "tenant-b must not be handed tenant-a's events"
1216 );
1217
1218 store
1219 .append_event(
1220 &TaskId::new("shared-id"),
1221 1,
1222 &event("shared-id", TaskState::Completed),
1223 )
1224 .await
1225 .expect("append b");
1226 })
1227 .await;
1228
1229 TenantContext::scope("tenant-a", async {
1232 assert_eq!(
1233 states(
1234 &store
1235 .read_events(&TaskId::new("shared-id"), 0, 10)
1236 .await
1237 .expect("read a")
1238 ),
1239 vec![TaskState::Working],
1240 );
1241 })
1242 .await;
1243 }
1244
1245 #[tokio::test]
1246 async fn events_round_trip_in_order_with_their_positions() {
1247 let store = store().await;
1248 TenantContext::scope("tenant-a", async {
1249 store.save(&task("t-1")).await.expect("save");
1250 for (seq, state) in [
1251 (1, TaskState::Submitted),
1252 (2, TaskState::Working),
1253 (3, TaskState::Completed),
1254 ] {
1255 store
1256 .append_event(&TaskId::new("t-1"), seq, &event("t-1", state))
1257 .await
1258 .expect("append");
1259 }
1260
1261 let all = store
1262 .read_events(&TaskId::new("t-1"), 0, 100)
1263 .await
1264 .expect("read");
1265 assert_eq!(all.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
1266 assert_eq!(
1267 states(&all),
1268 vec![
1269 TaskState::Submitted,
1270 TaskState::Working,
1271 TaskState::Completed
1272 ],
1273 );
1274 assert_eq!(
1275 store
1276 .last_event_seq(&TaskId::new("t-1"))
1277 .await
1278 .expect("last"),
1279 3
1280 );
1281 })
1282 .await;
1283 }
1284
1285 #[tokio::test]
1286 async fn appending_the_same_position_twice_leaves_one_row() {
1287 let store = store().await;
1288 TenantContext::scope("tenant-a", async {
1289 store.save(&task("t-1")).await.expect("save");
1290 store
1291 .append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Working))
1292 .await
1293 .expect("append");
1294 store
1295 .append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Completed))
1296 .await
1297 .expect("replay must not error");
1298
1299 let all = store
1300 .read_events(&TaskId::new("t-1"), 0, 10)
1301 .await
1302 .expect("read");
1303 assert_eq!(all.len(), 1, "one position, one row");
1304 assert_eq!(
1305 states(&all),
1306 vec![TaskState::Working],
1307 "the first write wins; a replay must not rewrite history"
1308 );
1309 })
1310 .await;
1311 }
1312
1313 #[tokio::test]
1314 async fn reading_after_an_offset_is_exclusive_and_honours_the_limit() {
1315 let store = store().await;
1316 TenantContext::scope("tenant-a", async {
1317 store.save(&task("t-1")).await.expect("save");
1318 for seq in 1..=5 {
1319 store
1320 .append_event(&TaskId::new("t-1"), seq, &event("t-1", TaskState::Working))
1321 .await
1322 .expect("append");
1323 }
1324
1325 let after_two = store
1326 .read_events(&TaskId::new("t-1"), 2, 100)
1327 .await
1328 .expect("read");
1329 assert_eq!(after_two.first().map(|r| r.seq), Some(3), "exclusive");
1330 assert_eq!(after_two.len(), 3);
1331 assert_eq!(
1332 store
1333 .read_events(&TaskId::new("t-1"), 0, 2)
1334 .await
1335 .expect("read")
1336 .len(),
1337 2
1338 );
1339 })
1340 .await;
1341 }
1342
1343 #[tokio::test]
1344 async fn deleting_a_task_removes_its_log_and_leaves_other_tenants_alone() {
1345 let store = store().await;
1346 for tenant in ["tenant-a", "tenant-b"] {
1347 TenantContext::scope(tenant, async {
1348 store.save(&task("t-1")).await.expect("save");
1349 store
1350 .append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Working))
1351 .await
1352 .expect("append");
1353 })
1354 .await;
1355 }
1356
1357 TenantContext::scope("tenant-a", async {
1358 store.delete(&TaskId::new("t-1")).await.expect("delete");
1359 assert_eq!(
1360 store
1361 .last_event_seq(&TaskId::new("t-1"))
1362 .await
1363 .expect("last"),
1364 0
1365 );
1366 })
1367 .await;
1368
1369 TenantContext::scope("tenant-b", async {
1370 assert_eq!(
1371 store
1372 .last_event_seq(&TaskId::new("t-1"))
1373 .await
1374 .expect("last"),
1375 1,
1376 "a delete in one tenant must not reach across the boundary"
1377 );
1378 })
1379 .await;
1380 }
1381
1382 #[tokio::test]
1387 async fn a_retention_sweep_reclaims_orphaned_events() {
1388 use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
1393 use std::str::FromStr as _;
1394
1395 let opts = SqliteConnectOptions::from_str("sqlite::memory:")
1396 .expect("options")
1397 .pragma("foreign_keys", "OFF")
1398 .create_if_missing(true);
1399 let pool = SqlitePoolOptions::new()
1400 .max_connections(1)
1401 .connect_with(opts)
1402 .await
1403 .expect("pool");
1404 let store = TenantAwareSqliteTaskStore::from_pool(pool.clone())
1405 .await
1406 .expect("store from a pool without the pragma");
1407
1408 TenantContext::scope("tenant-a", async {
1409 let mut done = task("t-1");
1410 done.status = TaskStatus::new(TaskState::Completed);
1411 store.save(&done).await.expect("save");
1412 store
1413 .append_event(&TaskId::new("t-1"), 1, &event("t-1", TaskState::Completed))
1414 .await
1415 .expect("append");
1416 })
1417 .await;
1418
1419 sqlx::query("UPDATE tenant_tasks SET updated_at = '2000-01-01 00:00:00.000'")
1421 .execute(&pool)
1422 .await
1423 .expect("backdate");
1424
1425 let report = store
1426 .purge_expired(&crate::store::RetentionPolicy::new(
1427 std::time::Duration::from_secs(60),
1428 ))
1429 .await
1430 .expect("purge");
1431 assert_eq!(report.tasks_deleted, 1);
1432 assert_eq!(
1433 report.orphan_rows_deleted, 1,
1434 "without the pragma the cascade does not fire, so the sweep must \
1435 reclaim the event row itself"
1436 );
1437
1438 TenantContext::scope("tenant-a", async {
1439 assert_eq!(
1440 store
1441 .last_event_seq(&TaskId::new("t-1"))
1442 .await
1443 .expect("last"),
1444 0
1445 );
1446 })
1447 .await;
1448 }
1449}