a2a_protocol_server/push/
tenant_config_store.rs1use std::collections::HashMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use a2a_protocol_types::error::A2aResult;
17use a2a_protocol_types::push::TaskPushNotificationConfig;
18use tokio::sync::RwLock;
19
20use super::config_store::{InMemoryPushConfigStore, PushConfigStore};
21use crate::store::tenant::TenantContext;
22
23#[derive(Debug)]
45pub struct TenantAwareInMemoryPushConfigStore {
46 stores: RwLock<HashMap<String, Arc<InMemoryPushConfigStore>>>,
47 max_tenants: usize,
48 max_configs_per_task: usize,
49}
50
51impl Default for TenantAwareInMemoryPushConfigStore {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl TenantAwareInMemoryPushConfigStore {
58 #[must_use]
60 pub fn new() -> Self {
61 Self {
62 stores: RwLock::new(HashMap::new()),
63 max_tenants: 1000,
64 max_configs_per_task: 100,
65 }
66 }
67
68 #[must_use]
70 pub fn with_limits(max_tenants: usize, max_configs_per_task: usize) -> Self {
71 Self {
72 stores: RwLock::new(HashMap::new()),
73 max_tenants,
74 max_configs_per_task,
75 }
76 }
77
78 async fn get_store(&self) -> A2aResult<Arc<InMemoryPushConfigStore>> {
80 let tenant = TenantContext::current();
81
82 {
83 let stores = self.stores.read().await;
84 if let Some(store) = stores.get(&tenant) {
85 return Ok(Arc::clone(store));
86 }
87 }
88
89 let mut stores = self.stores.write().await;
90 if let Some(store) = stores.get(&tenant) {
91 return Ok(Arc::clone(store));
92 }
93
94 if stores.len() >= self.max_tenants {
95 return Err(a2a_protocol_types::error::A2aError::internal(format!(
96 "tenant limit exceeded: max {} tenants",
97 self.max_tenants
98 )));
99 }
100
101 let store = Arc::new(InMemoryPushConfigStore::with_max_configs_per_task(
102 self.max_configs_per_task,
103 ));
104 stores.insert(tenant, Arc::clone(&store));
105 drop(stores);
106 Ok(store)
107 }
108
109 async fn get_existing_store(&self) -> Option<Arc<InMemoryPushConfigStore>> {
116 let tenant = TenantContext::current();
117 self.stores.read().await.get(&tenant).map(Arc::clone)
118 }
119
120 pub async fn tenant_count(&self) -> usize {
122 self.stores.read().await.len()
123 }
124}
125
126#[allow(clippy::manual_async_fn)]
127impl PushConfigStore for TenantAwareInMemoryPushConfigStore {
128 fn set<'a>(
129 &'a self,
130 config: TaskPushNotificationConfig,
131 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
132 Box::pin(async move {
133 let store = self.get_store().await?;
134 store.set(config).await
135 })
136 }
137
138 fn get<'a>(
139 &'a self,
140 task_id: &'a str,
141 id: &'a str,
142 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
143 {
144 Box::pin(async move {
145 match self.get_existing_store().await {
147 Some(store) => store.get(task_id, id).await,
148 None => Ok(None),
149 }
150 })
151 }
152
153 fn list<'a>(
154 &'a self,
155 task_id: &'a str,
156 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
157 Box::pin(async move {
158 match self.get_existing_store().await {
160 Some(store) => store.list(task_id).await,
161 None => Ok(Vec::new()),
162 }
163 })
164 }
165
166 fn delete<'a>(
167 &'a self,
168 task_id: &'a str,
169 id: &'a str,
170 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
171 Box::pin(async move {
172 match self.get_existing_store().await {
175 Some(store) => store.delete(task_id, id).await,
176 None => Ok(()),
177 }
178 })
179 }
180
181 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
182 Box::pin(async move {
183 match self.get_existing_store().await {
186 Some(store) => store.count().await,
187 None => Ok(Some(0)),
188 }
189 })
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use a2a_protocol_types::push::TaskPushNotificationConfig;
197
198 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
199 TaskPushNotificationConfig {
200 tenant: None,
201 id: id.map(String::from),
202 task_id: Some(task_id.to_string()),
203 url: url.to_string(),
204 token: None,
205 authentication: None,
206 }
207 }
208
209 #[tokio::test]
210 async fn new_store_has_zero_tenants() {
211 let store = TenantAwareInMemoryPushConfigStore::new();
212 assert_eq!(
213 store.tenant_count().await,
214 0,
215 "new store should have no tenants"
216 );
217 }
218
219 #[tokio::test]
220 async fn set_and_get_within_tenant_scope() {
221 let store = TenantAwareInMemoryPushConfigStore::new();
222 TenantContext::scope("tenant-a", async {
223 store
224 .set(make_config("task-1", Some("cfg-1"), "https://a.com/hook"))
225 .await
226 .expect("set should succeed");
227
228 let config = store
229 .get("task-1", "cfg-1")
230 .await
231 .expect("get should succeed")
232 .expect("config should exist");
233 assert_eq!(config.url, "https://a.com/hook");
234 })
235 .await;
236 }
237
238 #[tokio::test]
239 async fn tenant_isolation() {
240 let store = TenantAwareInMemoryPushConfigStore::new();
241
242 TenantContext::scope("tenant-a", async {
244 store
245 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
246 .await
247 .unwrap();
248 })
249 .await;
250
251 TenantContext::scope("tenant-b", async {
253 let result = store.get("task-1", "cfg-1").await.unwrap();
254 assert!(
255 result.is_none(),
256 "tenant-b should not see tenant-a's config"
257 );
258 })
259 .await;
260
261 TenantContext::scope("tenant-a", async {
263 let result = store.get("task-1", "cfg-1").await.unwrap();
264 assert!(result.is_some(), "tenant-a should still see its own config");
265 })
266 .await;
267 }
268
269 #[tokio::test]
275 async fn count_is_per_tenant_and_zero_for_an_unseen_one() {
276 let store = TenantAwareInMemoryPushConfigStore::new();
277 TenantContext::scope("tenant-a", async {
278 for task in ["t1", "t2"] {
279 store
280 .set(make_config(task, None, "https://a.com"))
281 .await
282 .unwrap();
283 }
284 })
285 .await;
286 TenantContext::scope("tenant-b", async {
287 store
288 .set(make_config("t1", None, "https://b.com"))
289 .await
290 .unwrap();
291 })
292 .await;
293
294 let a = TenantContext::scope("tenant-a", store.count())
295 .await
296 .unwrap();
297 let b = TenantContext::scope("tenant-b", store.count())
298 .await
299 .unwrap();
300 let unseen = TenantContext::scope("tenant-c", store.count())
301 .await
302 .unwrap();
303 assert_eq!((a, b, unseen), (Some(2), Some(1), Some(0)));
304 assert_eq!(
305 store.tenant_count().await,
306 2,
307 "counting allocated no partition"
308 );
309 }
310
311 #[tokio::test]
312 async fn tenant_count_tracks_distinct_tenants() {
313 let store = TenantAwareInMemoryPushConfigStore::new();
314
315 TenantContext::scope("tenant-a", async {
316 store
317 .set(make_config("t1", Some("c1"), "https://a.com"))
318 .await
319 .unwrap();
320 })
321 .await;
322 assert_eq!(store.tenant_count().await, 1);
323
324 TenantContext::scope("tenant-b", async {
325 store
326 .set(make_config("t1", Some("c1"), "https://b.com"))
327 .await
328 .unwrap();
329 })
330 .await;
331 assert_eq!(store.tenant_count().await, 2);
332
333 TenantContext::scope("tenant-a", async {
335 store
336 .set(make_config("t2", Some("c2"), "https://a2.com"))
337 .await
338 .unwrap();
339 })
340 .await;
341 assert_eq!(
342 store.tenant_count().await,
343 2,
344 "re-using an existing tenant should not increase count"
345 );
346 }
347
348 #[tokio::test]
349 async fn with_limits_enforces_max_tenants() {
350 let store = TenantAwareInMemoryPushConfigStore::with_limits(1, 100);
351
352 TenantContext::scope("tenant-a", async {
353 store
354 .set(make_config("t1", Some("c1"), "https://a.com"))
355 .await
356 .unwrap();
357 })
358 .await;
359
360 let err = TenantContext::scope("tenant-b", async {
361 store
362 .set(make_config("t1", Some("c1"), "https://b.com"))
363 .await
364 })
365 .await
366 .expect_err("second tenant should exceed max_tenants limit");
367
368 let msg = format!("{err}");
369 assert!(
370 msg.contains("tenant limit exceeded"),
371 "error should mention tenant limit, got: {msg}"
372 );
373 }
374
375 #[tokio::test]
376 async fn with_limits_enforces_per_task_config_limit() {
377 let store = TenantAwareInMemoryPushConfigStore::with_limits(100, 1);
378
379 let err = TenantContext::scope("tenant-a", async {
380 store
381 .set(make_config("t1", Some("c1"), "https://a.com"))
382 .await
383 .unwrap();
384 store
385 .set(make_config("t1", Some("c2"), "https://b.com"))
386 .await
387 })
388 .await
389 .expect_err("second config should exceed per-task limit");
390
391 let msg = format!("{err}");
392 assert!(
393 msg.contains("limit exceeded"),
394 "error should mention limit exceeded, got: {msg}"
395 );
396 }
397
398 #[tokio::test]
399 async fn list_scoped_to_tenant() {
400 let store = TenantAwareInMemoryPushConfigStore::new();
401
402 TenantContext::scope("tenant-a", async {
403 store
404 .set(make_config("t1", Some("c1"), "https://a.com/1"))
405 .await
406 .unwrap();
407 store
408 .set(make_config("t1", Some("c2"), "https://a.com/2"))
409 .await
410 .unwrap();
411 })
412 .await;
413
414 TenantContext::scope("tenant-b", async {
415 store
416 .set(make_config("t1", Some("c3"), "https://b.com/1"))
417 .await
418 .unwrap();
419 })
420 .await;
421
422 let a_list =
423 TenantContext::scope("tenant-a", async { store.list("t1").await.unwrap() }).await;
424 assert_eq!(a_list.len(), 2, "tenant-a should see 2 configs for task t1");
425
426 let b_list =
427 TenantContext::scope("tenant-b", async { store.list("t1").await.unwrap() }).await;
428 assert_eq!(b_list.len(), 1, "tenant-b should see 1 config for task t1");
429 }
430
431 #[tokio::test]
432 async fn delete_scoped_to_tenant() {
433 let store = TenantAwareInMemoryPushConfigStore::new();
434
435 TenantContext::scope("tenant-a", async {
437 store
438 .set(make_config("t1", Some("c1"), "https://a.com"))
439 .await
440 .unwrap();
441 })
442 .await;
443 TenantContext::scope("tenant-b", async {
444 store
445 .set(make_config("t1", Some("c1"), "https://b.com"))
446 .await
447 .unwrap();
448 })
449 .await;
450
451 TenantContext::scope("tenant-a", async {
453 store.delete("t1", "c1").await.unwrap();
454 })
455 .await;
456
457 let a_result =
459 TenantContext::scope("tenant-a", async { store.get("t1", "c1").await.unwrap() }).await;
460 assert!(a_result.is_none(), "tenant-a config should be deleted");
461
462 let b_result =
464 TenantContext::scope("tenant-b", async { store.get("t1", "c1").await.unwrap() }).await;
465 assert!(
466 b_result.is_some(),
467 "tenant-b config should be unaffected by tenant-a's delete"
468 );
469 }
470
471 #[test]
474 fn default_impl_creates_empty_store() {
475 let store = TenantAwareInMemoryPushConfigStore::default();
476 assert_eq!(store.max_tenants, 1000);
477 assert_eq!(store.max_configs_per_task, 100);
478 }
479
480 #[tokio::test]
481 async fn default_is_same_as_new() {
482 let store = TenantAwareInMemoryPushConfigStore::default();
483 assert_eq!(store.tenant_count().await, 0);
484 TenantContext::scope("t", async {
486 store
487 .set(make_config("t1", Some("c1"), "https://x.com"))
488 .await
489 .unwrap();
490 })
491 .await;
492 assert_eq!(store.tenant_count().await, 1);
493 }
494}