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}