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, SqlitePoolOptions};
39
40use super::task_store::TaskStore;
41use super::tenant::TenantContext;
42
43#[derive(Debug, Clone)]
65pub struct TenantAwareSqliteTaskStore {
66 pool: SqlitePool,
67}
68
69impl TenantAwareSqliteTaskStore {
70 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
76 let pool = sqlite_pool(url).await?;
77 Self::from_pool(pool).await
78 }
79
80 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
86 sqlx::query(
87 "CREATE TABLE IF NOT EXISTS tenant_tasks (
88 tenant_id TEXT NOT NULL DEFAULT '',
89 id TEXT NOT NULL,
90 context_id TEXT NOT NULL,
91 state TEXT NOT NULL,
92 data TEXT NOT NULL,
93 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
94 created_at TEXT NOT NULL DEFAULT (datetime('now')),
95 PRIMARY KEY (tenant_id, id)
96 )",
97 )
98 .execute(&pool)
99 .await?;
100
101 sqlx::query(
102 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx ON tenant_tasks(tenant_id, context_id)",
103 )
104 .execute(&pool)
105 .await?;
106
107 sqlx::query(
108 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_state ON tenant_tasks(tenant_id, state)",
109 )
110 .execute(&pool)
111 .await?;
112
113 sqlx::query(
114 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_ctx_state ON tenant_tasks(tenant_id, context_id, state)",
115 )
116 .execute(&pool)
117 .await?;
118
119 sqlx::query(
122 "CREATE INDEX IF NOT EXISTS idx_tenant_tasks_updated_at ON tenant_tasks(tenant_id, updated_at DESC, id DESC)",
123 )
124 .execute(&pool)
125 .await?;
126
127 Ok(Self { pool })
128 }
129}
130
131async fn sqlite_pool(url: &str) -> Result<SqlitePool, sqlx::Error> {
133 use sqlx::sqlite::SqliteConnectOptions;
134 use std::str::FromStr;
135
136 let opts = SqliteConnectOptions::from_str(url)?
137 .pragma("journal_mode", "WAL")
138 .pragma("busy_timeout", "5000")
139 .pragma("synchronous", "NORMAL")
140 .pragma("foreign_keys", "ON")
141 .create_if_missing(true);
142
143 SqlitePoolOptions::new()
144 .max_connections(8)
145 .connect_with(opts)
146 .await
147}
148
149fn to_a2a_error(e: &sqlx::Error) -> A2aError {
150 A2aError::internal(format!("sqlite error: {e}"))
151}
152
153#[allow(clippy::manual_async_fn)]
154impl TaskStore for TenantAwareSqliteTaskStore {
155 fn save<'a>(
156 &'a self,
157 task: &'a Task,
158 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
159 Box::pin(async move {
160 let tenant = TenantContext::current();
161 let id = task.id.0.as_str();
162 let context_id = task.context_id.0.as_str();
163 let state = task.status.state.to_string();
164 let data = serde_json::to_string(task)
165 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
166 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
170
171 sqlx::query(
172 "INSERT INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
173 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))
174 ON CONFLICT(tenant_id, id) DO UPDATE SET
175 context_id = excluded.context_id,
176 state = excluded.state,
177 data = excluded.data,
178 updated_at = excluded.updated_at",
179 )
180 .bind(&tenant)
181 .bind(id)
182 .bind(context_id)
183 .bind(&state)
184 .bind(&data)
185 .bind(&status_ts)
186 .execute(&self.pool)
187 .await
188 .map_err(|e| to_a2a_error(&e))?;
189
190 Ok(())
191 })
192 }
193
194 fn get<'a>(
195 &'a self,
196 id: &'a TaskId,
197 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
198 Box::pin(async move {
199 let tenant = TenantContext::current();
200 let row: Option<(String,)> =
201 sqlx::query_as("SELECT data FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
202 .bind(&tenant)
203 .bind(id.0.as_str())
204 .fetch_optional(&self.pool)
205 .await
206 .map_err(|e| to_a2a_error(&e))?;
207
208 match row {
209 Some((data,)) => {
210 let task: Task = serde_json::from_str(&data)
211 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
212 Ok(Some(task))
213 }
214 None => Ok(None),
215 }
216 })
217 }
218
219 #[allow(clippy::too_many_lines)]
220 fn list<'a>(
221 &'a self,
222 params: &'a ListTasksParams,
223 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
224 Box::pin(async move {
225 let tenant = TenantContext::current();
226 let mut conditions = vec!["tenant_id = ?1".to_string()];
227 let mut bind_values: Vec<String> = vec![tenant];
228
229 if let Some(ref ctx) = params.context_id {
230 conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
231 bind_values.push(ctx.clone());
232 }
233 if let Some(ref status) = params.status {
234 conditions.push(format!("state = ?{}", bind_values.len() + 1));
235 bind_values.push(status.to_string());
236 }
237 if let Some(ref after) = params.status_timestamp_after {
242 let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
243 return Ok(TaskListResponse::new(Vec::new()));
244 };
245 conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
246 bind_values.push(after_dt);
247 }
248 if let Some(ref token) = params.page_token {
252 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
253 return Ok(TaskListResponse::new(Vec::new()));
254 };
255 let p = bind_values.len();
256 conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
257 bind_values.push(cursor_ua.to_string());
258 bind_values.push(cursor_id.to_string());
259 }
260
261 let where_clause = format!("WHERE {}", conditions.join(" AND "));
262
263 let page_size = match params.page_size {
264 Some(0) | None => 50_u32,
265 Some(n) => n.min(1000),
266 };
267
268 let limit = super::pagination::fetch_limit(page_size);
269 let sql = format!(
270 "SELECT updated_at, data FROM tenant_tasks {where_clause} \
271 ORDER BY updated_at DESC, id DESC LIMIT {limit}"
272 );
273
274 let mut query = sqlx::query_as::<_, (String, String)>(&sql);
275 for val in &bind_values {
276 query = query.bind(val);
277 }
278
279 let rows: Vec<(String, String)> = query
280 .fetch_all(&self.pool)
281 .await
282 .map_err(|e| to_a2a_error(&e))?;
283
284 let mut rows: Vec<(String, Task)> = rows
285 .into_iter()
286 .map(|(updated_at, data)| {
287 serde_json::from_str::<Task>(&data)
288 .map(|task| (updated_at, task))
289 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
290 })
291 .collect::<A2aResult<Vec<_>>>()?;
292
293 let next_page_token =
294 if super::pagination::has_next_page(rows.len(), page_size as usize) {
295 rows.truncate(page_size as usize);
296 rows.last()
297 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
298 .unwrap_or_default()
299 } else {
300 String::new()
301 };
302
303 #[allow(clippy::cast_possible_truncation)]
304 let page_len = rows.len() as u32;
305 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
306 let mut response = TaskListResponse::new(tasks);
307 response.next_page_token = next_page_token;
308 response.page_size = page_len;
309 Ok(response)
310 })
311 }
312
313 fn insert_if_absent<'a>(
314 &'a self,
315 task: &'a Task,
316 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
317 Box::pin(async move {
318 let tenant = TenantContext::current();
319 let id = task.id.0.as_str();
320 let context_id = task.context_id.0.as_str();
321 let state = task.status.state.to_string();
322 let data = serde_json::to_string(task)
323 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
324 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
325
326 let result = sqlx::query(
327 "INSERT OR IGNORE INTO tenant_tasks (tenant_id, id, context_id, state, data, updated_at)
328 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, strftime('%Y-%m-%d %H:%M:%f','now')))",
329 )
330 .bind(&tenant)
331 .bind(id)
332 .bind(context_id)
333 .bind(&state)
334 .bind(&data)
335 .bind(&status_ts)
336 .execute(&self.pool)
337 .await
338 .map_err(|e| to_a2a_error(&e))?;
339
340 Ok(result.rows_affected() > 0)
341 })
342 }
343
344 fn delete<'a>(
345 &'a self,
346 id: &'a TaskId,
347 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
348 Box::pin(async move {
349 let tenant = TenantContext::current();
350 sqlx::query("DELETE FROM tenant_tasks WHERE tenant_id = ?1 AND id = ?2")
351 .bind(&tenant)
352 .bind(id.0.as_str())
353 .execute(&self.pool)
354 .await
355 .map_err(|e| to_a2a_error(&e))?;
356 Ok(())
357 })
358 }
359
360 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
361 Box::pin(async move {
362 let tenant = TenantContext::current();
363 let row: (i64,) =
364 sqlx::query_as("SELECT COUNT(*) FROM tenant_tasks WHERE tenant_id = ?1")
365 .bind(&tenant)
366 .fetch_one(&self.pool)
367 .await
368 .map_err(|e| to_a2a_error(&e))?;
369 #[allow(clippy::cast_sign_loss)]
370 Ok(row.0 as u64)
371 })
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
379
380 async fn make_store() -> TenantAwareSqliteTaskStore {
381 TenantAwareSqliteTaskStore::new("sqlite::memory:")
382 .await
383 .expect("failed to create in-memory tenant store")
384 }
385
386 fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
387 Task {
388 id: TaskId::new(id),
389 context_id: ContextId::new(ctx),
390 status: TaskStatus::new(state),
391 history: None,
392 artifacts: None,
393 metadata: None,
394 }
395 }
396
397 #[tokio::test]
398 async fn save_and_get_within_tenant() {
399 let store = make_store().await;
400 TenantContext::scope("acme", async {
401 store
402 .save(&make_task("t1", "ctx1", TaskState::Submitted))
403 .await
404 .unwrap();
405 let task = store.get(&TaskId::new("t1")).await.unwrap();
406 assert!(
407 task.is_some(),
408 "task should be retrievable within its tenant"
409 );
410 assert_eq!(task.unwrap().id, TaskId::new("t1"));
411 })
412 .await;
413 }
414
415 #[tokio::test]
416 async fn tenant_isolation_get() {
417 let store = make_store().await;
418 TenantContext::scope("tenant-a", async {
419 store
420 .save(&make_task("t1", "ctx1", TaskState::Submitted))
421 .await
422 .unwrap();
423 })
424 .await;
425
426 TenantContext::scope("tenant-b", async {
427 let result = store.get(&TaskId::new("t1")).await.unwrap();
428 assert!(result.is_none(), "tenant-b should not see tenant-a's task");
429 })
430 .await;
431 }
432
433 #[tokio::test]
434 async fn tenant_isolation_list() {
435 let store = make_store().await;
436 TenantContext::scope("tenant-a", async {
437 store
438 .save(&make_task("t1", "ctx1", TaskState::Submitted))
439 .await
440 .unwrap();
441 store
442 .save(&make_task("t2", "ctx1", TaskState::Working))
443 .await
444 .unwrap();
445 })
446 .await;
447
448 TenantContext::scope("tenant-b", async {
449 store
450 .save(&make_task("t3", "ctx1", TaskState::Submitted))
451 .await
452 .unwrap();
453 })
454 .await;
455
456 TenantContext::scope("tenant-a", async {
457 let response = store.list(&ListTasksParams::default()).await.unwrap();
458 assert_eq!(
459 response.tasks.len(),
460 2,
461 "tenant-a should see only its 2 tasks"
462 );
463 })
464 .await;
465
466 TenantContext::scope("tenant-b", async {
467 let response = store.list(&ListTasksParams::default()).await.unwrap();
468 assert_eq!(
469 response.tasks.len(),
470 1,
471 "tenant-b should see only its 1 task"
472 );
473 })
474 .await;
475 }
476
477 #[tokio::test]
478 async fn tenant_isolation_count() {
479 let store = make_store().await;
480 TenantContext::scope("tenant-a", async {
481 store
482 .save(&make_task("t1", "ctx1", TaskState::Submitted))
483 .await
484 .unwrap();
485 store
486 .save(&make_task("t2", "ctx1", TaskState::Working))
487 .await
488 .unwrap();
489 })
490 .await;
491
492 TenantContext::scope("tenant-b", async {
493 let count = store.count().await.unwrap();
494 assert_eq!(count, 0, "tenant-b should have zero tasks");
495 })
496 .await;
497
498 TenantContext::scope("tenant-a", async {
499 let count = store.count().await.unwrap();
500 assert_eq!(count, 2, "tenant-a should have 2 tasks");
501 })
502 .await;
503 }
504
505 #[tokio::test]
506 async fn tenant_isolation_delete() {
507 let store = make_store().await;
508 TenantContext::scope("tenant-a", async {
509 store
510 .save(&make_task("t1", "ctx1", TaskState::Submitted))
511 .await
512 .unwrap();
513 })
514 .await;
515
516 TenantContext::scope("tenant-b", async {
518 store.delete(&TaskId::new("t1")).await.unwrap();
519 })
520 .await;
521
522 TenantContext::scope("tenant-a", async {
523 let task = store.get(&TaskId::new("t1")).await.unwrap();
524 assert!(
525 task.is_some(),
526 "tenant-a's task should still exist after tenant-b's delete"
527 );
528 })
529 .await;
530 }
531
532 #[tokio::test]
533 async fn same_task_id_different_tenants() {
534 let store = make_store().await;
535 TenantContext::scope("tenant-a", async {
536 store
537 .save(&make_task("t1", "ctx-a", TaskState::Submitted))
538 .await
539 .unwrap();
540 })
541 .await;
542
543 TenantContext::scope("tenant-b", async {
544 store
545 .save(&make_task("t1", "ctx-b", TaskState::Working))
546 .await
547 .unwrap();
548 })
549 .await;
550
551 TenantContext::scope("tenant-a", async {
552 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
553 assert_eq!(
554 task.context_id,
555 ContextId::new("ctx-a"),
556 "tenant-a should get its own version of t1"
557 );
558 assert_eq!(task.status.state, TaskState::Submitted);
559 })
560 .await;
561
562 TenantContext::scope("tenant-b", async {
563 let task = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
564 assert_eq!(
565 task.context_id,
566 ContextId::new("ctx-b"),
567 "tenant-b should get its own version of t1"
568 );
569 assert_eq!(task.status.state, TaskState::Working);
570 })
571 .await;
572 }
573
574 #[tokio::test]
575 async fn insert_if_absent_respects_tenant_scope() {
576 let store = make_store().await;
577 TenantContext::scope("tenant-a", async {
578 let inserted = store
579 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Submitted))
580 .await
581 .unwrap();
582 assert!(inserted, "first insert should succeed");
583
584 let inserted = store
585 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
586 .await
587 .unwrap();
588 assert!(!inserted, "duplicate insert in same tenant should fail");
589 })
590 .await;
591
592 TenantContext::scope("tenant-b", async {
594 let inserted = store
595 .insert_if_absent(&make_task("t1", "ctx1", TaskState::Working))
596 .await
597 .unwrap();
598 assert!(
599 inserted,
600 "insert of same task id in different tenant should succeed"
601 );
602 })
603 .await;
604 }
605
606 #[tokio::test]
607 async fn list_pagination_within_tenant() {
608 let store = make_store().await;
609 TenantContext::scope("tenant-a", async {
610 for i in 0..5 {
611 store
612 .save(&make_task(
613 &format!("task-{i:03}"),
614 "ctx1",
615 TaskState::Submitted,
616 ))
617 .await
618 .unwrap();
619 }
620
621 let params = ListTasksParams {
622 page_size: Some(2),
623 ..Default::default()
624 };
625 let response = store.list(¶ms).await.unwrap();
626 assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
627 assert!(
628 !response.next_page_token.is_empty(),
629 "should have a next page token"
630 );
631
632 let params2 = ListTasksParams {
633 page_size: Some(2),
634 page_token: Some(response.next_page_token),
635 ..Default::default()
636 };
637 let response2 = store.list(¶ms2).await.unwrap();
638 assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
639 })
640 .await;
641 }
642
643 #[test]
645 fn to_a2a_error_formats_message() {
646 let sqlite_err = sqlx::Error::RowNotFound;
647 let a2a_err = to_a2a_error(&sqlite_err);
648 let msg = format!("{a2a_err}");
649 assert!(
650 msg.contains("sqlite error"),
651 "error message should contain 'sqlite error': {msg}"
652 );
653 }
654
655 #[tokio::test]
656 async fn default_tenant_context_uses_empty_string() {
657 let store = make_store().await;
658 store
660 .save(&make_task("t1", "ctx1", TaskState::Submitted))
661 .await
662 .unwrap();
663 let task = store.get(&TaskId::new("t1")).await.unwrap();
664 assert!(task.is_some(), "default (empty) tenant should work");
665 }
666}