Skip to main content

armature_cache/
invalidation.rs

1//! Tag-based cache invalidation
2
3use crate::error::{CacheError, CacheResult};
4use crate::traits::CacheStore;
5use futures::future::join_all;
6use std::collections::HashSet;
7use std::sync::Arc;
8use std::time::Duration;
9
10/// Cache with tag-based invalidation support.
11///
12/// The tag -> member-key index (and its reverse, key -> tags) is persisted in
13/// the backing [`CacheStore`] itself under reserved keys — it is NOT kept in
14/// a local, per-process map. That means the index is visible to every
15/// instance sharing the same backing store (e.g. every app process pointed at
16/// the same Redis), so a key tagged by one instance can be looked up and
17/// invalidated by another.
18///
19/// # Atomicity caveat (concurrent race)
20///
21/// Index updates go through [`CacheStore::set_add`] / [`CacheStore::set_remove`] /
22/// [`CacheStore::set_members`]. Only backends that override these with a
23/// native set type — `RedisCache` does, via `SADD`/`SREM`/`SMEMBERS` — update
24/// the index atomically (see [`CacheStore::supports_atomic_sets`]). Other
25/// backends (e.g. `InMemoryCache`, `MemcachedCache`) fall back to the
26/// trait's default, non-atomic read-modify-write, so concurrent
27/// `set_with_tags`/`invalidate_tag` calls against the SAME tag from
28/// different instances can race and lose an update. For distributed
29/// deployments, wrap a `RedisCache` (or another backend that overrides the
30/// set primitives) if you need that guarantee. [`Self::new`] logs a warning
31/// once, at construction time, when the backing store doesn't support
32/// atomic sets.
33///
34/// # Partial-failure caveat (sequential, non-transactional updates)
35///
36/// Separately from the concurrent-race caveat above: [`Self::set_with_tags`],
37/// [`Self::delete`], and [`Self::invalidate_tags`] each issue several
38/// independent `set_add`/`set_remove`/`delete` calls in sequence. If an
39/// early call in one of these sequences succeeds and a later one fails, the
40/// tag index (and its reverse index) can end up **partially updated** —
41/// inconsistent with the cached value, or inconsistent with itself (e.g. a
42/// key's reverse-index tag set can end up not matching the forward tag ->
43/// keys sets it's actually a member of). There is currently no automatic
44/// rollback or reconciliation for this case: a failed call may need to be
45/// retried or the affected tag(s)/key(s) reconciled manually. A best-effort
46/// warning is logged when this happens (see the calls guarded by
47/// `warn_on_partial_failure` in the implementation) so operators at least
48/// get a signal, but the index itself is not repaired automatically.
49///
50/// # Reserved key namespace
51///
52/// The tag index's bookkeeping keys (`tag_set_key`/`key_tags_set_key`/
53/// `TAG_INDEX_KEY`) all begin with the reserved prefix
54/// `"__armature_"` and live in the SAME keyspace as caller-supplied keys —
55/// both go through the same backing [`CacheStore`], with only `key_prefix`
56/// (from `CacheConfig`) applied identically to both. A caller-supplied key
57/// that happens to start with `"__armature_"` would therefore collide with
58/// this reserved bookkeeping keyspace (e.g. writing to
59/// `__armature_tag__:users` would clobber the "users" tag's member set).
60/// This prefix is forbidden for caller-supplied keys: [`Self::set_with_tags`],
61/// [`Self::get`], and [`Self::delete`] all reject a key starting with
62/// `"__armature_"` with a `CacheError::Config`, rather than silently
63/// allowing the collision.
64pub struct TaggedCache<C: CacheStore> {
65    /// Underlying cache store. Tag bookkeeping lives here too (see
66    /// `tag_set_key`/`key_tags_set_key`/`TAG_INDEX_KEY`), not in a local map.
67    cache: Arc<C>,
68}
69
70impl<C: CacheStore> TaggedCache<C> {
71    /// Reserved key holding a tag's member-key set (`tag -> {keys}`).
72    fn tag_set_key(tag: &str) -> String {
73        format!("__armature_tag__:{tag}")
74    }
75
76    /// Reserved key holding a key's tag set (`key -> {tags}`).
77    fn key_tags_set_key(key: &str) -> String {
78        format!("__armature_keytags__:{key}")
79    }
80
81    /// Reserved key holding the set of every tag name that currently has at
82    /// least one member (backs [`Self::list_tags`]).
83    const TAG_INDEX_KEY: &'static str = "__armature_tag_index__";
84
85    /// Prefix reserved for `TaggedCache`'s own tag-bookkeeping keys — every
86    /// key produced by [`Self::tag_set_key`], [`Self::key_tags_set_key`], and
87    /// [`Self::TAG_INDEX_KEY`] starts with it. Forbidden for caller-supplied
88    /// keys; see [`Self::validate_caller_key`] and the struct-level
89    /// "Reserved key namespace" docs.
90    const RESERVED_KEY_PREFIX: &'static str = "__armature_";
91
92    /// Reject a caller-supplied key that collides with `TaggedCache`'s
93    /// reserved bookkeeping keyspace (see [`Self::RESERVED_KEY_PREFIX`]).
94    ///
95    /// Called at the top of every `TaggedCache` method that accepts a raw,
96    /// caller-supplied key ([`Self::set_with_tags`], [`Self::get`],
97    /// [`Self::delete`]) so a key that happens to start with
98    /// `"__armature_"` is rejected with a clear `CacheError::Config` instead
99    /// of silently colliding with (and potentially corrupting) the tag
100    /// index's own reserved keys.
101    fn validate_caller_key(key: &str) -> CacheResult<()> {
102        if key.starts_with(Self::RESERVED_KEY_PREFIX) {
103            Err(CacheError::Config(format!(
104                "cache key {key:?} is reserved for TaggedCache's internal tag index \
105                 (the {:?} prefix is forbidden for caller-supplied keys)",
106                Self::RESERVED_KEY_PREFIX
107            )))
108        } else {
109            Ok(())
110        }
111    }
112
113    /// Best-effort observability hook for the "Partial-failure caveat"
114    /// described on the struct docs: logs a warning when a step in a
115    /// multi-step index update ([`Self::set_with_tags`], [`Self::delete`],
116    /// [`Self::invalidate_tags`]) fails after one or more earlier steps in
117    /// the same call already succeeded, since the tag index may now be left
118    /// partially updated with no automatic rollback.
119    fn warn_on_partial_failure(op: &str, err: &CacheError) {
120        armature_log::warn!(
121            "TaggedCache::{op} failed partway through a multi-step tag-index update; \
122             the tag index may now be inconsistent with the cached value or with itself \
123             (no automatic rollback): {err}"
124        );
125    }
126
127    /// Collapse the results of a concurrently-issued batch into the first
128    /// error, if any.
129    ///
130    /// The batch is driven with `join_all` rather than `try_join_all` so every
131    /// operation is actually issued: short-circuiting on the first failure
132    /// would cancel the still-pending index updates and widen the
133    /// partial-update window described in the struct docs.
134    fn first_error(op: &str, results: Vec<CacheResult<()>>) -> CacheResult<()> {
135        for result in results {
136            result.inspect_err(|e| Self::warn_on_partial_failure(op, e))?;
137        }
138        Ok(())
139    }
140
141    /// Create new tagged cache
142    ///
143    /// Checks [`CacheStore::supports_atomic_sets`] on `cache` and logs a
144    /// warning once, here at construction time, when the backing store does
145    /// NOT support atomic sets — see the struct-level "Atomicity caveat"
146    /// docs for what that means for concurrent tag-index updates.
147    ///
148    /// # Examples
149    ///
150    /// ```rust,ignore
151    /// use armature_cache::*;
152    ///
153    /// let cache = RedisCache::new(config).await?;
154    /// let tagged = TaggedCache::new(Arc::new(cache));
155    /// ```
156    pub fn new(cache: Arc<C>) -> Self {
157        if !cache.supports_atomic_sets() {
158            armature_log::warn!(
159                "TaggedCache backing store does not support atomic set operations \
160                 (SADD/SREM/SMEMBERS-equivalent); concurrent set_with_tags/invalidate_tag \
161                 calls against the same tag from different instances can race and lose an \
162                 update. Wrap a backend that overrides CacheStore::set_add/set_remove/\
163                 set_members atomically (e.g. RedisCache) if you need that guarantee."
164            );
165        }
166        Self { cache }
167    }
168
169    /// Set a value with tags
170    ///
171    /// A repeated call for the same `key` REPLACES its tag membership with
172    /// `tags` (it does not union with whatever tags the key carried before).
173    ///
174    /// # TTL and the tag index
175    ///
176    /// `ttl` applies to the value, and is **mirrored onto the key's reverse
177    /// index** (`key -> tags`) so that bookkeeping disappears along with the
178    /// value instead of outliving it forever.
179    ///
180    /// The forward index (`tag -> keys`) cannot carry the same TTL: one tag set
181    /// holds many keys with independent lifetimes, so expiring the set would
182    /// drop the memberships of keys that are still alive. An expired key
183    /// therefore remains a member of its tags until it is noticed, and
184    /// [`Self::get_keys_by_tag`] reconciles that on read — it filters out
185    /// members whose value is gone and prunes them from the tag set, so tag
186    /// sets do not grow monotonically and stale keys are never returned.
187    ///
188    /// # Examples
189    ///
190    /// ```rust,ignore
191    /// tagged.set_with_tags(
192    ///     "user:123",
193    ///     user_json,
194    ///     &["users", "active-users"],
195    ///     Some(Duration::from_secs(3600)),
196    /// ).await?;
197    /// ```
198    pub async fn set_with_tags(
199        &self,
200        key: &str,
201        value: String,
202        tags: &[&str],
203        ttl: Option<Duration>,
204    ) -> CacheResult<()> {
205        Self::validate_caller_key(key)?;
206
207        // Set in cache
208        self.cache.set_json(key, value, ttl).await?;
209
210        // Replace this key's persisted tag membership: drop it from any
211        // previously associated tag that is no longer in `tags`, then (re)add
212        // it to the current set.
213        //
214        // Each phase below issues its per-tag writes as ONE wave instead of a
215        // sequential chain: writes that target distinct set keys are driven
216        // concurrently with `join_all`, and writes that target the *same* set
217        // key go through the variadic `set_add_many`/`set_remove_many` (a
218        // single `SADD`/`SREM` on backends with native sets). The old code cost
219        // 3N sequential round-trips for N tags.
220        let previous_tags = self.get_tags_for_key(key).await?;
221        let new_tags: HashSet<String> = tags.iter().map(|t| t.to_string()).collect();
222        let key_tags_key = Self::key_tags_set_key(key);
223
224        let stale_tags: Vec<&str> = previous_tags
225            .iter()
226            .filter(|t| !new_tags.contains(*t))
227            .map(|t| t.as_str())
228            .collect();
229
230        if !stale_tags.is_empty() {
231            let stale_set_keys: Vec<String> =
232                stale_tags.iter().map(|t| Self::tag_set_key(t)).collect();
233            let removals = join_all(
234                stale_set_keys
235                    .iter()
236                    .map(|set_key| self.cache.set_remove(set_key, key)),
237            )
238            .await;
239            Self::first_error("set_with_tags", removals)?;
240
241            self.cache
242                .set_remove_many(&key_tags_key, &stale_tags)
243                .await
244                .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
245
246            self.prune_tag_index_where_empty(&stale_tags).await?;
247        }
248
249        let added_tags: Vec<&str> = new_tags.iter().map(|t| t.as_str()).collect();
250        if !added_tags.is_empty() {
251            let added_set_keys: Vec<String> =
252                added_tags.iter().map(|t| Self::tag_set_key(t)).collect();
253            let additions = join_all(
254                added_set_keys
255                    .iter()
256                    .map(|set_key| self.cache.set_add(set_key, key)),
257            )
258            .await;
259            Self::first_error("set_with_tags", additions)?;
260
261            self.cache
262                .set_add_many(&key_tags_key, &added_tags)
263                .await
264                .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
265            self.cache
266                .set_add_many(Self::TAG_INDEX_KEY, &added_tags)
267                .await
268                .inspect_err(|e| Self::warn_on_partial_failure("set_with_tags", e))?;
269
270            // Mirror the value's TTL onto the reverse index so it cannot
271            // outlive the value it describes. Best-effort: a failure here
272            // leaves a longer-lived index entry, which reconciliation on read
273            // still copes with, so it must not fail the write itself.
274            if let Some(ttl) = ttl
275                && let Err(e) = self.cache.expire(&key_tags_key, ttl).await
276            {
277                armature_log::warn!(
278                    "TaggedCache::set_with_tags could not mirror the value TTL onto the \
279                     reverse tag index for key {key:?}; the index entry may outlive the \
280                     value (stale members are still reconciled on read): {e}"
281                );
282            }
283        }
284
285        Ok(())
286    }
287
288    /// Get value from cache
289    pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
290        Self::validate_caller_key(key)?;
291        self.cache.get_json(key).await
292    }
293
294    /// Delete a specific key
295    pub async fn delete(&self, key: &str) -> CacheResult<()> {
296        Self::validate_caller_key(key)?;
297
298        // Delete from cache
299        self.cache.delete(key).await?;
300
301        // Remove from the persisted tag mappings. The per-tag removals target
302        // distinct set keys, so they go out as one concurrent wave.
303        let key_tags_key = Self::key_tags_set_key(key);
304        let tags = self.cache.set_members(&key_tags_key).await?;
305
306        if !tags.is_empty() {
307            let tag_set_keys: Vec<String> = tags.iter().map(|t| Self::tag_set_key(t)).collect();
308            let removals = join_all(
309                tag_set_keys
310                    .iter()
311                    .map(|set_key| self.cache.set_remove(set_key, key)),
312            )
313            .await;
314            Self::first_error("delete", removals)?;
315
316            let tag_refs: Vec<&str> = tags.iter().map(|t| t.as_str()).collect();
317            self.prune_tag_index_where_empty(&tag_refs).await?;
318
319            self.cache
320                .delete(&key_tags_key)
321                .await
322                .inspect_err(|e| Self::warn_on_partial_failure("delete", e))?;
323        }
324
325        Ok(())
326    }
327
328    /// Invalidate all keys with a specific tag
329    ///
330    /// # Examples
331    ///
332    /// ```rust,ignore
333    /// // Invalidate all user-related cache entries
334    /// tagged.invalidate_tag("users").await?;
335    /// ```
336    pub async fn invalidate_tag(&self, tag: &str) -> CacheResult<()> {
337        self.invalidate_tags(&[tag]).await
338    }
339
340    /// Invalidate all keys with any of the specified tags
341    ///
342    /// # Examples
343    ///
344    /// ```rust,ignore
345    /// // Invalidate all user and session data
346    /// tagged.invalidate_tags(&["users", "sessions"]).await?;
347    /// ```
348    pub async fn invalidate_tags(&self, tags: &[&str]) -> CacheResult<()> {
349        // Gather the union of keys across all requested tags. The reads target
350        // distinct set keys, so they are issued concurrently rather than as a
351        // sequential chain of one round-trip per tag.
352        let tag_set_keys: Vec<String> = tags.iter().map(|t| Self::tag_set_key(t)).collect();
353        let member_lists = join_all(
354            tag_set_keys
355                .iter()
356                .map(|set_key| self.cache.set_members(set_key)),
357        )
358        .await;
359
360        let mut victims: HashSet<String> = HashSet::new();
361        for members in member_lists {
362            victims.extend(members?);
363        }
364
365        // One batch delete for the whole union — coalesced into a single
366        // backend round-trip regardless of how many tags were requested.
367        if !victims.is_empty() {
368            let key_refs: Vec<&str> = victims.iter().map(|s| s.as_str()).collect();
369            self.cache
370                .delete_many(&key_refs)
371                .await
372                .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
373        }
374
375        // Update each victim's reverse index: drop only the tags being
376        // invalidated (a key may carry tags beyond those requested).
377        //
378        // Each victim owns a distinct reverse-index set key, so the victims are
379        // processed concurrently, and each victim's tag removals collapse into
380        // one variadic `set_remove_many`. The old code was O(victims x tags)
381        // strictly sequential round-trips.
382        let removed: HashSet<&str> = tags.iter().copied().collect();
383        let reverse_updates = join_all(victims.iter().map(|key| {
384            let removed = &removed;
385            async move {
386                let key_tags_key = Self::key_tags_set_key(key.as_str());
387                let current_tags = self.cache.set_members(&key_tags_key).await?;
388                let doomed: Vec<&str> = current_tags
389                    .iter()
390                    .map(|t| t.as_str())
391                    .filter(|t| removed.contains(t))
392                    .collect();
393                if doomed.is_empty() {
394                    return Ok(());
395                }
396                self.cache.set_remove_many(&key_tags_key, &doomed).await
397            }
398        }))
399        .await;
400        Self::first_error("invalidate_tags", reverse_updates)?;
401
402        // Drop the invalidated tags' member sets entirely and prune them from
403        // the tag index, rather than leaving emptied sets behind. Both are
404        // batched: one variadic delete for the member sets, one variadic
405        // removal from the global tag index.
406        let tag_set_key_refs: Vec<&str> = tag_set_keys.iter().map(|k| k.as_str()).collect();
407        if !tag_set_key_refs.is_empty() {
408            self.cache
409                .delete_many(&tag_set_key_refs)
410                .await
411                .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
412            self.cache
413                .set_remove_many(Self::TAG_INDEX_KEY, tags)
414                .await
415                .inspect_err(|e| Self::warn_on_partial_failure("invalidate_tags", e))?;
416        }
417
418        Ok(())
419    }
420
421    /// Get all keys currently tagged with `tag`.
422    ///
423    /// Members whose value is no longer present — most commonly because the
424    /// value's TTL elapsed — are **reconciled away** rather than returned: the
425    /// forward index (`tag -> keys`) cannot carry the value's TTL (one tag set
426    /// spans many keys with independent lifetimes), so an expired key would
427    /// otherwise stay a member of every one of its tags forever, growing the
428    /// tag sets monotonically and handing callers keys that no longer exist.
429    ///
430    /// Reconciliation is best-effort: if pruning the stale members fails, the
431    /// live keys are still returned and the prune is retried on the next read.
432    pub async fn get_keys_by_tag(&self, tag: &str) -> CacheResult<Vec<String>> {
433        let tag_key = Self::tag_set_key(tag);
434        let members = self.cache.set_members(&tag_key).await?;
435        if members.is_empty() {
436            return Ok(Vec::new());
437        }
438
439        let member_refs: Vec<&str> = members.iter().map(|m| m.as_str()).collect();
440        let present = self.cache.exists_many(&member_refs).await?;
441
442        let mut live: Vec<String> = Vec::with_capacity(members.len());
443        let mut stale: Vec<&str> = Vec::new();
444        for (member, exists) in members.iter().zip(present) {
445            if exists {
446                live.push(member.clone());
447            } else {
448                stale.push(member.as_str());
449            }
450        }
451
452        if !stale.is_empty() {
453            match self.cache.set_remove_many(&tag_key, &stale).await {
454                Ok(()) => {
455                    if live.is_empty()
456                        && let Err(e) = self.prune_tag_index_where_empty(&[tag]).await
457                    {
458                        armature_log::warn!(
459                            "TaggedCache::get_keys_by_tag could not prune the now-empty tag \
460                             {tag:?} from the tag index: {e}"
461                        );
462                    }
463                }
464                Err(e) => {
465                    let stale_count = stale.len();
466                    armature_log::warn!(
467                        "TaggedCache::get_keys_by_tag could not prune {stale_count} expired \
468                         member(s) from tag {tag:?}; they are excluded from this result and \
469                         the prune will be retried on the next read: {e}"
470                    );
471                }
472            }
473        }
474
475        Ok(live)
476    }
477
478    /// Get all tags for a specific key
479    pub async fn get_tags_for_key(&self, key: &str) -> CacheResult<Vec<String>> {
480        self.cache.set_members(&Self::key_tags_set_key(key)).await
481    }
482
483    /// Get all registered tags (tags currently carrying at least one member).
484    pub async fn list_tags(&self) -> CacheResult<Vec<String>> {
485        self.cache.set_members(Self::TAG_INDEX_KEY).await
486    }
487
488    /// Drop from the global tag index every tag in `tags` that has no members
489    /// left.
490    ///
491    /// The membership reads target distinct set keys and go out concurrently;
492    /// the resulting removals all target `TAG_INDEX_KEY`, so they collapse into
493    /// a single variadic `set_remove_many` (issuing them concurrently would
494    /// instead race the default backend's read-modify-write against itself).
495    async fn prune_tag_index_where_empty(&self, tags: &[&str]) -> CacheResult<()> {
496        if tags.is_empty() {
497            return Ok(());
498        }
499
500        let tag_set_keys: Vec<String> = tags.iter().map(|t| Self::tag_set_key(t)).collect();
501        let member_lists = join_all(
502            tag_set_keys
503                .iter()
504                .map(|set_key| self.cache.set_members(set_key)),
505        )
506        .await;
507
508        let mut empty: Vec<&str> = Vec::new();
509        for (tag, members) in tags.iter().zip(member_lists) {
510            if members?.is_empty() {
511                empty.push(*tag);
512            }
513        }
514
515        if !empty.is_empty() {
516            self.cache
517                .set_remove_many(Self::TAG_INDEX_KEY, &empty)
518                .await?;
519        }
520        Ok(())
521    }
522}
523
524impl<C: CacheStore> Clone for TaggedCache<C> {
525    fn clone(&self) -> Self {
526        Self {
527            cache: self.cache.clone(),
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::error::CacheResult;
536    use async_trait::async_trait;
537    use std::collections::HashMap;
538    use tokio::sync::RwLock;
539
540    use crate::tiered::InMemoryCache;
541
542    // Mock cache for testing. Records each batch-delete round-trip (as the set
543    // of keys it carried) so tests can assert that `invalidate_tags` coalesces
544    // its deletes instead of issuing one per tag.
545    #[derive(Clone)]
546    struct MockCache {
547        data: Arc<RwLock<HashMap<String, String>>>,
548        mdel_batches: Arc<RwLock<Vec<Vec<String>>>>,
549    }
550
551    impl MockCache {
552        fn new() -> Self {
553            Self {
554                data: Arc::new(RwLock::new(HashMap::new())),
555                mdel_batches: Arc::new(RwLock::new(Vec::new())),
556            }
557        }
558
559        /// Every recorded batch delete, each as a sorted list of keys.
560        async fn mdel_batches(&self) -> Vec<Vec<String>> {
561            self.mdel_batches
562                .read()
563                .await
564                .iter()
565                .map(|batch| {
566                    let mut batch = batch.clone();
567                    batch.sort();
568                    batch
569                })
570                .collect()
571        }
572    }
573
574    #[async_trait]
575    impl CacheStore for MockCache {
576        async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
577            Ok(self.data.read().await.get(key).cloned())
578        }
579
580        async fn set_json(
581            &self,
582            key: &str,
583            value: String,
584            _ttl: Option<Duration>,
585        ) -> CacheResult<()> {
586            self.data.write().await.insert(key.to_string(), value);
587            Ok(())
588        }
589
590        async fn delete(&self, key: &str) -> CacheResult<()> {
591            self.data.write().await.remove(key);
592            Ok(())
593        }
594
595        async fn exists(&self, key: &str) -> CacheResult<bool> {
596            Ok(self.data.read().await.contains_key(key))
597        }
598
599        async fn clear(&self) -> CacheResult<()> {
600            self.data.write().await.clear();
601            Ok(())
602        }
603
604        async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
605            self.mdel_batches
606                .write()
607                .await
608                .push(keys.iter().map(|k| k.to_string()).collect());
609            let mut data = self.data.write().await;
610            for key in keys {
611                data.remove(*key);
612            }
613            Ok(())
614        }
615
616        async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
617            Ok(None)
618        }
619
620        async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
621            Ok(())
622        }
623
624        async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
625            Ok(0)
626        }
627
628        async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
629            Ok(0)
630        }
631    }
632
633    #[tokio::test]
634    async fn test_tagged_cache() {
635        let cache = Arc::new(MockCache::new());
636        let tagged = TaggedCache::new(cache);
637
638        // Set with tags
639        tagged
640            .set_with_tags("user:1", "Alice".to_string(), &["users", "active"], None)
641            .await
642            .unwrap();
643
644        tagged
645            .set_with_tags("user:2", "Bob".to_string(), &["users"], None)
646            .await
647            .unwrap();
648
649        // Get value
650        let value = tagged.get("user:1").await.unwrap();
651        assert_eq!(value, Some("Alice".to_string()));
652
653        // Get keys by tag
654        let user_keys = tagged.get_keys_by_tag("users").await.unwrap();
655        assert_eq!(user_keys.len(), 2);
656
657        // Invalidate by tag
658        tagged.invalidate_tag("users").await.unwrap();
659
660        // Verify deletion
661        let value = tagged.get("user:1").await.unwrap();
662        assert_eq!(value, None);
663    }
664
665    #[tokio::test]
666    async fn test_multiple_tags() {
667        let cache = Arc::new(MockCache::new());
668        let tagged = TaggedCache::new(cache);
669
670        tagged
671            .set_with_tags("key1", "value1".to_string(), &["tag1", "tag2"], None)
672            .await
673            .unwrap();
674
675        let tags = tagged.get_tags_for_key("key1").await.unwrap();
676        assert_eq!(tags.len(), 2);
677
678        tagged.invalidate_tag("tag1").await.unwrap();
679
680        let value = tagged.get("key1").await.unwrap();
681        assert_eq!(value, None);
682    }
683
684    /// Regression: `invalidate_tags` must coalesce into a single backend batch
685    /// delete instead of one `delete_many` round-trip per tag.
686    #[tokio::test]
687    async fn test_invalidate_tags_coalesces_into_single_roundtrip() {
688        let cache = Arc::new(MockCache::new());
689        let tagged = TaggedCache::new(cache.clone());
690
691        // Distinct keys across three tags; "shared" carries two of them so we
692        // also verify a shared key is deleted exactly once.
693        tagged
694            .set_with_tags("k1", "a".to_string(), &["t1"], None)
695            .await
696            .unwrap();
697        tagged
698            .set_with_tags("k2", "b".to_string(), &["t2"], None)
699            .await
700            .unwrap();
701        tagged
702            .set_with_tags("shared", "c".to_string(), &["t1", "t3"], None)
703            .await
704            .unwrap();
705
706        tagged.invalidate_tags(&["t1", "t2", "t3"]).await.unwrap();
707
708        // All matching keys gone.
709        assert_eq!(tagged.get("k1").await.unwrap(), None);
710        assert_eq!(tagged.get("k2").await.unwrap(), None);
711        assert_eq!(tagged.get("shared").await.unwrap(), None);
712
713        // Two coalesced batch deletes regardless of tag count: one carrying the
714        // whole union of victim keys, one carrying every tag's member set.
715        // Neither scales with the number of tags in round-trips.
716        let batches = cache.mdel_batches().await;
717        assert_eq!(
718            batches.len(),
719            2,
720            "invalidate_tags must coalesce its deletes, got batches: {batches:?}"
721        );
722        assert_eq!(batches[0], vec!["k1", "k2", "shared"]);
723        assert_eq!(
724            batches[1],
725            vec![
726                "__armature_tag__:t1",
727                "__armature_tag__:t2",
728                "__armature_tag__:t3"
729            ]
730        );
731
732        // Reverse index fully cleaned up.
733        assert!(tagged.list_tags().await.unwrap().is_empty());
734        assert!(tagged.get_tags_for_key("shared").await.unwrap().is_empty());
735    }
736
737    /// Regression for Finding 2: the tag index must be visible across
738    /// independent `TaggedCache` instances that share the same backing store
739    /// — e.g. two app processes both wrapping the same `RedisCache`. The old
740    /// implementation kept `tags`/`key_tags` in a local, per-process
741    /// `HashMap`, so a key tagged on instance A was invisible to
742    /// `invalidate_tag` called on instance B. Tag state is now persisted in
743    /// the backing `CacheStore` itself, so a second `TaggedCache` wrapping
744    /// the same store observes and can invalidate tags set by the first.
745    #[tokio::test]
746    async fn test_tag_index_visible_across_instances_sharing_backend() {
747        let shared_backend = Arc::new(MockCache::new());
748
749        // Two independent `TaggedCache` "instances" (simulating two app
750        // processes) wrapping the SAME backing store.
751        let instance_a = TaggedCache::new(shared_backend.clone());
752        let instance_b = TaggedCache::new(shared_backend.clone());
753
754        instance_a
755            .set_with_tags("user:1", "Alice".to_string(), &["users"], None)
756            .await
757            .unwrap();
758
759        // Instance B never called `set_with_tags` itself but must still see
760        // the tag membership through the shared backend.
761        let keys = instance_b.get_keys_by_tag("users").await.unwrap();
762        assert_eq!(keys, vec!["user:1".to_string()]);
763
764        // ...and must be able to invalidate it.
765        instance_b.invalidate_tag("users").await.unwrap();
766
767        // The key is gone via the shared backend, observable from instance A.
768        assert_eq!(instance_a.get("user:1").await.unwrap(), None);
769        assert!(instance_a.list_tags().await.unwrap().is_empty());
770    }
771
772    /// Regression for Finding 2: a caller-supplied key that collides with
773    /// `TaggedCache`'s reserved bookkeeping prefix must be rejected by
774    /// `set_with_tags`, not silently allowed to corrupt the tag index.
775    #[tokio::test]
776    async fn test_set_with_tags_rejects_reserved_key_prefix() {
777        let cache = Arc::new(MockCache::new());
778        let tagged = TaggedCache::new(cache);
779
780        let err = tagged
781            .set_with_tags(
782                "__armature_tag__:users",
783                "corrupt".to_string(),
784                &["users"],
785                None,
786            )
787            .await
788            .unwrap_err();
789        assert!(
790            matches!(err, CacheError::Config(_)),
791            "expected CacheError::Config for a reserved-prefixed key, got: {err:?}"
792        );
793
794        // The real "users" tag index must be unaffected: no keys tagged yet.
795        assert!(tagged.get_keys_by_tag("users").await.unwrap().is_empty());
796    }
797
798    /// `get` and `delete` must reject reserved-prefixed keys too, since both
799    /// accept a raw caller-supplied key that is passed straight through to
800    /// the same backing store the tag index's bookkeeping keys live in.
801    #[tokio::test]
802    async fn test_get_and_delete_reject_reserved_key_prefix() {
803        let cache = Arc::new(MockCache::new());
804        let tagged = TaggedCache::new(cache);
805
806        let get_err = tagged.get("__armature_keytags__:foo").await.unwrap_err();
807        assert!(matches!(get_err, CacheError::Config(_)));
808
809        let delete_err = tagged.delete("__armature_tag_index__").await.unwrap_err();
810        assert!(matches!(delete_err, CacheError::Config(_)));
811    }
812
813    /// A caller key that merely CONTAINS the reserved prefix (not as a
814    /// leading substring) is a perfectly ordinary key and must be accepted —
815    /// only keys that actually *start with* the reserved prefix collide with
816    /// the bookkeeping keyspace.
817    #[tokio::test]
818    async fn test_key_containing_but_not_starting_with_reserved_prefix_is_allowed() {
819        let cache = Arc::new(MockCache::new());
820        let tagged = TaggedCache::new(cache);
821
822        tagged
823            .set_with_tags(
824                "user:__armature_tag__:not-a-prefix-collision",
825                "fine".to_string(),
826                &["users"],
827                None,
828            )
829            .await
830            .unwrap();
831
832        assert_eq!(
833            tagged
834                .get("user:__armature_tag__:not-a-prefix-collision")
835                .await
836                .unwrap(),
837            Some("fine".to_string())
838        );
839    }
840
841    /// Regression for Finding 3: `TaggedCache::new` must not panic/error when
842    /// wrapping a backend that doesn't support atomic sets (it only logs a
843    /// warning) — functionality is unaffected either way.
844    #[tokio::test]
845    async fn test_new_does_not_fail_on_non_atomic_backend() {
846        let cache = Arc::new(MockCache::new());
847        assert!(!cache.supports_atomic_sets());
848        let tagged = TaggedCache::new(cache);
849
850        // Still fully functional.
851        tagged
852            .set_with_tags("k", "v".to_string(), &["t"], None)
853            .await
854            .unwrap();
855        assert_eq!(tagged.get("k").await.unwrap(), Some("v".to_string()));
856    }
857
858    /// The value's TTL must be mirrored onto the key's reverse tag index so
859    /// that bookkeeping cannot outlive the value it describes. `InMemoryCache`
860    /// is used here (rather than `MockCache`) because it actually honours TTLs.
861    #[tokio::test(start_paused = true)]
862    async fn test_set_with_tags_mirrors_ttl_onto_reverse_index() {
863        let cache = Arc::new(InMemoryCache::new());
864        let tagged = TaggedCache::new(cache.clone());
865
866        tagged
867            .set_with_tags(
868                "user:1",
869                "Alice".to_string(),
870                &["users"],
871                Some(Duration::from_secs(60)),
872            )
873            .await
874            .unwrap();
875
876        let key_tags_key = TaggedCache::<InMemoryCache>::key_tags_set_key("user:1");
877        let index_ttl = cache
878            .ttl(&key_tags_key)
879            .await
880            .unwrap()
881            .expect("the reverse tag index must inherit the value's TTL");
882        assert!(index_ttl > Duration::from_secs(0));
883        assert!(index_ttl <= Duration::from_secs(60));
884
885        // Once the value expires, its reverse index is gone too rather than
886        // lingering forever.
887        tokio::time::advance(Duration::from_secs(61)).await;
888        assert_eq!(tagged.get("user:1").await.unwrap(), None);
889        assert!(tagged.get_tags_for_key("user:1").await.unwrap().is_empty());
890    }
891
892    /// A `None` TTL leaves the reverse index unexpiring, matching the value.
893    #[tokio::test(start_paused = true)]
894    async fn test_set_with_tags_without_ttl_leaves_index_unexpiring() {
895        let cache = Arc::new(InMemoryCache::new());
896        let tagged = TaggedCache::new(cache.clone());
897
898        tagged
899            .set_with_tags("user:1", "Alice".to_string(), &["users"], None)
900            .await
901            .unwrap();
902
903        let key_tags_key = TaggedCache::<InMemoryCache>::key_tags_set_key("user:1");
904        assert_eq!(cache.ttl(&key_tags_key).await.unwrap(), None);
905
906        tokio::time::advance(Duration::from_secs(3600)).await;
907        assert_eq!(
908            tagged.get_keys_by_tag("users").await.unwrap(),
909            vec!["user:1".to_string()],
910            "a key with no TTL must stay a live member of its tags"
911        );
912    }
913
914    /// Regression: an expired key used to remain a member of every tag it
915    /// carried forever, so tag sets grew monotonically and `get_keys_by_tag`
916    /// handed back keys that no longer existed. Stale members must now be
917    /// filtered out on read and pruned from the tag set.
918    #[tokio::test(start_paused = true)]
919    async fn test_get_keys_by_tag_reconciles_expired_members() {
920        let cache = Arc::new(InMemoryCache::new());
921        let tagged = TaggedCache::new(cache.clone());
922
923        tagged
924            .set_with_tags(
925                "short",
926                "gone-soon".to_string(),
927                &["users"],
928                Some(Duration::from_secs(1)),
929            )
930            .await
931            .unwrap();
932        tagged
933            .set_with_tags("forever", "stays".to_string(), &["users"], None)
934            .await
935            .unwrap();
936
937        let mut keys = tagged.get_keys_by_tag("users").await.unwrap();
938        keys.sort();
939        assert_eq!(keys, vec!["forever".to_string(), "short".to_string()]);
940
941        tokio::time::advance(Duration::from_secs(2)).await;
942
943        // The expired key is not returned...
944        assert_eq!(
945            tagged.get_keys_by_tag("users").await.unwrap(),
946            vec!["forever".to_string()]
947        );
948
949        // ...and has actually been pruned from the persisted tag set, so the
950        // set does not grow monotonically with dead keys.
951        let tag_key = TaggedCache::<InMemoryCache>::tag_set_key("users");
952        assert_eq!(
953            cache.set_members(&tag_key).await.unwrap(),
954            vec!["forever".to_string()]
955        );
956    }
957
958    /// When reconciliation empties a tag entirely, the tag is also dropped
959    /// from the global tag index instead of lingering as a phantom tag.
960    #[tokio::test(start_paused = true)]
961    async fn test_reconciliation_prunes_emptied_tag_from_index() {
962        let cache = Arc::new(InMemoryCache::new());
963        let tagged = TaggedCache::new(cache.clone());
964
965        tagged
966            .set_with_tags(
967                "short",
968                "gone-soon".to_string(),
969                &["ephemeral"],
970                Some(Duration::from_secs(1)),
971            )
972            .await
973            .unwrap();
974        assert_eq!(
975            tagged.list_tags().await.unwrap(),
976            vec!["ephemeral".to_string()]
977        );
978
979        tokio::time::advance(Duration::from_secs(2)).await;
980
981        assert!(
982            tagged
983                .get_keys_by_tag("ephemeral")
984                .await
985                .unwrap()
986                .is_empty()
987        );
988        assert!(
989            tagged.list_tags().await.unwrap().is_empty(),
990            "an emptied tag must be pruned from the global tag index"
991        );
992    }
993
994    /// Replacing a key's tags must still drop it from the tags it no longer
995    /// carries, now that those removals are issued as one concurrent wave.
996    #[tokio::test]
997    async fn test_retagging_removes_key_from_dropped_tags() {
998        let cache = Arc::new(MockCache::new());
999        let tagged = TaggedCache::new(cache);
1000
1001        tagged
1002            .set_with_tags("k", "v1".to_string(), &["a", "b", "c"], None)
1003            .await
1004            .unwrap();
1005        tagged
1006            .set_with_tags("k", "v2".to_string(), &["c", "d"], None)
1007            .await
1008            .unwrap();
1009
1010        assert!(tagged.get_keys_by_tag("a").await.unwrap().is_empty());
1011        assert!(tagged.get_keys_by_tag("b").await.unwrap().is_empty());
1012        assert_eq!(
1013            tagged.get_keys_by_tag("c").await.unwrap(),
1014            vec!["k".to_string()]
1015        );
1016        assert_eq!(
1017            tagged.get_keys_by_tag("d").await.unwrap(),
1018            vec!["k".to_string()]
1019        );
1020
1021        let mut tags = tagged.get_tags_for_key("k").await.unwrap();
1022        tags.sort();
1023        assert_eq!(tags, vec!["c".to_string(), "d".to_string()]);
1024
1025        let mut listed = tagged.list_tags().await.unwrap();
1026        listed.sort();
1027        assert_eq!(listed, vec!["c".to_string(), "d".to_string()]);
1028    }
1029}