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 required_dimensions: Vec<String>,
47}
48
49impl QueryCachePolicy {
50 /// Create an empty cache policy.
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 /// Create a short-lived policy for values that should smooth brief bursts.
56 ///
57 /// The preset uses a 30 second TTL and leaves key/tags to the caller.
58 ///
59 /// # Example
60 ///
61 /// ```rust
62 /// use std::time::Duration;
63 ///
64 /// use hydracache_db::QueryCachePolicy;
65 ///
66 /// let policy = QueryCachePolicy::short_lived().key("user:42");
67 ///
68 /// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(30)));
69 /// assert_eq!(policy.key_value(), Some("user:42"));
70 /// ```
71 pub fn short_lived() -> Self {
72 Self::new().ttl(SHORT_LIVED_TTL)
73 }
74
75 /// Create a read-mostly policy for values that change rarely.
76 ///
77 /// The preset uses a 5 minute TTL. Pair it with entity or collection tags
78 /// so writes can still invalidate cached results explicitly.
79 pub fn read_mostly() -> Self {
80 Self::new().ttl(READ_MOSTLY_TTL)
81 }
82
83 /// Create a policy intended for one entity-shaped result.
84 ///
85 /// The preset uses a 5 minute TTL and expects the caller to add an entity
86 /// key/tag with [`QueryCachePolicy::for_entity`] or
87 /// [`QueryCachePolicy::for_cache_entity`].
88 pub fn per_entity() -> Self {
89 Self::new().ttl(PER_ENTITY_TTL)
90 }
91
92 /// Create a policy for explicit-invalidation-only values.
93 ///
94 /// No TTL is configured. The value remains cached until the caller
95 /// invalidates a key/tag, removes it, flushes the cache, or the backend
96 /// evicts it due to capacity pressure.
97 pub fn no_ttl_explicit_invalidation() -> Self {
98 Self::new()
99 }
100
101 /// Create a policy for caching negative lookups briefly.
102 ///
103 /// Use this for `Option<T>` or domain-specific "not found" results where
104 /// repeated misses are expensive but long-lived absence would be unsafe.
105 /// The preset uses a 30 second TTL.
106 pub fn negative_cache() -> Self {
107 Self::new().ttl(NEGATIVE_CACHE_TTL)
108 }
109
110 /// Create a cache policy with a diagnostic operation name.
111 pub fn named(name: impl Into<String>) -> Self {
112 Self::new().with_name(name)
113 }
114
115 /// Return the optional diagnostic operation name.
116 pub fn name(&self) -> Option<&str> {
117 self.name.as_deref()
118 }
119
120 /// Return the logical key, if one has been configured.
121 pub fn key_value(&self) -> Option<&str> {
122 self.key.as_deref()
123 }
124
125 /// Return configured invalidation tags.
126 pub fn tags_value(&self) -> &[String] {
127 self.tags.as_slice()
128 }
129
130 /// Return the optional per-entry TTL.
131 pub fn ttl_value(&self) -> Option<Duration> {
132 self.ttl
133 }
134
135 /// Return the optional refresh/stale policy.
136 pub fn refresh_policy_value(&self) -> Option<RefreshOptions> {
137 self.refresh
138 }
139
140 /// Return statically declared key dimensions required by this policy.
141 ///
142 /// These labels are diagnostics only; values are intentionally not stored.
143 pub fn required_dimensions_value(&self) -> &[String] {
144 &self.required_dimensions
145 }
146
147 /// Set or replace the diagnostic operation name.
148 pub fn with_name(mut self, name: impl Into<String>) -> Self {
149 self.name = Some(name.into());
150 self
151 }
152
153 /// Set the logical cache key.
154 pub fn key(mut self, key: impl Into<String>) -> Self {
155 self.key = Some(key.into());
156 self
157 }
158
159 /// Set the logical cache key from a segmented key builder.
160 pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
161 self.key(key.build_string())
162 }
163
164 /// Set the logical key and add the same entity invalidation tag.
165 pub fn for_entity(mut self, kind: impl ToString, id: impl ToString) -> Self {
166 let key = entity_key(kind, id);
167 self.key = Some(key.clone());
168 self.tags = self.tags.tag(key);
169 self
170 }
171
172 /// Set the logical key and tags from [`CacheEntity`] metadata.
173 pub fn for_cache_entity<T>(mut self, id: T::Id) -> Self
174 where
175 T: CacheEntity,
176 {
177 let key = T::cache_key_for(&id);
178 self.key = Some(key);
179 self.tags = self.tags.tag(T::entity_tag_for(&id));
180 self.tags = append_optional_tag(self.tags, T::collection_tag());
181 self
182 }
183
184 /// Set the logical key and invalidation tag for a collection result.
185 pub fn collection(mut self, name: impl ToString) -> Self {
186 let tag = collection_tag(name);
187 self.key = Some(tag.clone());
188 self.tags = self.tags.tag(tag);
189 self
190 }
191
192 /// Add one invalidation tag.
193 pub fn tag(mut self, tag: impl Into<String>) -> Self {
194 self.tags = self.tags.tag(tag);
195 self
196 }
197
198 /// Add a collection invalidation tag from one escaped key segment.
199 pub fn collection_tag(mut self, name: impl ToString) -> Self {
200 self.tags = self.tags.tag(collection_tag(name));
201 self
202 }
203
204 /// Add several invalidation tags.
205 pub fn tags<I, S>(mut self, tags: I) -> Self
206 where
207 I: IntoIterator<Item = S>,
208 S: Into<String>,
209 {
210 self.tags = self.tags.tags(tags);
211 self
212 }
213
214 /// Replace invalidation tags from a reusable [`TagSet`].
215 pub fn tag_set(mut self, tags: TagSet) -> Self {
216 self.tags = tags;
217 self
218 }
219
220 /// Set a per-entry TTL.
221 pub fn ttl(mut self, ttl: Duration) -> Self {
222 self.ttl = Some(ttl);
223 self
224 }
225
226 /// Set refresh/stale behavior for this query result.
227 pub fn refresh_policy(mut self, refresh: RefreshOptions) -> Self {
228 self.refresh = Some(refresh);
229 self
230 }
231
232 /// Store statically declared key dimensions for diagnostics and review.
233 pub fn required_dimensions<I, S>(mut self, dimensions: I) -> Self
234 where
235 I: IntoIterator<Item = S>,
236 S: Into<String>,
237 {
238 self.required_dimensions = dimensions.into_iter().map(Into::into).collect();
239 self
240 }
241
242 /// Add one statically declared key dimension for diagnostics and review.
243 pub fn required_dimension(mut self, dimension: impl Into<String>) -> Self {
244 self.required_dimensions.push(dimension.into());
245 self
246 }
247
248 pub(crate) fn cache_options(&self) -> CacheOptions {
249 let mut options = CacheOptions::new().tag_set(self.tags.clone());
250 if let Some(ttl) = self.ttl {
251 options = options.ttl(ttl);
252 }
253 options
254 }
255}
256
257pub(crate) fn entity_key(kind: impl ToString, id: impl ToString) -> String {
258 CacheKeyBuilder::new().entity(kind, id).build_string()
259}
260
261pub(crate) fn collection_tag(name: impl ToString) -> String {
262 CacheKeyBuilder::from_segment(name).build_string()
263}
264
265fn append_optional_tag(tags: TagSet, tag: Option<String>) -> TagSet {
266 match tag {
267 Some(tag) => tags.tag(tag),
268 None => tags,
269 }
270}