Skip to main content

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}