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}