1use std::collections::HashMap;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use a2a_protocol_types::error::A2aResult;
14use a2a_protocol_types::params::ListTasksParams;
15use a2a_protocol_types::responses::TaskListResponse;
16use a2a_protocol_types::task::{Task, TaskId};
17use tokio::sync::RwLock;
18
19use super::super::task_store::{InMemoryTaskStore, TaskStore, TaskStoreConfig};
20use super::context::TenantContext;
21
22#[derive(Debug, Clone)]
26pub struct TenantStoreConfig {
27 pub per_tenant: TaskStoreConfig,
30
31 pub max_tenants: usize,
56}
57
58impl Default for TenantStoreConfig {
59 fn default() -> Self {
60 Self {
61 per_tenant: TaskStoreConfig::default(),
62 max_tenants: 1000,
63 }
64 }
65}
66
67#[derive(Debug)]
104pub struct TenantAwareInMemoryTaskStore {
105 stores: RwLock<HashMap<String, Arc<InMemoryTaskStore>>>,
106 config: TenantStoreConfig,
107 overrides: HashMap<String, TaskStoreConfig>,
115}
116
117impl Default for TenantAwareInMemoryTaskStore {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123impl TenantAwareInMemoryTaskStore {
124 #[must_use]
126 pub fn new() -> Self {
127 Self {
128 stores: RwLock::new(HashMap::new()),
129 config: TenantStoreConfig::default(),
130 overrides: HashMap::new(),
131 }
132 }
133
134 #[must_use]
136 pub fn with_config(config: TenantStoreConfig) -> Self {
137 Self {
138 stores: RwLock::new(HashMap::new()),
139 config,
140 overrides: HashMap::new(),
141 }
142 }
143
144 #[must_use]
173 pub fn with_tenant_override(
174 mut self,
175 tenant: impl Into<String>,
176 config: TaskStoreConfig,
177 ) -> Self {
178 self.overrides.insert(tenant.into(), config);
179 self
180 }
181
182 async fn get_store(&self) -> A2aResult<Arc<InMemoryTaskStore>> {
184 let tenant = TenantContext::current();
185
186 {
188 let stores = self.stores.read().await;
189 if let Some(store) = stores.get(&tenant) {
190 return Ok(Arc::clone(store));
191 }
192 }
193
194 let mut stores = self.stores.write().await;
196 if let Some(store) = stores.get(&tenant) {
198 return Ok(Arc::clone(store));
199 }
200
201 if stores.len() >= self.config.max_tenants {
202 return Err(a2a_protocol_types::error::A2aError::internal(format!(
203 "tenant limit exceeded: max {} tenants",
204 self.config.max_tenants
205 )));
206 }
207
208 let store = Arc::new(InMemoryTaskStore::with_config(
209 self.overrides
210 .get(&tenant)
211 .unwrap_or(&self.config.per_tenant)
212 .clone(),
213 ));
214 stores.insert(tenant, Arc::clone(&store));
215 drop(stores);
216 Ok(store)
217 }
218
219 async fn get_existing_store(&self) -> Option<Arc<InMemoryTaskStore>> {
225 let tenant = TenantContext::current();
226 let stores = self.stores.read().await;
227 stores.get(&tenant).map(Arc::clone)
228 }
229
230 pub async fn tenant_count(&self) -> usize {
232 self.stores.read().await.len()
233 }
234
235 pub async fn run_eviction_all(&self) {
239 let stores = self.stores.read().await;
240 for store in stores.values() {
241 store.run_eviction().await;
242 }
243 }
244
245 pub async fn prune_empty_tenants(&self) {
258 let mut stores = self.stores.write().await;
259 let mut empty_tenants = Vec::new();
260 for (tenant, store) in stores.iter() {
261 if store.count().await.unwrap_or(0) == 0 {
262 empty_tenants.push(tenant.clone());
263 }
264 }
265 for tenant in empty_tenants {
266 stores.remove(&tenant);
267 }
268 }
269}
270
271#[allow(clippy::manual_async_fn)]
272impl TaskStore for TenantAwareInMemoryTaskStore {
273 fn save<'a>(
274 &'a self,
275 task: &'a Task,
276 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
277 Box::pin(async move {
278 let store = self.get_store().await?;
279 store.save(task).await
280 })
281 }
282
283 fn get<'a>(
284 &'a self,
285 id: &'a TaskId,
286 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
287 Box::pin(async move {
288 match self.get_existing_store().await {
289 Some(store) => store.get(id).await,
290 None => Ok(None),
291 }
292 })
293 }
294
295 fn list<'a>(
296 &'a self,
297 params: &'a ListTasksParams,
298 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
299 Box::pin(async move {
300 match self.get_existing_store().await {
301 Some(store) => store.list(params).await,
302 None => Ok(TaskListResponse::new(Vec::new())),
303 }
304 })
305 }
306
307 fn insert_if_absent<'a>(
308 &'a self,
309 task: &'a Task,
310 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
311 Box::pin(async move {
312 let store = self.get_store().await?;
313 store.insert_if_absent(task).await
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 match self.get_existing_store().await {
323 Some(store) => store.delete(id).await,
324 None => Ok(()),
325 }
326 })
327 }
328
329 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
330 Box::pin(async move {
331 match self.get_existing_store().await {
332 Some(store) => store.count().await,
333 None => Ok(0),
334 }
335 })
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
343
344 fn make_task(id: &str, state: TaskState) -> Task {
346 Task {
347 id: TaskId::new(id),
348 context_id: ContextId::new("ctx-default"),
349 status: TaskStatus::new(state),
350 history: None,
351 artifacts: None,
352 metadata: None,
353 }
354 }
355
356 #[tokio::test]
368 async fn per_tenant_page_size_cap_reaches_the_delegate() {
369 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
370 per_tenant: TaskStoreConfig {
371 max_page_size: 2,
372 ..TaskStoreConfig::default()
373 },
374 max_tenants: 10,
375 });
376
377 TenantContext::scope("capped", async {
378 for i in 0..5 {
379 store
380 .save(&make_task(&format!("t{i}"), TaskState::Submitted))
381 .await
382 .expect("save");
383 }
384
385 let listed = store
386 .list(&ListTasksParams {
387 page_size: Some(100),
388 ..Default::default()
389 })
390 .await
391 .expect("list");
392
393 assert_eq!(
394 listed.tasks.len(),
395 2,
396 "the caller asked for 100; per_tenant.max_page_size is 2. \
397 A delegate built with the default config would return 5"
398 );
399 })
400 .await;
401 }
402
403 #[tokio::test]
410 async fn an_override_gives_that_tenant_its_own_store_config() {
411 async fn saved_then_listed(
412 store: &TenantAwareInMemoryTaskStore,
413 tenant: &'static str,
414 ) -> usize {
415 TenantContext::scope(tenant, async {
416 for i in 0..5 {
417 store
418 .save(&make_task(&format!("{tenant}-{i}"), TaskState::Submitted))
419 .await
420 .expect("save");
421 }
422 store
423 .list(&ListTasksParams {
424 page_size: Some(100),
425 ..Default::default()
426 })
427 .await
428 .expect("list")
429 .tasks
430 .len()
431 })
432 .await
433 }
434
435 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
436 per_tenant: TaskStoreConfig {
437 max_page_size: 50,
438 ..TaskStoreConfig::default()
439 },
440 max_tenants: 10,
441 })
442 .with_tenant_override(
443 "small",
444 TaskStoreConfig {
445 max_page_size: 1,
446 ..TaskStoreConfig::default()
447 },
448 );
449
450 assert_eq!(
451 saved_then_listed(&store, "small").await,
452 1,
453 "the override caps this tenant's page size at 1"
454 );
455 assert_eq!(
456 saved_then_listed(&store, "ordinary").await,
457 5,
458 "a tenant with no override keeps per_tenant's cap of 50"
459 );
460 }
461
462 #[tokio::test]
465 async fn tenant_context_default_is_empty_string() {
466 let tenant = TenantContext::current();
468 assert_eq!(tenant, "", "default tenant should be empty string");
469 }
470
471 #[tokio::test]
472 async fn tenant_context_scope_sets_and_restores() {
473 let before = TenantContext::current();
474 assert_eq!(before, "");
475
476 let inside = TenantContext::scope("acme", async { TenantContext::current() }).await;
477 assert_eq!(inside, "acme", "scope should set the tenant");
478
479 let after = TenantContext::current();
480 assert_eq!(after, "", "tenant should revert after scope exits");
481 }
482
483 #[tokio::test]
484 async fn tenant_context_nested_scopes() {
485 TenantContext::scope("outer", async {
486 assert_eq!(TenantContext::current(), "outer");
487 TenantContext::scope("inner", async {
488 assert_eq!(TenantContext::current(), "inner");
489 })
490 .await;
491 assert_eq!(
492 TenantContext::current(),
493 "outer",
494 "should restore outer tenant after inner scope"
495 );
496 })
497 .await;
498 }
499
500 #[tokio::test]
503 async fn tenant_isolation_save_and_get() {
504 let store = TenantAwareInMemoryTaskStore::new();
505
506 TenantContext::scope("tenant-a", async {
508 store
509 .save(&make_task("t1", TaskState::Submitted))
510 .await
511 .unwrap();
512 })
513 .await;
514
515 let found = TenantContext::scope("tenant-a", async {
517 store.get(&TaskId::new("t1")).await.unwrap()
518 })
519 .await;
520 assert!(found.is_some(), "tenant-a should see its own task");
521
522 let not_found = TenantContext::scope("tenant-b", async {
524 store.get(&TaskId::new("t1")).await.unwrap()
525 })
526 .await;
527 assert!(
528 not_found.is_none(),
529 "tenant-b should not see tenant-a's task"
530 );
531 }
532
533 #[tokio::test]
534 async fn tenant_isolation_list() {
535 let store = TenantAwareInMemoryTaskStore::new();
536
537 TenantContext::scope("alpha", async {
538 store
539 .save(&make_task("a1", TaskState::Submitted))
540 .await
541 .unwrap();
542 store
543 .save(&make_task("a2", TaskState::Working))
544 .await
545 .unwrap();
546 })
547 .await;
548
549 TenantContext::scope("beta", async {
550 store
551 .save(&make_task("b1", TaskState::Submitted))
552 .await
553 .unwrap();
554 })
555 .await;
556
557 let alpha_list = TenantContext::scope("alpha", async {
558 let params = ListTasksParams::default();
559 store.list(¶ms).await.unwrap()
560 })
561 .await;
562 assert_eq!(
563 alpha_list.tasks.len(),
564 2,
565 "alpha should see only its 2 tasks"
566 );
567
568 let beta_list = TenantContext::scope("beta", async {
569 let params = ListTasksParams::default();
570 store.list(¶ms).await.unwrap()
571 })
572 .await;
573 assert_eq!(beta_list.tasks.len(), 1, "beta should see only its 1 task");
574 }
575
576 #[tokio::test]
577 async fn tenant_isolation_delete() {
578 let store = TenantAwareInMemoryTaskStore::new();
579
580 TenantContext::scope("tenant-a", async {
581 store
582 .save(&make_task("t1", TaskState::Submitted))
583 .await
584 .unwrap();
585 })
586 .await;
587
588 TenantContext::scope("tenant-b", async {
590 store.delete(&TaskId::new("t1")).await.unwrap();
591 })
592 .await;
593
594 let still_exists = TenantContext::scope("tenant-a", async {
595 store.get(&TaskId::new("t1")).await.unwrap()
596 })
597 .await;
598 assert!(
599 still_exists.is_some(),
600 "tenant-a's task should survive tenant-b's delete"
601 );
602 }
603
604 #[tokio::test]
605 async fn tenant_isolation_insert_if_absent() {
606 let store = TenantAwareInMemoryTaskStore::new();
607
608 let inserted_a = TenantContext::scope("tenant-a", async {
610 store
611 .insert_if_absent(&make_task("shared-id", TaskState::Submitted))
612 .await
613 .unwrap()
614 })
615 .await;
616 assert!(inserted_a, "tenant-a insert should succeed");
617
618 let inserted_b = TenantContext::scope("tenant-b", async {
619 store
620 .insert_if_absent(&make_task("shared-id", TaskState::Working))
621 .await
622 .unwrap()
623 })
624 .await;
625 assert!(
626 inserted_b,
627 "tenant-b insert of same ID should also succeed (different partition)"
628 );
629 }
630
631 #[tokio::test]
632 async fn tenant_isolation_count() {
633 let store = TenantAwareInMemoryTaskStore::new();
634
635 TenantContext::scope("x", async {
636 store
637 .save(&make_task("t1", TaskState::Submitted))
638 .await
639 .unwrap();
640 store
641 .save(&make_task("t2", TaskState::Submitted))
642 .await
643 .unwrap();
644 })
645 .await;
646
647 TenantContext::scope("y", async {
648 store
649 .save(&make_task("t3", TaskState::Submitted))
650 .await
651 .unwrap();
652 })
653 .await;
654
655 let count_x = TenantContext::scope("x", async { store.count().await.unwrap() }).await;
656 assert_eq!(count_x, 2, "tenant x should have 2 tasks");
657
658 let count_y = TenantContext::scope("y", async { store.count().await.unwrap() }).await;
659 assert_eq!(count_y, 1, "tenant y should have 1 task");
660 }
661
662 #[tokio::test]
674 async fn a_full_tenant_table_refuses_new_tenants_and_pruning_does_not_help() {
675 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
676 per_tenant: TaskStoreConfig::default(),
677 max_tenants: 2,
678 });
679
680 for junk in ["junk-1", "junk-2"] {
681 TenantContext::scope(junk, async {
682 store
683 .save(&make_task("t", TaskState::Working))
684 .await
685 .expect("a fresh tenant partition is created on demand");
686 })
687 .await;
688 }
689 assert_eq!(store.tenant_count().await, 2, "the table is full");
690
691 let refused = TenantContext::scope("legitimate", async {
692 store.save(&make_task("t", TaskState::Working)).await
693 })
694 .await;
695 assert!(
696 refused.is_err(),
697 "a new tenant must be refused once the cap is reached"
698 );
699
700 store.prune_empty_tenants().await;
702 assert_eq!(
703 store.tenant_count().await,
704 2,
705 "pruning reclaims nothing while the junk partitions hold live tasks"
706 );
707
708 let still_refused = TenantContext::scope("legitimate", async {
709 store.save(&make_task("t", TaskState::Working)).await
710 })
711 .await;
712 assert!(
713 still_refused.is_err(),
714 "so the lockout outlives the pruning that is supposed to end it"
715 );
716
717 for junk in ["junk-1", "junk-2"] {
719 TenantContext::scope(junk, async {
720 store.delete(&TaskId::new("t")).await.expect("delete");
721 })
722 .await;
723 }
724 store.prune_empty_tenants().await;
725 assert_eq!(store.tenant_count().await, 0, "now the slots come back");
726
727 let admitted = TenantContext::scope("legitimate", async {
728 store.save(&make_task("t", TaskState::Working)).await
729 })
730 .await;
731 assert!(admitted.is_ok(), "and the legitimate tenant gets in");
732 }
733
734 #[tokio::test]
735 async fn tenant_count_reflects_active_tenants() {
736 let store = TenantAwareInMemoryTaskStore::new();
737 assert_eq!(store.tenant_count().await, 0);
738
739 TenantContext::scope("a", async {
740 store
741 .save(&make_task("t1", TaskState::Submitted))
742 .await
743 .unwrap();
744 })
745 .await;
746 assert_eq!(store.tenant_count().await, 1);
747
748 TenantContext::scope("b", async {
749 store
750 .save(&make_task("t2", TaskState::Submitted))
751 .await
752 .unwrap();
753 })
754 .await;
755 assert_eq!(store.tenant_count().await, 2);
756 }
757
758 #[tokio::test]
759 async fn max_tenants_limit_enforced() {
760 let config = TenantStoreConfig {
761 per_tenant: TaskStoreConfig::default(),
762 max_tenants: 2,
763 };
764 let store = TenantAwareInMemoryTaskStore::with_config(config);
765
766 TenantContext::scope("t1", async {
768 store
769 .save(&make_task("task-a", TaskState::Submitted))
770 .await
771 .unwrap();
772 })
773 .await;
774 TenantContext::scope("t2", async {
775 store
776 .save(&make_task("task-b", TaskState::Submitted))
777 .await
778 .unwrap();
779 })
780 .await;
781
782 let result = TenantContext::scope("t3", async {
784 store.save(&make_task("task-c", TaskState::Submitted)).await
785 })
786 .await;
787 assert!(
788 result.is_err(),
789 "exceeding max_tenants should return an error"
790 );
791 }
792
793 #[tokio::test]
794 async fn existing_tenant_does_not_count_against_limit() {
795 let config = TenantStoreConfig {
796 per_tenant: TaskStoreConfig::default(),
797 max_tenants: 1,
798 };
799 let store = TenantAwareInMemoryTaskStore::with_config(config);
800
801 TenantContext::scope("only", async {
802 store
803 .save(&make_task("t1", TaskState::Submitted))
804 .await
805 .unwrap();
806 store
808 .save(&make_task("t2", TaskState::Working))
809 .await
810 .unwrap();
811 })
812 .await;
813
814 let count = TenantContext::scope("only", async { store.count().await.unwrap() }).await;
815 assert_eq!(count, 2, "existing tenant can add more tasks");
816 }
817
818 #[tokio::test]
821 async fn no_tenant_context_uses_default_partition() {
822 let store = TenantAwareInMemoryTaskStore::new();
823
824 store
826 .save(&make_task("default-task", TaskState::Submitted))
827 .await
828 .unwrap();
829
830 let fetched = store.get(&TaskId::new("default-task")).await.unwrap();
831 assert!(
832 fetched.is_some(),
833 "task saved without tenant context should be retrievable without context"
834 );
835
836 let not_found = TenantContext::scope("other", async {
838 store.get(&TaskId::new("default-task")).await.unwrap()
839 })
840 .await;
841 assert!(
842 not_found.is_none(),
843 "default partition task should not leak to named tenants"
844 );
845 }
846
847 #[tokio::test]
850 async fn prune_empty_tenants_removes_empty_partitions() {
851 let store = TenantAwareInMemoryTaskStore::new();
852
853 TenantContext::scope("keep", async {
854 store
855 .save(&make_task("t1", TaskState::Submitted))
856 .await
857 .unwrap();
858 })
859 .await;
860 TenantContext::scope("remove", async {
861 store
862 .save(&make_task("t2", TaskState::Submitted))
863 .await
864 .unwrap();
865 })
866 .await;
867 assert_eq!(store.tenant_count().await, 2);
868
869 TenantContext::scope("remove", async {
871 store.delete(&TaskId::new("t2")).await.unwrap();
872 })
873 .await;
874
875 store.prune_empty_tenants().await;
876 assert_eq!(
877 store.tenant_count().await,
878 1,
879 "empty tenant partition should be pruned"
880 );
881 }
882
883 #[test]
887 fn default_creates_new_tenant_store() {
888 let store = TenantAwareInMemoryTaskStore::default();
889 let rt = tokio::runtime::Builder::new_current_thread()
890 .enable_all()
891 .build()
892 .unwrap();
893 let count = rt.block_on(store.tenant_count());
894 assert_eq!(count, 0, "default store should have no tenants");
895 }
896
897 #[tokio::test]
899 async fn run_eviction_all_runs_without_error() {
900 let store = TenantAwareInMemoryTaskStore::new();
901
902 TenantContext::scope("t1", async {
904 store
905 .save(&make_task("task-a", TaskState::Completed))
906 .await
907 .unwrap();
908 })
909 .await;
910 TenantContext::scope("t2", async {
911 store
912 .save(&make_task("task-b", TaskState::Working))
913 .await
914 .unwrap();
915 })
916 .await;
917
918 store.run_eviction_all().await;
920 }
921
922 #[tokio::test]
926 async fn get_store_double_check_path() {
927 let store = TenantAwareInMemoryTaskStore::new();
928
929 TenantContext::scope("racer", async {
931 store
932 .save(&make_task("t1", TaskState::Submitted))
933 .await
934 .unwrap();
935 store
937 .save(&make_task("t2", TaskState::Working))
938 .await
939 .unwrap();
940
941 let count = store.count().await.unwrap();
942 assert_eq!(count, 2, "both tasks should be in same tenant store");
943 })
944 .await;
945
946 assert_eq!(
947 store.tenant_count().await,
948 1,
949 "should have exactly 1 tenant"
950 );
951 }
952
953 #[test]
954 fn default_tenant_store_config() {
955 let cfg = TenantStoreConfig::default();
956 assert_eq!(cfg.max_tenants, 1000);
957 }
958
959 #[tokio::test]
971 async fn run_eviction_all_evicts_in_every_tenant() {
972 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
973 per_tenant: TaskStoreConfig {
974 task_ttl: Some(std::time::Duration::from_millis(1)),
975 ..TaskStoreConfig::default()
976 },
977 ..TenantStoreConfig::default()
978 });
979
980 for tenant in ["tenant-a", "tenant-b"] {
981 TenantContext::scope(tenant, async {
982 store
983 .save(&make_task("t1", TaskState::Completed))
984 .await
985 .expect("save");
986 })
987 .await;
988 }
989
990 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
992 store.run_eviction_all().await;
993
994 for tenant in ["tenant-a", "tenant-b"] {
995 let still_there = TenantContext::scope(tenant, async {
996 store.get(&TaskId::new("t1")).await.expect("get")
997 })
998 .await;
999 assert!(
1000 still_there.is_none(),
1001 "a terminal task past its TTL must be evicted in {tenant}; \
1002 surviving means run_eviction_all did not reach this partition"
1003 );
1004 }
1005 }
1006}