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, RecordedEvent, TaskStore, TaskStoreConfig};
20use super::context::TenantContext;
21
22#[derive(Debug, Clone)]
30#[non_exhaustive]
31pub struct TenantStoreConfig {
32 pub per_tenant: TaskStoreConfig,
35
36 pub max_tenants: usize,
61}
62
63impl Default for TenantStoreConfig {
64 fn default() -> Self {
65 Self {
66 per_tenant: TaskStoreConfig::default(),
67 max_tenants: 1000,
68 }
69 }
70}
71
72impl TenantStoreConfig {
73 #[must_use]
75 pub const fn with_per_tenant(mut self, config: TaskStoreConfig) -> Self {
76 self.per_tenant = config;
77 self
78 }
79
80 #[must_use]
83 pub const fn with_max_tenants(mut self, max: usize) -> Self {
84 self.max_tenants = max;
85 self
86 }
87}
88
89#[derive(Debug)]
126pub struct TenantAwareInMemoryTaskStore {
127 stores: RwLock<HashMap<String, Arc<InMemoryTaskStore>>>,
128 config: TenantStoreConfig,
129 overrides: HashMap<String, TaskStoreConfig>,
138 metrics: crate::metrics::MetricsHandle,
144}
145
146impl Default for TenantAwareInMemoryTaskStore {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152impl TenantAwareInMemoryTaskStore {
153 #[must_use]
155 pub fn new() -> Self {
156 Self {
157 stores: RwLock::new(HashMap::new()),
158 config: TenantStoreConfig::default(),
159 overrides: HashMap::new(),
160 metrics: crate::metrics::MetricsHandle::default(),
161 }
162 }
163
164 #[must_use]
166 pub fn with_config(config: TenantStoreConfig) -> Self {
167 Self {
168 stores: RwLock::new(HashMap::new()),
169 config,
170 overrides: HashMap::new(),
171 metrics: crate::metrics::MetricsHandle::default(),
172 }
173 }
174
175 #[must_use]
185 pub fn with_metrics(mut self, metrics: crate::metrics::MetricsHandle) -> Self {
186 self.metrics = metrics;
187 self
188 }
189
190 #[must_use]
216 pub fn with_tenant_override(
217 mut self,
218 tenant: impl Into<String>,
219 config: TaskStoreConfig,
220 ) -> Self {
221 self.overrides.insert(tenant.into(), config);
222 self
223 }
224
225 async fn get_store(&self) -> A2aResult<Arc<InMemoryTaskStore>> {
227 let tenant = TenantContext::current();
228
229 {
231 let stores = self.stores.read().await;
232 if let Some(store) = stores.get(&tenant) {
233 return Ok(Arc::clone(store));
234 }
235 }
236
237 let mut stores = self.stores.write().await;
239 if let Some(store) = stores.get(&tenant) {
241 return Ok(Arc::clone(store));
242 }
243
244 if stores.len() >= self.config.max_tenants {
245 return Err(a2a_protocol_types::error::A2aError::internal(format!(
246 "tenant limit exceeded: max {} tenants",
247 self.config.max_tenants
248 )));
249 }
250
251 let store = Arc::new(
252 InMemoryTaskStore::with_config(
253 self.overrides
254 .get(&tenant)
255 .unwrap_or(&self.config.per_tenant)
256 .clone(),
257 )
258 .with_metrics(self.metrics.clone()),
259 );
260 stores.insert(tenant, Arc::clone(&store));
261 drop(stores);
262 Ok(store)
263 }
264
265 async fn get_existing_store(&self) -> Option<Arc<InMemoryTaskStore>> {
271 let tenant = TenantContext::current();
272 let stores = self.stores.read().await;
273 stores.get(&tenant).map(Arc::clone)
274 }
275
276 pub async fn tenant_count(&self) -> usize {
278 self.stores.read().await.len()
279 }
280
281 pub async fn run_eviction_all(&self) {
285 let stores = self.stores.read().await;
286 for store in stores.values() {
287 store.run_eviction().await;
288 }
289 }
290
291 pub async fn prune_empty_tenants(&self) {
316 let mut stores = self.stores.write().await;
317 let mut empty_tenants = Vec::new();
318 for (tenant, store) in stores.iter() {
319 if store.is_prunable().await {
322 empty_tenants.push(tenant.clone());
323 }
324 }
325 for tenant in empty_tenants {
326 stores.remove(&tenant);
327 }
328 }
329}
330
331#[allow(clippy::manual_async_fn)]
332impl TaskStore for TenantAwareInMemoryTaskStore {
333 fn supports_idempotency(&self) -> bool {
337 true
338 }
339
340 fn claim_idempotency_key<'a>(
341 &'a self,
342 key: &'a str,
343 message_id: &'a a2a_protocol_types::message::MessageId,
344 task_id: &'a TaskId,
345 ) -> Pin<
346 Box<dyn Future<Output = A2aResult<super::super::task_store::IdempotencyClaim>> + Send + 'a>,
347 > {
348 Box::pin(async move {
349 let store = self.get_store().await?;
350 store.claim_idempotency_key(key, message_id, task_id).await
351 })
352 }
353
354 fn release_idempotency_key<'a>(
355 &'a self,
356 key: &'a str,
357 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
358 Box::pin(async move {
359 match self.get_existing_store().await {
365 Some(store) => store.release_idempotency_key(key).await,
366 None => Ok(()),
367 }
368 })
369 }
370
371 fn save<'a>(
372 &'a self,
373 task: &'a Task,
374 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
375 Box::pin(async move {
376 let store = self.get_store().await?;
377 store.save(task).await
378 })
379 }
380
381 fn get<'a>(
382 &'a self,
383 id: &'a TaskId,
384 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
385 Box::pin(async move {
386 match self.get_existing_store().await {
387 Some(store) => store.get(id).await,
388 None => Ok(None),
389 }
390 })
391 }
392
393 fn list<'a>(
394 &'a self,
395 params: &'a ListTasksParams,
396 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
397 Box::pin(async move {
398 match self.get_existing_store().await {
399 Some(store) => store.list(params).await,
400 None => Ok(TaskListResponse::new(Vec::new())),
401 }
402 })
403 }
404
405 fn insert_if_absent<'a>(
406 &'a self,
407 task: &'a Task,
408 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
409 Box::pin(async move {
410 let store = self.get_store().await?;
411 store.insert_if_absent(task).await
412 })
413 }
414
415 fn delete<'a>(
416 &'a self,
417 id: &'a TaskId,
418 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
419 Box::pin(async move {
420 match self.get_existing_store().await {
421 Some(store) => store.delete(id).await,
422 None => Ok(()),
423 }
424 })
425 }
426
427 fn supports_event_log(&self) -> bool {
432 true
433 }
434
435 fn append_event<'a>(
436 &'a self,
437 task_id: &'a TaskId,
438 seq: u64,
439 event: &'a a2a_protocol_types::events::StreamResponse,
440 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
441 Box::pin(async move {
442 let store = self.get_store().await?;
443 store.append_event(task_id, seq, event).await
444 })
445 }
446
447 fn last_event_seq<'a>(
448 &'a self,
449 task_id: &'a TaskId,
450 ) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
451 Box::pin(async move {
452 match self.get_existing_store().await {
456 Some(store) => store.last_event_seq(task_id).await,
457 None => Ok(0),
458 }
459 })
460 }
461
462 fn earliest_event_seq<'a>(
463 &'a self,
464 task_id: &'a TaskId,
465 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<u64>>> + Send + 'a>> {
466 Box::pin(async move {
467 match self.get_existing_store().await {
474 Some(store) => store.earliest_event_seq(task_id).await,
475 None => Ok(None),
476 }
477 })
478 }
479
480 fn read_events<'a>(
481 &'a self,
482 task_id: &'a TaskId,
483 after_seq: u64,
484 limit: usize,
485 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<RecordedEvent>>> + Send + 'a>> {
486 Box::pin(async move {
487 match self.get_existing_store().await {
488 Some(store) => store.read_events(task_id, after_seq, limit).await,
489 None => Ok(Vec::new()),
490 }
491 })
492 }
493
494 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
495 Box::pin(async move {
496 match self.get_existing_store().await {
497 Some(store) => store.count().await,
498 None => Ok(0),
499 }
500 })
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
508
509 fn make_task(id: &str, state: TaskState) -> Task {
511 Task {
512 id: TaskId::new(id),
513 context_id: ContextId::new("ctx-default"),
514 status: TaskStatus::new(state),
515 history: None,
516 artifacts: None,
517 metadata: None,
518 }
519 }
520
521 #[tokio::test]
533 async fn with_metrics_reaches_the_per_tenant_stores() {
534 use a2a_protocol_types::events::StreamResponse;
535 use a2a_protocol_types::events::TaskStatusUpdateEvent;
536
537 #[derive(Default)]
538 struct Recorder(std::sync::Mutex<Vec<(String, String)>>);
539
540 impl crate::metrics::Metrics for Recorder {
541 fn on_persistence_error(&self, operation: &str, error_kind: &str) {
542 self.0
543 .lock()
544 .expect("not poisoned")
545 .push((operation.to_owned(), error_kind.to_owned()));
546 }
547 }
548
549 let recorder = Arc::new(Recorder::default());
550 let store = TenantAwareInMemoryTaskStore::new()
551 .with_metrics(crate::metrics::MetricsHandle::from_arc(recorder.clone()));
552
553 store
557 .append_event(
558 &TaskId::new("gone"),
559 1,
560 &StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
561 task_id: TaskId::new("gone"),
562 context_id: ContextId::new("ctx-default"),
563 status: TaskStatus::new(TaskState::Working),
564 metadata: None,
565 }),
566 )
567 .await
568 .expect("a missing task must not fail the agent");
569
570 assert_eq!(
571 *recorder.0.lock().expect("not poisoned"),
572 vec![(
573 crate::metrics::persistence_operation::EVENT_APPEND.to_owned(),
574 crate::metrics::event_append_error::TASK_ABSENT.to_owned(),
575 )],
576 "the partition must report to the handle the store was given"
577 );
578 }
579
580 #[tokio::test]
592 async fn per_tenant_page_size_cap_reaches_the_delegate() {
593 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
594 per_tenant: TaskStoreConfig {
595 max_page_size: 2,
596 ..TaskStoreConfig::default()
597 },
598 max_tenants: 10,
599 });
600
601 TenantContext::scope("capped", async {
602 for i in 0..5 {
603 store
604 .save(&make_task(&format!("t{i}"), TaskState::Submitted))
605 .await
606 .expect("save");
607 }
608
609 let listed = store
610 .list(&ListTasksParams {
611 page_size: Some(100),
612 ..Default::default()
613 })
614 .await
615 .expect("list");
616
617 assert_eq!(
618 listed.tasks.len(),
619 2,
620 "the caller asked for 100; per_tenant.max_page_size is 2. \
621 A delegate built with the default config would return 5"
622 );
623 })
624 .await;
625 }
626
627 #[tokio::test]
634 async fn an_override_gives_that_tenant_its_own_store_config() {
635 async fn saved_then_listed(
636 store: &TenantAwareInMemoryTaskStore,
637 tenant: &'static str,
638 ) -> usize {
639 TenantContext::scope(tenant, async {
640 for i in 0..5 {
641 store
642 .save(&make_task(&format!("{tenant}-{i}"), TaskState::Submitted))
643 .await
644 .expect("save");
645 }
646 store
647 .list(&ListTasksParams {
648 page_size: Some(100),
649 ..Default::default()
650 })
651 .await
652 .expect("list")
653 .tasks
654 .len()
655 })
656 .await
657 }
658
659 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
660 per_tenant: TaskStoreConfig {
661 max_page_size: 50,
662 ..TaskStoreConfig::default()
663 },
664 max_tenants: 10,
665 })
666 .with_tenant_override(
667 "small",
668 TaskStoreConfig {
669 max_page_size: 1,
670 ..TaskStoreConfig::default()
671 },
672 );
673
674 assert_eq!(
675 saved_then_listed(&store, "small").await,
676 1,
677 "the override caps this tenant's page size at 1"
678 );
679 assert_eq!(
680 saved_then_listed(&store, "ordinary").await,
681 5,
682 "a tenant with no override keeps per_tenant's cap of 50"
683 );
684 }
685
686 #[tokio::test]
689 async fn tenant_context_default_is_empty_string() {
690 let tenant = TenantContext::current();
692 assert_eq!(tenant, "", "default tenant should be empty string");
693 }
694
695 #[tokio::test]
696 async fn tenant_context_scope_sets_and_restores() {
697 let before = TenantContext::current();
698 assert_eq!(before, "");
699
700 let inside = TenantContext::scope("acme", async { TenantContext::current() }).await;
701 assert_eq!(inside, "acme", "scope should set the tenant");
702
703 let after = TenantContext::current();
704 assert_eq!(after, "", "tenant should revert after scope exits");
705 }
706
707 #[tokio::test]
708 async fn tenant_context_nested_scopes() {
709 TenantContext::scope("outer", async {
710 assert_eq!(TenantContext::current(), "outer");
711 TenantContext::scope("inner", async {
712 assert_eq!(TenantContext::current(), "inner");
713 })
714 .await;
715 assert_eq!(
716 TenantContext::current(),
717 "outer",
718 "should restore outer tenant after inner scope"
719 );
720 })
721 .await;
722 }
723
724 #[tokio::test]
727 async fn tenant_isolation_save_and_get() {
728 let store = TenantAwareInMemoryTaskStore::new();
729
730 TenantContext::scope("tenant-a", async {
732 store
733 .save(&make_task("t1", TaskState::Submitted))
734 .await
735 .unwrap();
736 })
737 .await;
738
739 let found = TenantContext::scope("tenant-a", async {
741 store.get(&TaskId::new("t1")).await.unwrap()
742 })
743 .await;
744 assert!(found.is_some(), "tenant-a should see its own task");
745
746 let not_found = TenantContext::scope("tenant-b", async {
748 store.get(&TaskId::new("t1")).await.unwrap()
749 })
750 .await;
751 assert!(
752 not_found.is_none(),
753 "tenant-b should not see tenant-a's task"
754 );
755 }
756
757 #[tokio::test]
758 async fn tenant_isolation_list() {
759 let store = TenantAwareInMemoryTaskStore::new();
760
761 TenantContext::scope("alpha", async {
762 store
763 .save(&make_task("a1", TaskState::Submitted))
764 .await
765 .unwrap();
766 store
767 .save(&make_task("a2", TaskState::Working))
768 .await
769 .unwrap();
770 })
771 .await;
772
773 TenantContext::scope("beta", async {
774 store
775 .save(&make_task("b1", TaskState::Submitted))
776 .await
777 .unwrap();
778 })
779 .await;
780
781 let alpha_list = TenantContext::scope("alpha", async {
782 let params = ListTasksParams::default();
783 store.list(¶ms).await.unwrap()
784 })
785 .await;
786 assert_eq!(
787 alpha_list.tasks.len(),
788 2,
789 "alpha should see only its 2 tasks"
790 );
791
792 let beta_list = TenantContext::scope("beta", async {
793 let params = ListTasksParams::default();
794 store.list(¶ms).await.unwrap()
795 })
796 .await;
797 assert_eq!(beta_list.tasks.len(), 1, "beta should see only its 1 task");
798 }
799
800 #[tokio::test]
801 async fn tenant_isolation_delete() {
802 let store = TenantAwareInMemoryTaskStore::new();
803
804 TenantContext::scope("tenant-a", async {
805 store
806 .save(&make_task("t1", TaskState::Submitted))
807 .await
808 .unwrap();
809 })
810 .await;
811
812 TenantContext::scope("tenant-b", async {
814 store.delete(&TaskId::new("t1")).await.unwrap();
815 })
816 .await;
817
818 let still_exists = TenantContext::scope("tenant-a", async {
819 store.get(&TaskId::new("t1")).await.unwrap()
820 })
821 .await;
822 assert!(
823 still_exists.is_some(),
824 "tenant-a's task should survive tenant-b's delete"
825 );
826 }
827
828 #[tokio::test]
829 async fn tenant_isolation_insert_if_absent() {
830 let store = TenantAwareInMemoryTaskStore::new();
831
832 let inserted_a = TenantContext::scope("tenant-a", async {
834 store
835 .insert_if_absent(&make_task("shared-id", TaskState::Submitted))
836 .await
837 .unwrap()
838 })
839 .await;
840 assert!(inserted_a, "tenant-a insert should succeed");
841
842 let inserted_b = TenantContext::scope("tenant-b", async {
843 store
844 .insert_if_absent(&make_task("shared-id", TaskState::Working))
845 .await
846 .unwrap()
847 })
848 .await;
849 assert!(
850 inserted_b,
851 "tenant-b insert of same ID should also succeed (different partition)"
852 );
853 }
854
855 #[tokio::test]
856 async fn tenant_isolation_count() {
857 let store = TenantAwareInMemoryTaskStore::new();
858
859 TenantContext::scope("x", async {
860 store
861 .save(&make_task("t1", TaskState::Submitted))
862 .await
863 .unwrap();
864 store
865 .save(&make_task("t2", TaskState::Submitted))
866 .await
867 .unwrap();
868 })
869 .await;
870
871 TenantContext::scope("y", async {
872 store
873 .save(&make_task("t3", TaskState::Submitted))
874 .await
875 .unwrap();
876 })
877 .await;
878
879 let count_x = TenantContext::scope("x", async { store.count().await.unwrap() }).await;
880 assert_eq!(count_x, 2, "tenant x should have 2 tasks");
881
882 let count_y = TenantContext::scope("y", async { store.count().await.unwrap() }).await;
883 assert_eq!(count_y, 1, "tenant y should have 1 task");
884 }
885
886 #[tokio::test]
898 async fn a_full_tenant_table_refuses_new_tenants_and_pruning_does_not_help() {
899 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
900 per_tenant: TaskStoreConfig::default(),
901 max_tenants: 2,
902 });
903
904 for junk in ["junk-1", "junk-2"] {
905 TenantContext::scope(junk, async {
906 store
907 .save(&make_task("t", TaskState::Working))
908 .await
909 .expect("a fresh tenant partition is created on demand");
910 })
911 .await;
912 }
913 assert_eq!(store.tenant_count().await, 2, "the table is full");
914
915 let refused = TenantContext::scope("legitimate", async {
916 store.save(&make_task("t", TaskState::Working)).await
917 })
918 .await;
919 assert!(
920 refused.is_err(),
921 "a new tenant must be refused once the cap is reached"
922 );
923
924 store.prune_empty_tenants().await;
926 assert_eq!(
927 store.tenant_count().await,
928 2,
929 "pruning reclaims nothing while the junk partitions hold live tasks"
930 );
931
932 let still_refused = TenantContext::scope("legitimate", async {
933 store.save(&make_task("t", TaskState::Working)).await
934 })
935 .await;
936 assert!(
937 still_refused.is_err(),
938 "so the lockout outlives the pruning that is supposed to end it"
939 );
940
941 for junk in ["junk-1", "junk-2"] {
943 TenantContext::scope(junk, async {
944 store.delete(&TaskId::new("t")).await.expect("delete");
945 })
946 .await;
947 }
948 store.prune_empty_tenants().await;
949 assert_eq!(store.tenant_count().await, 0, "now the slots come back");
950
951 let admitted = TenantContext::scope("legitimate", async {
952 store.save(&make_task("t", TaskState::Working)).await
953 })
954 .await;
955 assert!(admitted.is_ok(), "and the legitimate tenant gets in");
956 }
957
958 #[tokio::test]
959 async fn tenant_count_reflects_active_tenants() {
960 let store = TenantAwareInMemoryTaskStore::new();
961 assert_eq!(store.tenant_count().await, 0);
962
963 TenantContext::scope("a", async {
964 store
965 .save(&make_task("t1", TaskState::Submitted))
966 .await
967 .unwrap();
968 })
969 .await;
970 assert_eq!(store.tenant_count().await, 1);
971
972 TenantContext::scope("b", async {
973 store
974 .save(&make_task("t2", TaskState::Submitted))
975 .await
976 .unwrap();
977 })
978 .await;
979 assert_eq!(store.tenant_count().await, 2);
980 }
981
982 #[tokio::test]
983 async fn max_tenants_limit_enforced() {
984 let config = TenantStoreConfig {
985 per_tenant: TaskStoreConfig::default(),
986 max_tenants: 2,
987 };
988 let store = TenantAwareInMemoryTaskStore::with_config(config);
989
990 TenantContext::scope("t1", async {
992 store
993 .save(&make_task("task-a", TaskState::Submitted))
994 .await
995 .unwrap();
996 })
997 .await;
998 TenantContext::scope("t2", async {
999 store
1000 .save(&make_task("task-b", TaskState::Submitted))
1001 .await
1002 .unwrap();
1003 })
1004 .await;
1005
1006 let result = TenantContext::scope("t3", async {
1008 store.save(&make_task("task-c", TaskState::Submitted)).await
1009 })
1010 .await;
1011 assert!(
1012 result.is_err(),
1013 "exceeding max_tenants should return an error"
1014 );
1015 }
1016
1017 #[tokio::test]
1018 async fn existing_tenant_does_not_count_against_limit() {
1019 let config = TenantStoreConfig {
1020 per_tenant: TaskStoreConfig::default(),
1021 max_tenants: 1,
1022 };
1023 let store = TenantAwareInMemoryTaskStore::with_config(config);
1024
1025 TenantContext::scope("only", async {
1026 store
1027 .save(&make_task("t1", TaskState::Submitted))
1028 .await
1029 .unwrap();
1030 store
1032 .save(&make_task("t2", TaskState::Working))
1033 .await
1034 .unwrap();
1035 })
1036 .await;
1037
1038 let count = TenantContext::scope("only", async { store.count().await.unwrap() }).await;
1039 assert_eq!(count, 2, "existing tenant can add more tasks");
1040 }
1041
1042 #[tokio::test]
1045 async fn no_tenant_context_uses_default_partition() {
1046 let store = TenantAwareInMemoryTaskStore::new();
1047
1048 store
1050 .save(&make_task("default-task", TaskState::Submitted))
1051 .await
1052 .unwrap();
1053
1054 let fetched = store.get(&TaskId::new("default-task")).await.unwrap();
1055 assert!(
1056 fetched.is_some(),
1057 "task saved without tenant context should be retrievable without context"
1058 );
1059
1060 let not_found = TenantContext::scope("other", async {
1062 store.get(&TaskId::new("default-task")).await.unwrap()
1063 })
1064 .await;
1065 assert!(
1066 not_found.is_none(),
1067 "default partition task should not leak to named tenants"
1068 );
1069 }
1070
1071 #[tokio::test]
1074 async fn prune_empty_tenants_removes_empty_partitions() {
1075 let store = TenantAwareInMemoryTaskStore::new();
1076
1077 TenantContext::scope("keep", async {
1078 store
1079 .save(&make_task("t1", TaskState::Submitted))
1080 .await
1081 .unwrap();
1082 })
1083 .await;
1084 TenantContext::scope("remove", async {
1085 store
1086 .save(&make_task("t2", TaskState::Submitted))
1087 .await
1088 .unwrap();
1089 })
1090 .await;
1091 assert_eq!(store.tenant_count().await, 2);
1092
1093 TenantContext::scope("remove", async {
1095 store.delete(&TaskId::new("t2")).await.unwrap();
1096 })
1097 .await;
1098
1099 store.prune_empty_tenants().await;
1100 assert_eq!(
1101 store.tenant_count().await,
1102 1,
1103 "empty tenant partition should be pruned"
1104 );
1105 }
1106
1107 #[tokio::test]
1116 async fn a_partition_still_holding_idempotency_keys_is_not_pruned() {
1117 let store = TenantAwareInMemoryTaskStore::new();
1118
1119 TenantContext::scope("keyed", async {
1120 store
1121 .save(&make_task("t1", TaskState::Submitted))
1122 .await
1123 .unwrap();
1124 store
1125 .claim_idempotency_key(
1126 "8f14e45fceea167a5a36dedd4bea2543",
1127 &a2a_protocol_types::message::MessageId::new("msg-1"),
1128 &TaskId::new("t1"),
1129 )
1130 .await
1131 .unwrap();
1132 store.delete(&TaskId::new("t1")).await.unwrap();
1135 assert_eq!(
1136 store.count().await.unwrap(),
1137 0,
1138 "the partition holds no task, which is what used to decide this"
1139 );
1140 })
1141 .await;
1142 assert_eq!(store.tenant_count().await, 1);
1143
1144 store.prune_empty_tenants().await;
1145 assert_eq!(
1146 store.tenant_count().await,
1147 1,
1148 "a partition holding a live idempotency key must survive the prune"
1149 );
1150
1151 let claim = TenantContext::scope("keyed", async {
1153 store
1154 .claim_idempotency_key(
1155 "8f14e45fceea167a5a36dedd4bea2543",
1156 &a2a_protocol_types::message::MessageId::new("msg-1"),
1157 &TaskId::new("t2"),
1158 )
1159 .await
1160 .unwrap()
1161 })
1162 .await;
1163 assert!(
1164 matches!(
1165 claim,
1166 crate::store::task_store::IdempotencyClaim::Replay(ref id) if id.0 == "t1"
1167 ),
1168 "the surviving key must still name the first task, got {claim:?}"
1169 );
1170 }
1171
1172 #[tokio::test]
1175 async fn a_partition_holding_neither_tasks_nor_keys_is_still_pruned() {
1176 let store = TenantAwareInMemoryTaskStore::new();
1177
1178 TenantContext::scope("transient", async {
1179 store
1180 .save(&make_task("t1", TaskState::Submitted))
1181 .await
1182 .unwrap();
1183 store.delete(&TaskId::new("t1")).await.unwrap();
1184 })
1185 .await;
1186 assert_eq!(store.tenant_count().await, 1);
1187
1188 store.prune_empty_tenants().await;
1189 assert_eq!(
1190 store.tenant_count().await,
1191 0,
1192 "a genuinely empty partition must still give its slot back"
1193 );
1194 }
1195
1196 #[test]
1200 fn default_creates_new_tenant_store() {
1201 let store = TenantAwareInMemoryTaskStore::default();
1202 let rt = tokio::runtime::Builder::new_current_thread()
1203 .enable_all()
1204 .build()
1205 .unwrap();
1206 let count = rt.block_on(store.tenant_count());
1207 assert_eq!(count, 0, "default store should have no tenants");
1208 }
1209
1210 #[tokio::test]
1212 async fn run_eviction_all_runs_without_error() {
1213 let store = TenantAwareInMemoryTaskStore::new();
1214
1215 TenantContext::scope("t1", async {
1217 store
1218 .save(&make_task("task-a", TaskState::Completed))
1219 .await
1220 .unwrap();
1221 })
1222 .await;
1223 TenantContext::scope("t2", async {
1224 store
1225 .save(&make_task("task-b", TaskState::Working))
1226 .await
1227 .unwrap();
1228 })
1229 .await;
1230
1231 store.run_eviction_all().await;
1233 }
1234
1235 #[tokio::test]
1239 async fn get_store_double_check_path() {
1240 let store = TenantAwareInMemoryTaskStore::new();
1241
1242 TenantContext::scope("racer", async {
1244 store
1245 .save(&make_task("t1", TaskState::Submitted))
1246 .await
1247 .unwrap();
1248 store
1250 .save(&make_task("t2", TaskState::Working))
1251 .await
1252 .unwrap();
1253
1254 let count = store.count().await.unwrap();
1255 assert_eq!(count, 2, "both tasks should be in same tenant store");
1256 })
1257 .await;
1258
1259 assert_eq!(
1260 store.tenant_count().await,
1261 1,
1262 "should have exactly 1 tenant"
1263 );
1264 }
1265
1266 #[test]
1267 fn default_tenant_store_config() {
1268 let cfg = TenantStoreConfig::default();
1269 assert_eq!(cfg.max_tenants, 1000);
1270 }
1271
1272 #[tokio::test]
1284 async fn run_eviction_all_evicts_in_every_tenant() {
1285 let store = TenantAwareInMemoryTaskStore::with_config(TenantStoreConfig {
1286 per_tenant: TaskStoreConfig {
1287 task_ttl: Some(std::time::Duration::from_millis(1)),
1288 ..TaskStoreConfig::default()
1289 },
1290 ..TenantStoreConfig::default()
1291 });
1292
1293 for tenant in ["tenant-a", "tenant-b"] {
1294 TenantContext::scope(tenant, async {
1295 store
1296 .save(&make_task("t1", TaskState::Completed))
1297 .await
1298 .expect("save");
1299 })
1300 .await;
1301 }
1302
1303 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1305 store.run_eviction_all().await;
1306
1307 for tenant in ["tenant-a", "tenant-b"] {
1308 let still_there = TenantContext::scope(tenant, async {
1309 store.get(&TaskId::new("t1")).await.expect("get")
1310 })
1311 .await;
1312 assert!(
1313 still_there.is_none(),
1314 "a terminal task past its TTL must be evicted in {tenant}; \
1315 surviving means run_eviction_all did not reach this partition"
1316 );
1317 }
1318 }
1319
1320 #[test]
1326 fn in_memory_tenant_store_advertises_idempotency_support() {
1327 let store = TenantAwareInMemoryTaskStore::new();
1328 assert!(
1329 store.supports_idempotency(),
1330 "the tenant-aware in-memory store partitions by tenant, which is \
1331 exactly the scope a key needs; it must advertise support"
1332 );
1333 }
1334
1335 #[test]
1336 fn every_tenant_store_config_setter_sets_its_field() {
1337 let d = TenantStoreConfig::default();
1338 let per_tenant =
1339 TaskStoreConfig::default().with_max_page_size(d.per_tenant.max_page_size + 1);
1340 let cfg = TenantStoreConfig::default()
1341 .with_per_tenant(per_tenant)
1342 .with_max_tenants(d.max_tenants + 1);
1343 assert_eq!(cfg.per_tenant.max_page_size, d.per_tenant.max_page_size + 1);
1344 assert_eq!(cfg.max_tenants, d.max_tenants + 1);
1345 }
1346}
1347
1348#[cfg(test)]
1349mod idempotency_isolation_tests {
1350 use super::*;
1351 use crate::store::task_store::IdempotencyClaim;
1352 use a2a_protocol_types::message::MessageId;
1353
1354 const KEY: &str = "8f14e45fceea167a5a36dedd4bea2543";
1355
1356 #[tokio::test]
1357 async fn one_tenants_key_never_names_another_tenants_task() {
1358 let store = TenantAwareInMemoryTaskStore::new();
1362
1363 let a = TenantContext::scope("tenant-a", async {
1364 store
1365 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1366 .await
1367 .unwrap()
1368 })
1369 .await;
1370 assert_eq!(a, IdempotencyClaim::Claimed);
1371
1372 let b = TenantContext::scope("tenant-b", async {
1373 store
1374 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-b"))
1375 .await
1376 .unwrap()
1377 })
1378 .await;
1379 assert_eq!(
1380 b,
1381 IdempotencyClaim::Claimed,
1382 "tenant-b must claim its own key, not replay tenant-a's task"
1383 );
1384 }
1385
1386 #[tokio::test]
1387 async fn a_retry_within_one_tenant_still_replays() {
1388 let store = TenantAwareInMemoryTaskStore::new();
1389 TenantContext::scope("tenant-a", async {
1390 let first = store
1391 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-a"))
1392 .await
1393 .unwrap();
1394 assert_eq!(first, IdempotencyClaim::Claimed);
1395
1396 let retry = store
1397 .claim_idempotency_key(KEY, &MessageId::new("m1"), &TaskId::new("task-other"))
1398 .await
1399 .unwrap();
1400 assert_eq!(retry, IdempotencyClaim::Replay(TaskId::new("task-a")));
1401 })
1402 .await;
1403 }
1404
1405 #[tokio::test]
1406 async fn releasing_under_an_unknown_tenant_creates_no_partition() {
1407 let store = TenantAwareInMemoryTaskStore::new();
1410 TenantContext::scope("never-seen", async {
1411 store.release_idempotency_key(KEY).await.unwrap();
1412 })
1413 .await;
1414 assert_eq!(store.tenant_count().await, 0);
1415 }
1416}
1417
1418#[cfg(test)]
1419mod event_log_tests {
1420 use super::*;
1421 use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
1422 use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
1423
1424 fn make_task(id: &str, state: TaskState) -> Task {
1425 Task {
1426 id: TaskId::new(id),
1427 context_id: ContextId::new("ctx-default"),
1428 status: TaskStatus::new(state),
1429 history: None,
1430 artifacts: None,
1431 metadata: None,
1432 }
1433 }
1434
1435 fn event(id: &str, state: TaskState) -> StreamResponse {
1436 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
1437 task_id: TaskId::new(id),
1438 context_id: ContextId::new("ctx-default"),
1439 status: TaskStatus::new(state),
1440 metadata: None,
1441 })
1442 }
1443
1444 #[tokio::test]
1445 async fn the_dispatcher_reports_that_it_keeps_a_log() {
1446 assert!(TenantAwareInMemoryTaskStore::new().supports_event_log());
1447 }
1448
1449 #[tokio::test]
1453 async fn one_tenants_task_id_never_reaches_another_tenants_log() {
1454 let store = TenantAwareInMemoryTaskStore::new();
1455
1456 TenantContext::scope("tenant-a", async {
1457 store
1458 .save(&make_task("shared", TaskState::Working))
1459 .await
1460 .unwrap();
1461 store
1462 .append_event(
1463 &TaskId::new("shared"),
1464 1,
1465 &event("shared", TaskState::Working),
1466 )
1467 .await
1468 .unwrap();
1469 })
1470 .await;
1471
1472 TenantContext::scope("tenant-b", async {
1473 store
1474 .save(&make_task("shared", TaskState::Working))
1475 .await
1476 .unwrap();
1477 assert_eq!(
1478 store.last_event_seq(&TaskId::new("shared")).await.unwrap(),
1479 0
1480 );
1481 assert!(
1482 store
1483 .read_events(&TaskId::new("shared"), 0, 10)
1484 .await
1485 .unwrap()
1486 .is_empty(),
1487 "tenant-b must not be handed tenant-a's events"
1488 );
1489 })
1490 .await;
1491
1492 TenantContext::scope("tenant-a", async {
1493 assert_eq!(
1494 store.last_event_seq(&TaskId::new("shared")).await.unwrap(),
1495 1
1496 );
1497 })
1498 .await;
1499 }
1500
1501 #[tokio::test]
1505 async fn reading_under_an_unknown_tenant_creates_no_partition() {
1506 let store = TenantAwareInMemoryTaskStore::new();
1507 TenantContext::scope("never-seen", async {
1508 assert_eq!(store.last_event_seq(&TaskId::new("t1")).await.unwrap(), 0);
1509 assert!(
1510 store
1511 .read_events(&TaskId::new("t1"), 0, 10)
1512 .await
1513 .unwrap()
1514 .is_empty()
1515 );
1516 })
1517 .await;
1518 assert_eq!(store.tenant_count().await, 0);
1519 }
1520}