armature_cache/traits.rs
1//! Cache store trait definition.
2
3use crate::error::{CacheError, CacheResult};
4use async_trait::async_trait;
5use std::time::Duration;
6
7/// Cache store trait for different cache backends.
8#[async_trait]
9pub trait CacheStore: Send + Sync {
10 /// Get a JSON value from the cache.
11 ///
12 /// # Arguments
13 ///
14 /// * `key` - The cache key
15 ///
16 /// # Returns
17 ///
18 /// Returns `Ok(Some(value))` if the key exists, `Ok(None)` if not found,
19 /// or an error if the operation fails.
20 async fn get_json(&self, key: &str) -> CacheResult<Option<String>>;
21
22 /// Set a JSON value in the cache.
23 ///
24 /// # Arguments
25 ///
26 /// * `key` - The cache key
27 /// * `value` - The JSON string value
28 /// * `ttl` - Optional time-to-live duration
29 ///
30 /// # `None` means "unspecified", not "forever"
31 ///
32 /// Backends configured with a `CacheConfig::default_ttl` treat `ttl: None`
33 /// as "no TTL was specified for this write" and fall back to that default.
34 /// To store an entry that genuinely never expires — bypassing
35 /// `default_ttl` — use [`Self::set_json_forever`].
36 async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()>;
37
38 /// Set a JSON value that never expires, explicitly bypassing any configured
39 /// `CacheConfig::default_ttl`.
40 ///
41 /// This exists because [`Self::set_json`] cannot express the difference
42 /// between "the caller did not specify a TTL" and "the caller wants no
43 /// expiry at all" — both are `None`, and backends resolve `None` against
44 /// `default_ttl`. On a store built with `.with_default_ttl(..)` that made
45 /// a non-expiring entry unobtainable, so "remember forever" silently
46 /// became "remember for the default TTL". Callers that mean *forever*
47 /// must come through here.
48 ///
49 /// The default implementation forwards to `set_json(key, value, None)`,
50 /// which is already correct for any backend that has no `default_ttl`
51 /// concept (e.g. [`crate::tiered::InMemoryCache`]). Backends that resolve
52 /// `None` against a configured default **must** override this.
53 ///
54 /// # Arguments
55 ///
56 /// * `key` - The cache key
57 /// * `value` - The JSON string value
58 async fn set_json_forever(&self, key: &str, value: String) -> CacheResult<()> {
59 self.set_json(key, value, None).await
60 }
61
62 /// Delete a key from the cache.
63 ///
64 /// # Arguments
65 ///
66 /// * `key` - The cache key to delete
67 async fn delete(&self, key: &str) -> CacheResult<()>;
68
69 /// Check if a key exists in the cache.
70 ///
71 /// # Arguments
72 ///
73 /// * `key` - The cache key to check
74 async fn exists(&self, key: &str) -> CacheResult<bool>;
75
76 /// Clear all keys from the cache.
77 ///
78 /// **Warning:** This operation may be destructive and affect all keys.
79 async fn clear(&self) -> CacheResult<()>;
80
81 /// Get the TTL (time-to-live) of a key.
82 ///
83 /// # Arguments
84 ///
85 /// * `key` - The cache key
86 ///
87 /// # Returns
88 ///
89 /// Returns `Ok(Some(duration))` if the key has a TTL, `Ok(None)` if the key
90 /// has no expiration or doesn't exist.
91 async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>>;
92
93 /// Set or update the expiration time for a key.
94 ///
95 /// # Arguments
96 ///
97 /// * `key` - The cache key
98 /// * `ttl` - The new time-to-live duration
99 async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()>;
100
101 /// Increment a numeric value.
102 ///
103 /// # Arguments
104 ///
105 /// * `key` - The cache key
106 /// * `delta` - The amount to increment by
107 ///
108 /// # Returns
109 ///
110 /// Returns the new value after incrementing.
111 async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64>;
112
113 /// Decrement a numeric value.
114 ///
115 /// # Arguments
116 ///
117 /// * `key` - The cache key
118 /// * `delta` - The amount to decrement by
119 ///
120 /// # Returns
121 ///
122 /// Returns the new value after decrementing.
123 async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64>;
124
125 // ========== Native Batch Primitives ==========
126 //
127 // These are the low-level batch operations that backends can override with
128 // a single native command (e.g. Redis `MGET`/`MSET`/variadic `DEL`) to turn
129 // N round-trips into one. The DEFAULT implementations fall back to the
130 // per-key loop (run concurrently), so existing `CacheStore` impls keep
131 // working unchanged. The higher-level `get_many`/`set_many`/`delete_many`
132 // methods and `ParallelCacheOps` delegate here, so overriding these three
133 // methods is enough to accelerate all batch APIs.
134
135 /// Get multiple keys in a single batch operation.
136 ///
137 /// Returns a vector of `Option<String>` in the **same order** as `keys`;
138 /// `None` indicates a missing key.
139 ///
140 /// The default implementation issues one `get_json` per key concurrently;
141 /// backends should override this with a native multi-get (e.g. `MGET`).
142 async fn mget(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
143 use futures::future::try_join_all;
144
145 let futures = keys.iter().map(|key| self.get_json(key));
146 try_join_all(futures).await
147 }
148
149 /// Set multiple key/value pairs in a single batch operation.
150 ///
151 /// The default implementation issues one `set_json` per pair concurrently;
152 /// backends should override this with a native multi-set (e.g. `MSET`, or a
153 /// pipeline of `SET ... EX` when a TTL is required).
154 async fn mset(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
155 use futures::future::try_join_all;
156
157 let futures = items
158 .iter()
159 .map(|(key, value)| self.set_json(key, value.clone(), ttl));
160 try_join_all(futures).await?;
161 Ok(())
162 }
163
164 /// Delete multiple keys in a single batch operation.
165 ///
166 /// The default implementation issues one `delete` per key concurrently;
167 /// backends should override this with a variadic `DEL`/`UNLINK`.
168 async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
169 use futures::future::try_join_all;
170
171 let futures = keys.iter().map(|key| self.delete(key));
172 try_join_all(futures).await?;
173 Ok(())
174 }
175
176 // ========== Batch Operations (Parallel) ==========
177
178 /// Get multiple keys in parallel.
179 ///
180 /// This operation fetches multiple cache keys concurrently, significantly
181 /// reducing total latency compared to sequential gets.
182 ///
183 /// # Arguments
184 ///
185 /// * `keys` - Slice of cache keys to fetch
186 ///
187 /// # Returns
188 ///
189 /// Returns a vector of `Option<String>` in the same order as the input keys.
190 /// `None` indicates the key was not found.
191 ///
192 /// # Performance
193 ///
194 /// - **Sequential:** O(n * network_latency)
195 /// - **Parallel:** O(max(network_latencies)) ≈ O(network_latency)
196 /// - **Speedup:** 10-100x for network-bound operations
197 ///
198 /// # Examples
199 ///
200 /// ```no_run
201 /// # use armature_cache::*;
202 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
203 /// // Fetch 100 user profiles in parallel
204 /// let keys: Vec<String> = (1..=100).map(|i| format!("user:{}", i)).collect();
205 /// let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
206 /// let profiles = cache.get_many(&key_refs).await?;
207 ///
208 /// // Sequential: ~1000ms (10ms * 100)
209 /// // Parallel: ~15ms (max of all parallel requests)
210 /// # Ok(())
211 /// # }
212 /// ```
213 async fn get_many(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
214 // Delegate to the native batch primitive so backends that override
215 // `mget` (e.g. Redis `MGET`) accelerate this path automatically.
216 self.mget(keys).await
217 }
218
219 /// Set multiple key-value pairs in parallel.
220 ///
221 /// # Arguments
222 ///
223 /// * `items` - Slice of (key, value) tuples
224 /// * `ttl` - Optional time-to-live for all keys
225 ///
226 /// # Performance
227 ///
228 /// 10-100x faster than sequential sets for network-bound operations.
229 ///
230 /// # Examples
231 ///
232 /// ```no_run
233 /// # use armature_cache::*;
234 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
235 /// use std::time::Duration;
236 ///
237 /// let items = vec![
238 /// ("user:1", r#"{"name":"Alice"}"#.to_string()),
239 /// ("user:2", r#"{"name":"Bob"}"#.to_string()),
240 /// ];
241 ///
242 /// cache.set_many(&items, Some(Duration::from_secs(3600))).await?;
243 /// # Ok(())
244 /// # }
245 /// ```
246 async fn set_many(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
247 // Delegate to the native batch primitive (e.g. Redis `MSET`/pipeline).
248 self.mset(items, ttl).await
249 }
250
251 /// Delete multiple keys in parallel.
252 ///
253 /// # Arguments
254 ///
255 /// * `keys` - Slice of cache keys to delete
256 ///
257 /// # Performance
258 ///
259 /// 10-100x faster than sequential deletes.
260 ///
261 /// # Examples
262 ///
263 /// ```no_run
264 /// # use armature_cache::*;
265 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
266 /// // Bulk cache invalidation
267 /// let keys = vec!["session:1", "session:2", "session:3"];
268 /// cache.delete_many(&keys).await?;
269 /// # Ok(())
270 /// # }
271 /// ```
272 async fn delete_many(&self, keys: &[&str]) -> CacheResult<()> {
273 // Delegate to the native batch primitive (e.g. Redis variadic `DEL`).
274 self.mdel(keys).await
275 }
276
277 /// Check existence of multiple keys in parallel.
278 ///
279 /// # Arguments
280 ///
281 /// * `keys` - Slice of cache keys to check
282 ///
283 /// # Returns
284 ///
285 /// Returns a vector of booleans in the same order as input keys.
286 ///
287 /// # Examples
288 ///
289 /// ```no_run
290 /// # use armature_cache::*;
291 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
292 /// let keys = vec!["user:1", "user:2", "user:3"];
293 /// let exists = cache.exists_many(&keys).await?;
294 ///
295 /// for (key, exists) in keys.iter().zip(exists.iter()) {
296 /// println!("{}: {}", key, exists);
297 /// }
298 /// # Ok(())
299 /// # }
300 /// ```
301 async fn exists_many(&self, keys: &[&str]) -> CacheResult<Vec<bool>> {
302 use futures::future::try_join_all;
303
304 let futures = keys.iter().map(|key| self.exists(key));
305 try_join_all(futures).await
306 }
307
308 /// Get TTL for multiple keys in parallel.
309 ///
310 /// # Arguments
311 ///
312 /// * `keys` - Slice of cache keys
313 ///
314 /// # Returns
315 ///
316 /// Returns a vector of `Option<Duration>` for each key.
317 ///
318 /// # Examples
319 ///
320 /// ```no_run
321 /// # use armature_cache::*;
322 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
323 /// let keys = vec!["session:1", "session:2"];
324 /// let ttls = cache.ttl_many(&keys).await?;
325 ///
326 /// for (key, ttl) in keys.iter().zip(ttls.iter()) {
327 /// match ttl {
328 /// Some(duration) => println!("{}: expires in {:?}", key, duration),
329 /// None => println!("{}: no expiration", key),
330 /// }
331 /// }
332 /// # Ok(())
333 /// # }
334 /// ```
335 async fn ttl_many(&self, keys: &[&str]) -> CacheResult<Vec<Option<Duration>>> {
336 use futures::future::try_join_all;
337
338 let futures = keys.iter().map(|key| self.ttl(key));
339 try_join_all(futures).await
340 }
341
342 // ========== Set Primitives (persistent, cross-instance indexes) ==========
343 //
344 // Low-level primitives for maintaining a set-of-strings value at a given
345 // key *in the backing store itself*. These exist so higher-level indexes
346 // built on top of a `CacheStore` — e.g. `TaggedCache`'s tag -> member-key
347 // index (see `crate::invalidation`) — are visible to every process /
348 // instance sharing that store, not just the process that wrote them.
349 //
350 // The DEFAULT implementations below are a portable but NON-ATOMIC
351 // read-modify-write layered on `get_json`/`set_json`: correct for a single
352 // writer or low-contention use, but concurrent `set_add`/`set_remove`
353 // calls against the SAME `set_key` from different instances can race and
354 // lose an update (last write wins). Backends with a native set type
355 // should override these three methods for atomicity — `RedisCache` does,
356 // via `SADD`/`SREM`/`SMEMBERS`.
357
358 /// Whether this backend's [`Self::set_add`]/[`Self::set_remove`]/
359 /// [`Self::set_members`] are backed by a native, atomic set type rather
360 /// than the trait's default non-atomic read-modify-write.
361 ///
362 /// `RedisCache` overrides this to return `true` (its implementations use
363 /// `SADD`/`SREM`/`SMEMBERS`). Every other backend — including
364 /// `InMemoryCache` and `MemcachedCache` — keeps this default `false`,
365 /// since they inherit the default set primitives above.
366 ///
367 /// [`crate::invalidation::TaggedCache::new`] checks this capability and
368 /// logs a warning once, at construction time, when the backing store
369 /// answers `false` — giving operators a runtime signal (not just a doc
370 /// comment) that concurrent tag-index updates against that deployment
371 /// can race and silently lose an update.
372 fn supports_atomic_sets(&self) -> bool {
373 false
374 }
375
376 /// Add `member` to the persistent string set stored at `set_key`.
377 ///
378 /// A no-op if `member` is already present.
379 ///
380 /// # Examples
381 ///
382 /// ```no_run
383 /// # use armature_cache::*;
384 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
385 /// // Build a persisted set of member keys under "tag:users".
386 /// cache.set_add("tag:users", "user:1").await?;
387 /// cache.set_add("tag:users", "user:2").await?;
388 /// cache.set_add("tag:users", "user:1").await?; // duplicate: no-op
389 ///
390 /// let members = cache.set_members("tag:users").await?;
391 /// assert_eq!(members.len(), 2);
392 /// # Ok(())
393 /// # }
394 /// ```
395 async fn set_add(&self, set_key: &str, member: &str) -> CacheResult<()> {
396 let mut members = self.set_members(set_key).await?;
397 if !members.iter().any(|m| m == member) {
398 members.push(member.to_string());
399 let json = serde_json::to_string(&members)
400 .map_err(|e| CacheError::Serialization(e.to_string()))?;
401 self.set_json(set_key, json, None).await?;
402 }
403 Ok(())
404 }
405
406 /// Remove `member` from the persistent string set stored at `set_key`.
407 ///
408 /// A no-op if `set_key` or `member` doesn't exist. Deletes `set_key`
409 /// entirely once its last member is removed, so an emptied set doesn't
410 /// linger as a zero-length entry.
411 ///
412 /// # Examples
413 ///
414 /// ```no_run
415 /// # use armature_cache::*;
416 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
417 /// cache.set_add("tag:users", "user:1").await?;
418 /// cache.set_remove("tag:users", "user:1").await?;
419 ///
420 /// // The set is now empty; `set_key` itself is removed rather than left
421 /// // behind as a zero-length entry.
422 /// assert!(cache.set_members("tag:users").await?.is_empty());
423 /// # Ok(())
424 /// # }
425 /// ```
426 async fn set_remove(&self, set_key: &str, member: &str) -> CacheResult<()> {
427 let mut members = self.set_members(set_key).await?;
428 let before = members.len();
429 members.retain(|m| m != member);
430
431 if members.len() != before {
432 if members.is_empty() {
433 self.delete(set_key).await?;
434 } else {
435 let json = serde_json::to_string(&members)
436 .map_err(|e| CacheError::Serialization(e.to_string()))?;
437 self.set_json(set_key, json, None).await?;
438 }
439 }
440 Ok(())
441 }
442
443 /// Add every member of `members` to the set stored at `set_key`.
444 ///
445 /// Backends with a native set type should override this with a single
446 /// variadic command (`SADD key m1 m2 ...`), turning N round-trips into one;
447 /// `RedisCache` does.
448 ///
449 /// The default implementation applies [`Self::set_add`] **sequentially**,
450 /// not concurrently: the default `set_add` is a read-modify-write against
451 /// the same `set_key`, so issuing those concurrently would race with
452 /// itself and drop members.
453 async fn set_add_many(&self, set_key: &str, members: &[&str]) -> CacheResult<()> {
454 for member in members {
455 self.set_add(set_key, member).await?;
456 }
457 Ok(())
458 }
459
460 /// Remove every member of `members` from the set stored at `set_key`.
461 ///
462 /// The variadic counterpart to [`Self::set_remove`]; see
463 /// [`Self::set_add_many`] for why the default implementation is sequential.
464 async fn set_remove_many(&self, set_key: &str, members: &[&str]) -> CacheResult<()> {
465 for member in members {
466 self.set_remove(set_key, member).await?;
467 }
468 Ok(())
469 }
470
471 /// Read every member of the persistent string set stored at `set_key`.
472 ///
473 /// Returns an empty `Vec` if `set_key` doesn't exist.
474 ///
475 /// # Examples
476 ///
477 /// ```no_run
478 /// # use armature_cache::*;
479 /// # async fn example(cache: &impl CacheStore) -> CacheResult<()> {
480 /// cache.set_add("tag:users", "user:1").await?;
481 /// cache.set_add("tag:users", "user:2").await?;
482 ///
483 /// let mut members = cache.set_members("tag:users").await?;
484 /// members.sort();
485 /// assert_eq!(members, vec!["user:1".to_string(), "user:2".to_string()]);
486 ///
487 /// // A set_key that was never written returns an empty Vec.
488 /// assert!(cache.set_members("tag:unused").await?.is_empty());
489 /// # Ok(())
490 /// # }
491 /// ```
492 async fn set_members(&self, set_key: &str) -> CacheResult<Vec<String>> {
493 match self.get_json(set_key).await? {
494 Some(json) => {
495 serde_json::from_str(&json).map_err(|e| CacheError::Deserialization(e.to_string()))
496 }
497 None => Ok(Vec::new()),
498 }
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::tiered::InMemoryCache;
506
507 // `InMemoryCache` does NOT override `mget`/`mset`/`mdel`, so these tests
508 // exercise the trait's DEFAULT (per-key loop) implementations.
509
510 #[tokio::test]
511 async fn test_mget_default_fallback_preserves_order() {
512 let cache = InMemoryCache::new();
513 cache.set_json("a", "1".to_string(), None).await.unwrap();
514 cache.set_json("c", "3".to_string(), None).await.unwrap();
515
516 let got = cache.mget(&["a", "b", "c"]).await.unwrap();
517 assert_eq!(
518 got,
519 vec![Some("1".to_string()), None, Some("3".to_string())]
520 );
521 }
522
523 #[tokio::test]
524 async fn test_mset_and_mdel_default_fallback() {
525 let cache = InMemoryCache::new();
526 cache
527 .mset(&[("x", "10".to_string()), ("y", "20".to_string())], None)
528 .await
529 .unwrap();
530 assert_eq!(cache.get_json("x").await.unwrap(), Some("10".to_string()));
531 assert_eq!(cache.get_json("y").await.unwrap(), Some("20".to_string()));
532
533 cache.mdel(&["x", "y"]).await.unwrap();
534 assert_eq!(cache.get_json("x").await.unwrap(), None);
535 assert_eq!(cache.get_json("y").await.unwrap(), None);
536 }
537
538 #[tokio::test]
539 async fn test_public_batch_methods_delegate_to_primitives() {
540 let cache = InMemoryCache::new();
541 cache.set_json("k1", "v1".to_string(), None).await.unwrap();
542
543 // get_many delegates to mget
544 let got = cache.get_many(&["k1", "k2"]).await.unwrap();
545 assert_eq!(got, vec![Some("v1".to_string()), None]);
546
547 // set_many delegates to mset
548 cache
549 .set_many(&[("k3", "v3".to_string())], None)
550 .await
551 .unwrap();
552 assert_eq!(cache.get_json("k3").await.unwrap(), Some("v3".to_string()));
553
554 // delete_many delegates to mdel
555 cache.delete_many(&["k1", "k3"]).await.unwrap();
556 assert_eq!(cache.get_json("k1").await.unwrap(), None);
557 assert_eq!(cache.get_json("k3").await.unwrap(), None);
558 }
559
560 #[tokio::test]
561 async fn test_mget_empty_keys() {
562 let cache = InMemoryCache::new();
563 let got = cache.mget(&[]).await.unwrap();
564 assert!(got.is_empty());
565 }
566
567 /// Regression for Finding 3: backends that don't override the default,
568 /// non-atomic `set_add`/`set_remove`/`set_members` must report
569 /// `supports_atomic_sets() == false` so callers (e.g. `TaggedCache::new`)
570 /// can warn operators. `InMemoryCache` never overrides these, so it must
571 /// keep the trait's default answer.
572 #[tokio::test]
573 async fn test_supports_atomic_sets_default_is_false() {
574 let cache = InMemoryCache::new();
575 assert!(!cache.supports_atomic_sets());
576 }
577}