Skip to main content

hydracache_db/
policy.rs

1use std::time::Duration;
2
3use hydracache::{CacheKeyBuilder, CacheOptions, RefreshOptions, TagSet};
4
5use crate::CacheEntity;
6
7const SHORT_LIVED_TTL: Duration = Duration::from_secs(30);
8const READ_MOSTLY_TTL: Duration = Duration::from_secs(300);
9const PER_ENTITY_TTL: Duration = Duration::from_secs(300);
10const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(30);
11
12/// Reusable cache metadata for one database query result.
13///
14/// `QueryCachePolicy` contains the database-neutral parts of query result
15/// caching: diagnostic name, logical key, invalidation tags, and optional TTL.
16/// It is intentionally independent of SQLx, Diesel, SeaORM, or any other
17/// database client.
18///
19/// # Example
20///
21/// ```rust
22/// use std::time::Duration;
23///
24/// use hydracache_db::QueryCachePolicy;
25///
26/// let policy = QueryCachePolicy::named("load-user")
27///     .key("user:42")
28///     .tag("user:42")
29///     .ttl(Duration::from_secs(60));
30///
31/// assert_eq!(policy.name(), Some("load-user"));
32/// assert_eq!(policy.key_value(), Some("user:42"));
33/// assert_eq!(policy.tags_value(), &["user:42".to_owned()]);
34/// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(60)));
35/// ```
36///
37/// The [`query_cache_policy!`](crate::query_cache_policy) macro provides a
38/// shorter declarative form when the policy is known at the call site.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct QueryCachePolicy {
41    name: Option<String>,
42    key: Option<String>,
43    tags: TagSet,
44    ttl: Option<Duration>,
45    refresh: Option<RefreshOptions>,
46}
47
48impl QueryCachePolicy {
49    /// Create an empty cache policy.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Create a short-lived policy for values that should smooth brief bursts.
55    ///
56    /// The preset uses a 30 second TTL and leaves key/tags to the caller.
57    ///
58    /// # Example
59    ///
60    /// ```rust
61    /// use std::time::Duration;
62    ///
63    /// use hydracache_db::QueryCachePolicy;
64    ///
65    /// let policy = QueryCachePolicy::short_lived().key("user:42");
66    ///
67    /// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(30)));
68    /// assert_eq!(policy.key_value(), Some("user:42"));
69    /// ```
70    pub fn short_lived() -> Self {
71        Self::new().ttl(SHORT_LIVED_TTL)
72    }
73
74    /// Create a read-mostly policy for values that change rarely.
75    ///
76    /// The preset uses a 5 minute TTL. Pair it with entity or collection tags
77    /// so writes can still invalidate cached results explicitly.
78    pub fn read_mostly() -> Self {
79        Self::new().ttl(READ_MOSTLY_TTL)
80    }
81
82    /// Create a policy intended for one entity-shaped result.
83    ///
84    /// The preset uses a 5 minute TTL and expects the caller to add an entity
85    /// key/tag with [`QueryCachePolicy::for_entity`] or
86    /// [`QueryCachePolicy::for_cache_entity`].
87    pub fn per_entity() -> Self {
88        Self::new().ttl(PER_ENTITY_TTL)
89    }
90
91    /// Create a policy for explicit-invalidation-only values.
92    ///
93    /// No TTL is configured. The value remains cached until the caller
94    /// invalidates a key/tag, removes it, flushes the cache, or the backend
95    /// evicts it due to capacity pressure.
96    pub fn no_ttl_explicit_invalidation() -> Self {
97        Self::new()
98    }
99
100    /// Create a policy for caching negative lookups briefly.
101    ///
102    /// Use this for `Option<T>` or domain-specific "not found" results where
103    /// repeated misses are expensive but long-lived absence would be unsafe.
104    /// The preset uses a 30 second TTL.
105    pub fn negative_cache() -> Self {
106        Self::new().ttl(NEGATIVE_CACHE_TTL)
107    }
108
109    /// Create a cache policy with a diagnostic operation name.
110    pub fn named(name: impl Into<String>) -> Self {
111        Self::new().with_name(name)
112    }
113
114    /// Return the optional diagnostic operation name.
115    pub fn name(&self) -> Option<&str> {
116        self.name.as_deref()
117    }
118
119    /// Return the logical key, if one has been configured.
120    pub fn key_value(&self) -> Option<&str> {
121        self.key.as_deref()
122    }
123
124    /// Return configured invalidation tags.
125    pub fn tags_value(&self) -> &[String] {
126        self.tags.as_slice()
127    }
128
129    /// Return the optional per-entry TTL.
130    pub fn ttl_value(&self) -> Option<Duration> {
131        self.ttl
132    }
133
134    /// Return the optional refresh/stale policy.
135    pub fn refresh_policy_value(&self) -> Option<RefreshOptions> {
136        self.refresh
137    }
138
139    /// Set or replace the diagnostic operation name.
140    pub fn with_name(mut self, name: impl Into<String>) -> Self {
141        self.name = Some(name.into());
142        self
143    }
144
145    /// Set the logical cache key.
146    pub fn key(mut self, key: impl Into<String>) -> Self {
147        self.key = Some(key.into());
148        self
149    }
150
151    /// Set the logical cache key from a segmented key builder.
152    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
153        self.key(key.build_string())
154    }
155
156    /// Set the logical key and add the same entity invalidation tag.
157    pub fn for_entity(mut self, kind: impl ToString, id: impl ToString) -> Self {
158        let key = entity_key(kind, id);
159        self.key = Some(key.clone());
160        self.tags = self.tags.tag(key);
161        self
162    }
163
164    /// Set the logical key and tags from [`CacheEntity`] metadata.
165    pub fn for_cache_entity<T>(mut self, id: T::Id) -> Self
166    where
167        T: CacheEntity,
168    {
169        let key = T::cache_key_for(&id);
170        self.key = Some(key);
171        self.tags = self.tags.tag(T::entity_tag_for(&id));
172        self.tags = append_optional_tag(self.tags, T::collection_tag());
173        self
174    }
175
176    /// Set the logical key and invalidation tag for a collection result.
177    pub fn collection(mut self, name: impl ToString) -> Self {
178        let tag = collection_tag(name);
179        self.key = Some(tag.clone());
180        self.tags = self.tags.tag(tag);
181        self
182    }
183
184    /// Add one invalidation tag.
185    pub fn tag(mut self, tag: impl Into<String>) -> Self {
186        self.tags = self.tags.tag(tag);
187        self
188    }
189
190    /// Add a collection invalidation tag from one escaped key segment.
191    pub fn collection_tag(mut self, name: impl ToString) -> Self {
192        self.tags = self.tags.tag(collection_tag(name));
193        self
194    }
195
196    /// Add several invalidation tags.
197    pub fn tags<I, S>(mut self, tags: I) -> Self
198    where
199        I: IntoIterator<Item = S>,
200        S: Into<String>,
201    {
202        self.tags = self.tags.tags(tags);
203        self
204    }
205
206    /// Replace invalidation tags from a reusable [`TagSet`].
207    pub fn tag_set(mut self, tags: TagSet) -> Self {
208        self.tags = tags;
209        self
210    }
211
212    /// Set a per-entry TTL.
213    pub fn ttl(mut self, ttl: Duration) -> Self {
214        self.ttl = Some(ttl);
215        self
216    }
217
218    /// Set refresh/stale behavior for this query result.
219    pub fn refresh_policy(mut self, refresh: RefreshOptions) -> Self {
220        self.refresh = Some(refresh);
221        self
222    }
223
224    pub(crate) fn cache_options(&self) -> CacheOptions {
225        let mut options = CacheOptions::new().tag_set(self.tags.clone());
226        if let Some(ttl) = self.ttl {
227            options = options.ttl(ttl);
228        }
229        options
230    }
231}
232
233pub(crate) fn entity_key(kind: impl ToString, id: impl ToString) -> String {
234    CacheKeyBuilder::new().entity(kind, id).build_string()
235}
236
237pub(crate) fn collection_tag(name: impl ToString) -> String {
238    CacheKeyBuilder::from_segment(name).build_string()
239}
240
241fn append_optional_tag(tags: TagSet, tag: Option<String>) -> TagSet {
242    match tag {
243        Some(tag) => tags.tag(tag),
244        None => tags,
245    }
246}