armature_cache/invalidation.rs
1//! Tag-based cache invalidation
2
3use crate::error::{CacheError, CacheResult};
4use crate::traits::CacheStore;
5use std::collections::HashSet;
6use std::sync::Arc;
7use std::time::Duration;
8
9/// Cache with tag-based invalidation support.
10///
11/// The tag -> member-key index (and its reverse, key -> tags) is persisted in
12/// the backing [`CacheStore`] itself under reserved keys — it is NOT kept in
13/// a local, per-process map. That means the index is visible to every
14/// instance sharing the same backing store (e.g. every app process pointed at
15/// the same Redis), so a key tagged by one instance can be looked up and
16/// invalidated by another.
17///
18/// # Atomicity caveat (concurrent race)
19///
20/// Index updates go through [`CacheStore::set_add`] / [`CacheStore::set_remove`] /
21/// [`CacheStore::set_members`]. Only backends that override these with a
22/// native set type — `RedisCache` does, via `SADD`/`SREM`/`SMEMBERS` — update
23/// the index atomically (see [`CacheStore::supports_atomic_sets`]). Other
24/// backends (e.g. `InMemoryCache`, `MemcachedCache`) fall back to the
25/// trait's default, non-atomic read-modify-write, so concurrent
26/// `set_with_tags`/`invalidate_tag` calls against the SAME tag from
27/// different instances can race and lose an update. For distributed
28/// deployments, wrap a `RedisCache` (or another backend that overrides the
29/// set primitives) if you need that guarantee. [`Self::new`] logs a warning
30/// once, at construction time, when the backing store doesn't support
31/// atomic sets.
32///
33/// # Partial-failure caveat (sequential, non-transactional updates)
34///
35/// Separately from the concurrent-race caveat above: [`Self::set_with_tags`],
36/// [`Self::delete`], and [`Self::invalidate_tags`] each issue several
37/// independent `set_add`/`set_remove`/`delete` calls in sequence. If an
38/// early call in one of these sequences succeeds and a later one fails, the
39/// tag index (and its reverse index) can end up **partially updated** —
40/// inconsistent with the cached value, or inconsistent with itself (e.g. a
41/// key's reverse-index tag set can end up not matching the forward tag ->
42/// keys sets it's actually a member of). There is currently no automatic
43/// rollback or reconciliation for this case: a failed call may need to be
44/// retried or the affected tag(s)/key(s) reconciled manually. A best-effort
45/// warning is logged when this happens (see the calls guarded by
46/// `warn_on_partial_failure` in the implementation) so operators at least
47/// get a signal, but the index itself is not repaired automatically.
48///
49/// # Reserved key namespace
50///
51/// The tag index's bookkeeping keys (`tag_set_key`/`key_tags_set_key`/
52/// `TAG_INDEX_KEY`) all begin with the reserved prefix
53/// `"__armature_"` and live in the SAME keyspace as caller-supplied keys —
54/// both go through the same backing [`CacheStore`], with only `key_prefix`
55/// (from `CacheConfig`) applied identically to both. A caller-supplied key
56/// that happens to start with `"__armature_"` would therefore collide with
57/// this reserved bookkeeping keyspace (e.g. writing to
58/// `__armature_tag__:users` would clobber the "users" tag's member set).
59/// This prefix is forbidden for caller-supplied keys: [`Self::set_with_tags`],
60/// [`Self::get`], and [`Self::delete`] all reject a key starting with
61/// `"__armature_"` with a `CacheError::Config`, rather than silently
62/// allowing the collision.
63pub struct TaggedCache<C: CacheStore> {
64 /// Underlying cache store. Tag bookkeeping lives here too (see
65 /// `tag_set_key`/`key_tags_set_key`/`TAG_INDEX_KEY`), not in a local map.
66 cache: Arc<C>,
67}
68
69impl<C: CacheStore> TaggedCache<C> {
70 /// Reserved key holding a tag's member-key set (`tag -> {keys}`).
71 fn tag_set_key(tag: &str) -> String {
72 format!("__armature_tag__:{tag}")
73 }
74
75 /// Reserved key holding a key's tag set (`key -> {tags}`).
76 fn key_tags_set_key(key: &str) -> String {
77 format!("__armature_keytags__:{key}")
78 }
79
80 /// Reserved key holding the set of every tag name that currently has at
81 /// least one member (backs [`Self::list_tags`]).
82 const TAG_INDEX_KEY: &'static str = "__armature_tag_index__";
83
84 /// Prefix reserved for `TaggedCache`'s own tag-bookkeeping keys — every
85 /// key produced by [`Self::tag_set_key`], [`Self::key_tags_set_key`], and
86 /// [`Self::TAG_INDEX_KEY`] starts with it. Forbidden for caller-supplied
87 /// keys; see [`Self::validate_caller_key`] and the struct-level
88 /// "Reserved key namespace" docs.
89 const RESERVED_KEY_PREFIX: &'static str = "__armature_";
90
91 /// Reject a caller-supplied key that collides with `TaggedCache`'s
92 /// reserved bookkeeping keyspace (see [`Self::RESERVED_KEY_PREFIX`]).
93 ///
94 /// Called at the top of every `TaggedCache` method that accepts a raw,
95 /// caller-supplied key ([`Self::set_with_tags`], [`Self::get`],
96 /// [`Self::delete`]) so a key that happens to start with
97 /// `"__armature_"` is rejected with a clear `CacheError::Config` instead
98 /// of silently colliding with (and potentially corrupting) the tag
99 /// index's own reserved keys.
100 fn validate_caller_key(key: &str) -> CacheResult<()> {
101 if key.starts_with(Self::RESERVED_KEY_PREFIX) {
102 Err(CacheError::Config(format!(
103 "cache key {key:?} is reserved for TaggedCache's internal tag index \
104 (the {:?} prefix is forbidden for caller-supplied keys)",
105 Self::RESERVED_KEY_PREFIX
106 )))
107 } else {
108 Ok(())
109 }
110 }
111
112 /// Best-effort observability hook for the "Partial-failure caveat"
113 /// described on the struct docs: logs a warning when a step in a
114 /// multi-step index update ([`Self::set_with_tags`], [`Self::delete`],
115 /// [`Self::invalidate_tags`]) fails after one or more earlier steps in
116 /// the same call already succeeded, since the tag index may now be left
117 /// partially updated with no automatic rollback.
118 fn warn_on_partial_failure(op: &str, err: &CacheError) {
119 armature_log::warn!(
120 "TaggedCache::{op} failed partway through a multi-step tag-index update; \
121 the tag index may now be inconsistent with the cached value or with itself \
122 (no automatic rollback): {err}"
123 );
124 }
125
126 /// Create new tagged cache
127 ///
128 /// Checks [`CacheStore::supports_atomic_sets`] on `cache` and logs a
129 /// warning once, here at construction time, when the backing store does
130 /// NOT support atomic sets — see the struct-level "Atomicity caveat"
131 /// docs for what that means for concurrent tag-index updates.
132 ///
133 /// # Examples
134 ///
135 /// ```rust,ignore
136 /// use armature_cache::*;
137 ///
138 /// let cache = RedisCache::new(config).await?;
139 /// let tagged = TaggedCache::new(Arc::new(cache));
140 /// ```
141 pub fn new(cache: Arc<C>) -> Self {
142 if !cache.supports_atomic_sets() {
143 armature_log::warn!(
144 "TaggedCache backing store does not support atomic set operations \
145 (SADD/SREM/SMEMBERS-equivalent); concurrent set_with_tags/invalidate_tag \
146 calls against the same tag from different instances can race and lose an \
147 update. Wrap a backend that overrides CacheStore::set_add/set_remove/\
148 set_members atomically (e.g. RedisCache) if you need that guarantee."
149 );
150 }
151 Self { cache }
152 }
153
154 /// Set a value with tags
155 ///
156 /// A repeated call for the same `key` REPLACES its tag membership with
157 /// `tags` (it does not union with whatever tags the key carried before).
158 ///
159 /// # Examples
160 ///
161 /// ```rust,ignore
162 /// tagged.set_with_tags(
163 /// "user:123",
164 /// user_json,
165 /// &["users", "active-users"],
166 /// Some(Duration::from_secs(3600)),
167 /// ).await?;
168 /// ```
169 pub async fn set_with_tags(
170 &self,
171 key: &str,
172 value: String,
173 tags: &[&str],
174 ttl: Option<Duration>,
175 ) -> CacheResult<()> {
176 Self::validate_caller_key(key)?;
177
178 // Set in cache
179 self.cache.set_json(key, value, ttl).await?;
180
181 // Replace this key's persisted tag membership: drop it from any
182 // previously associated tag that is no longer in `tags`, then (re)add
183 // it to the current set.
184 let previous_tags = self.get_tags_for_key(key).await?;
185 let new_tags: HashSet<String> = tags.iter().map(|t| t.to_string()).collect();
186 let key_tags_key = Self::key_tags_set_key(key);
187
188 for old_tag in &previous_tags {
189 if !new_tags.contains(old_tag) {
190 self.cache
191 .set_remove(&Self::tag_set_key(old_tag), key)
192 .await
193 .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
194 self.cache
195 .set_remove(&key_tags_key, old_tag)
196 .await
197 .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
198 self.prune_tag_index_if_empty(old_tag).await?;
199 }
200 }
201
202 for tag in &new_tags {
203 self.cache
204 .set_add(&Self::tag_set_key(tag), key)
205 .await
206 .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
207 self.cache
208 .set_add(&key_tags_key, tag)
209 .await
210 .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
211 self.cache
212 .set_add(Self::TAG_INDEX_KEY, tag)
213 .await
214 .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
215 }
216
217 Ok(())
218 }
219
220 /// Get value from cache
221 pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
222 Self::validate_caller_key(key)?;
223 self.cache.get_json(key).await
224 }
225
226 /// Delete a specific key
227 pub async fn delete(&self, key: &str) -> CacheResult<()> {
228 Self::validate_caller_key(key)?;
229
230 // Delete from cache
231 self.cache.delete(key).await?;
232
233 // Remove from the persisted tag mappings.
234 let key_tags_key = Self::key_tags_set_key(key);
235 let tags = self.cache.set_members(&key_tags_key).await?;
236
237 for tag in &tags {
238 self.cache
239 .set_remove(&Self::tag_set_key(tag), key)
240 .await
241 .inspect_err(|e| Self::warn_on_partial_failure("delete", e))?;
242 self.prune_tag_index_if_empty(tag).await?;
243 }
244 if !tags.is_empty() {
245 self.cache
246 .delete(&key_tags_key)
247 .await
248 .inspect_err(|e| Self::warn_on_partial_failure("delete", e))?;
249 }
250
251 Ok(())
252 }
253
254 /// Invalidate all keys with a specific tag
255 ///
256 /// # Examples
257 ///
258 /// ```rust,ignore
259 /// // Invalidate all user-related cache entries
260 /// tagged.invalidate_tag("users").await?;
261 /// ```
262 pub async fn invalidate_tag(&self, tag: &str) -> CacheResult<()> {
263 self.invalidate_tags(&[tag]).await
264 }
265
266 /// Invalidate all keys with any of the specified tags
267 ///
268 /// # Examples
269 ///
270 /// ```rust,ignore
271 /// // Invalidate all user and session data
272 /// tagged.invalidate_tags(&["users", "sessions"]).await?;
273 /// ```
274 pub async fn invalidate_tags(&self, tags: &[&str]) -> CacheResult<()> {
275 // Gather the union of keys across all requested tags, reading each
276 // tag's persisted member set from the backing store.
277 let mut victims: HashSet<String> = HashSet::new();
278 for &tag in tags {
279 let members = self.cache.set_members(&Self::tag_set_key(tag)).await?;
280 victims.extend(members);
281 }
282
283 // One batch delete for the whole union — coalesced into a single
284 // backend round-trip regardless of how many tags were requested.
285 if !victims.is_empty() {
286 let key_refs: Vec<&str> = victims.iter().map(|s| s.as_str()).collect();
287 self.cache
288 .delete_many(&key_refs)
289 .await
290 .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
291 }
292
293 // Update each victim's reverse index: drop only the tags being
294 // invalidated (a key may carry tags beyond those requested).
295 let removed: HashSet<&str> = tags.iter().copied().collect();
296 for key in &victims {
297 let key_tags_key = Self::key_tags_set_key(key);
298 let current_tags = self.cache.set_members(&key_tags_key).await?;
299 for tag in current_tags.iter().filter(|t| removed.contains(t.as_str())) {
300 self.cache
301 .set_remove(&key_tags_key, tag)
302 .await
303 .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
304 }
305 }
306
307 // Drop the invalidated tags' member sets entirely and prune them from
308 // the tag index, rather than leaving emptied sets behind.
309 for &tag in tags {
310 self.cache
311 .delete(&Self::tag_set_key(tag))
312 .await
313 .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
314 self.cache
315 .set_remove(Self::TAG_INDEX_KEY, tag)
316 .await
317 .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
318 }
319
320 Ok(())
321 }
322
323 /// Get all keys with a specific tag
324 pub async fn get_keys_by_tag(&self, tag: &str) -> CacheResult<Vec<String>> {
325 self.cache.set_members(&Self::tag_set_key(tag)).await
326 }
327
328 /// Get all tags for a specific key
329 pub async fn get_tags_for_key(&self, key: &str) -> CacheResult<Vec<String>> {
330 self.cache.set_members(&Self::key_tags_set_key(key)).await
331 }
332
333 /// Get all registered tags (tags currently carrying at least one member).
334 pub async fn list_tags(&self) -> CacheResult<Vec<String>> {
335 self.cache.set_members(Self::TAG_INDEX_KEY).await
336 }
337
338 /// Drop `tag` from the global tag index once it has no members left.
339 async fn prune_tag_index_if_empty(&self, tag: &str) -> CacheResult<()> {
340 let members = self.cache.set_members(&Self::tag_set_key(tag)).await?;
341 if members.is_empty() {
342 self.cache.set_remove(Self::TAG_INDEX_KEY, tag).await?;
343 }
344 Ok(())
345 }
346}
347
348impl<C: CacheStore> Clone for TaggedCache<C> {
349 fn clone(&self) -> Self {
350 Self {
351 cache: self.cache.clone(),
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::error::CacheResult;
360 use async_trait::async_trait;
361 use std::collections::HashMap;
362 use tokio::sync::RwLock;
363
364 use std::sync::atomic::{AtomicUsize, Ordering};
365
366 // Mock cache for testing. Counts batch-delete round-trips so tests can
367 // assert that `invalidate_tags` coalesces into a single backend call.
368 #[derive(Clone)]
369 struct MockCache {
370 data: Arc<RwLock<HashMap<String, String>>>,
371 mdel_calls: Arc<AtomicUsize>,
372 }
373
374 impl MockCache {
375 fn new() -> Self {
376 Self {
377 data: Arc::new(RwLock::new(HashMap::new())),
378 mdel_calls: Arc::new(AtomicUsize::new(0)),
379 }
380 }
381
382 fn mdel_calls(&self) -> usize {
383 self.mdel_calls.load(Ordering::Relaxed)
384 }
385 }
386
387 #[async_trait]
388 impl CacheStore for MockCache {
389 async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
390 Ok(self.data.read().await.get(key).cloned())
391 }
392
393 async fn set_json(
394 &self,
395 key: &str,
396 value: String,
397 _ttl: Option<Duration>,
398 ) -> CacheResult<()> {
399 self.data.write().await.insert(key.to_string(), value);
400 Ok(())
401 }
402
403 async fn delete(&self, key: &str) -> CacheResult<()> {
404 self.data.write().await.remove(key);
405 Ok(())
406 }
407
408 async fn exists(&self, key: &str) -> CacheResult<bool> {
409 Ok(self.data.read().await.contains_key(key))
410 }
411
412 async fn clear(&self) -> CacheResult<()> {
413 self.data.write().await.clear();
414 Ok(())
415 }
416
417 async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
418 self.mdel_calls.fetch_add(1, Ordering::Relaxed);
419 let mut data = self.data.write().await;
420 for key in keys {
421 data.remove(*key);
422 }
423 Ok(())
424 }
425
426 async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
427 Ok(None)
428 }
429
430 async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
431 Ok(())
432 }
433
434 async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
435 Ok(0)
436 }
437
438 async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
439 Ok(0)
440 }
441 }
442
443 #[tokio::test]
444 async fn test_tagged_cache() {
445 let cache = Arc::new(MockCache::new());
446 let tagged = TaggedCache::new(cache);
447
448 // Set with tags
449 tagged
450 .set_with_tags("user:1", "Alice".to_string(), &["users", "active"], None)
451 .await
452 .unwrap();
453
454 tagged
455 .set_with_tags("user:2", "Bob".to_string(), &["users"], None)
456 .await
457 .unwrap();
458
459 // Get value
460 let value = tagged.get("user:1").await.unwrap();
461 assert_eq!(value, Some("Alice".to_string()));
462
463 // Get keys by tag
464 let user_keys = tagged.get_keys_by_tag("users").await.unwrap();
465 assert_eq!(user_keys.len(), 2);
466
467 // Invalidate by tag
468 tagged.invalidate_tag("users").await.unwrap();
469
470 // Verify deletion
471 let value = tagged.get("user:1").await.unwrap();
472 assert_eq!(value, None);
473 }
474
475 #[tokio::test]
476 async fn test_multiple_tags() {
477 let cache = Arc::new(MockCache::new());
478 let tagged = TaggedCache::new(cache);
479
480 tagged
481 .set_with_tags("key1", "value1".to_string(), &["tag1", "tag2"], None)
482 .await
483 .unwrap();
484
485 let tags = tagged.get_tags_for_key("key1").await.unwrap();
486 assert_eq!(tags.len(), 2);
487
488 tagged.invalidate_tag("tag1").await.unwrap();
489
490 let value = tagged.get("key1").await.unwrap();
491 assert_eq!(value, None);
492 }
493
494 /// Regression: `invalidate_tags` must coalesce into a single backend batch
495 /// delete instead of one `delete_many` round-trip per tag.
496 #[tokio::test]
497 async fn test_invalidate_tags_coalesces_into_single_roundtrip() {
498 let cache = Arc::new(MockCache::new());
499 let tagged = TaggedCache::new(cache.clone());
500
501 // Distinct keys across three tags; "shared" carries two of them so we
502 // also verify a shared key is deleted exactly once.
503 tagged
504 .set_with_tags("k1", "a".to_string(), &["t1"], None)
505 .await
506 .unwrap();
507 tagged
508 .set_with_tags("k2", "b".to_string(), &["t2"], None)
509 .await
510 .unwrap();
511 tagged
512 .set_with_tags("shared", "c".to_string(), &["t1", "t3"], None)
513 .await
514 .unwrap();
515
516 tagged.invalidate_tags(&["t1", "t2", "t3"]).await.unwrap();
517
518 // All matching keys gone.
519 assert_eq!(tagged.get("k1").await.unwrap(), None);
520 assert_eq!(tagged.get("k2").await.unwrap(), None);
521 assert_eq!(tagged.get("shared").await.unwrap(), None);
522
523 // Exactly one batch delete round-trip, regardless of tag count.
524 assert_eq!(
525 cache.mdel_calls(),
526 1,
527 "invalidate_tags must issue a single coalesced batch delete"
528 );
529
530 // Reverse index fully cleaned up.
531 assert!(tagged.list_tags().await.unwrap().is_empty());
532 assert!(tagged.get_tags_for_key("shared").await.unwrap().is_empty());
533 }
534
535 /// Regression for Finding 2: the tag index must be visible across
536 /// independent `TaggedCache` instances that share the same backing store
537 /// — e.g. two app processes both wrapping the same `RedisCache`. The old
538 /// implementation kept `tags`/`key_tags` in a local, per-process
539 /// `HashMap`, so a key tagged on instance A was invisible to
540 /// `invalidate_tag` called on instance B. Tag state is now persisted in
541 /// the backing `CacheStore` itself, so a second `TaggedCache` wrapping
542 /// the same store observes and can invalidate tags set by the first.
543 #[tokio::test]
544 async fn test_tag_index_visible_across_instances_sharing_backend() {
545 let shared_backend = Arc::new(MockCache::new());
546
547 // Two independent `TaggedCache` "instances" (simulating two app
548 // processes) wrapping the SAME backing store.
549 let instance_a = TaggedCache::new(shared_backend.clone());
550 let instance_b = TaggedCache::new(shared_backend.clone());
551
552 instance_a
553 .set_with_tags("user:1", "Alice".to_string(), &["users"], None)
554 .await
555 .unwrap();
556
557 // Instance B never called `set_with_tags` itself but must still see
558 // the tag membership through the shared backend.
559 let keys = instance_b.get_keys_by_tag("users").await.unwrap();
560 assert_eq!(keys, vec!["user:1".to_string()]);
561
562 // ...and must be able to invalidate it.
563 instance_b.invalidate_tag("users").await.unwrap();
564
565 // The key is gone via the shared backend, observable from instance A.
566 assert_eq!(instance_a.get("user:1").await.unwrap(), None);
567 assert!(instance_a.list_tags().await.unwrap().is_empty());
568 }
569
570 /// Regression for Finding 2: a caller-supplied key that collides with
571 /// `TaggedCache`'s reserved bookkeeping prefix must be rejected by
572 /// `set_with_tags`, not silently allowed to corrupt the tag index.
573 #[tokio::test]
574 async fn test_set_with_tags_rejects_reserved_key_prefix() {
575 let cache = Arc::new(MockCache::new());
576 let tagged = TaggedCache::new(cache);
577
578 let err = tagged
579 .set_with_tags(
580 "__armature_tag__:users",
581 "corrupt".to_string(),
582 &["users"],
583 None,
584 )
585 .await
586 .unwrap_err();
587 assert!(
588 matches!(err, CacheError::Config(_)),
589 "expected CacheError::Config for a reserved-prefixed key, got: {err:?}"
590 );
591
592 // The real "users" tag index must be unaffected: no keys tagged yet.
593 assert!(tagged.get_keys_by_tag("users").await.unwrap().is_empty());
594 }
595
596 /// `get` and `delete` must reject reserved-prefixed keys too, since both
597 /// accept a raw caller-supplied key that is passed straight through to
598 /// the same backing store the tag index's bookkeeping keys live in.
599 #[tokio::test]
600 async fn test_get_and_delete_reject_reserved_key_prefix() {
601 let cache = Arc::new(MockCache::new());
602 let tagged = TaggedCache::new(cache);
603
604 let get_err = tagged.get("__armature_keytags__:foo").await.unwrap_err();
605 assert!(matches!(get_err, CacheError::Config(_)));
606
607 let delete_err = tagged.delete("__armature_tag_index__").await.unwrap_err();
608 assert!(matches!(delete_err, CacheError::Config(_)));
609 }
610
611 /// A caller key that merely CONTAINS the reserved prefix (not as a
612 /// leading substring) is a perfectly ordinary key and must be accepted —
613 /// only keys that actually *start with* the reserved prefix collide with
614 /// the bookkeeping keyspace.
615 #[tokio::test]
616 async fn test_key_containing_but_not_starting_with_reserved_prefix_is_allowed() {
617 let cache = Arc::new(MockCache::new());
618 let tagged = TaggedCache::new(cache);
619
620 tagged
621 .set_with_tags(
622 "user:__armature_tag__:not-a-prefix-collision",
623 "fine".to_string(),
624 &["users"],
625 None,
626 )
627 .await
628 .unwrap();
629
630 assert_eq!(
631 tagged
632 .get("user:__armature_tag__:not-a-prefix-collision")
633 .await
634 .unwrap(),
635 Some("fine".to_string())
636 );
637 }
638
639 /// Regression for Finding 3: `TaggedCache::new` must not panic/error when
640 /// wrapping a backend that doesn't support atomic sets (it only logs a
641 /// warning) — functionality is unaffected either way.
642 #[tokio::test]
643 async fn test_new_does_not_fail_on_non_atomic_backend() {
644 let cache = Arc::new(MockCache::new());
645 assert!(!cache.supports_atomic_sets());
646 let tagged = TaggedCache::new(cache);
647
648 // Still fully functional.
649 tagged
650 .set_with_tags("k", "v".to_string(), &["t"], None)
651 .await
652 .unwrap();
653 assert_eq!(tagged.get("k").await.unwrap(), Some("v".to_string()));
654 }
655}