Skip to main content

hydracache_db/
policy.rs

1use std::time::Duration;
2
3use hydracache::{CacheKeyBuilder, CacheOptions, RefreshOptions, TagSet};
4
5use crate::{
6    CacheEntity, DeclaredLintMode, DeclaredRelation, DimensionAllow, DimensionProfile,
7    DimensionValidationMode, LintFinding, PolicyLintMetadata, ProfileValidation,
8};
9
10const SHORT_LIVED_TTL: Duration = Duration::from_secs(30);
11const READ_MOSTLY_TTL: Duration = Duration::from_secs(300);
12const PER_ENTITY_TTL: Duration = Duration::from_secs(300);
13const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(30);
14
15/// Reusable cache metadata for one database query result.
16///
17/// `QueryCachePolicy` contains the database-neutral parts of query result
18/// caching: diagnostic name, logical key, invalidation tags, and optional TTL.
19/// It is intentionally independent of SQLx, Diesel, SeaORM, or any other
20/// database client.
21///
22/// # Example
23///
24/// ```rust
25/// use std::time::Duration;
26///
27/// use hydracache_db::QueryCachePolicy;
28///
29/// let policy = QueryCachePolicy::named("load-user")
30///     .key("user:42")
31///     .tag("user:42")
32///     .ttl(Duration::from_secs(60));
33///
34/// assert_eq!(policy.name(), Some("load-user"));
35/// assert_eq!(policy.key_value(), Some("user:42"));
36/// assert_eq!(policy.tags_value(), &["user:42".to_owned()]);
37/// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(60)));
38/// ```
39///
40/// The [`query_cache_policy!`](crate::query_cache_policy) macro provides a
41/// shorter declarative form when the policy is known at the call site.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct QueryCachePolicy {
44    name: Option<String>,
45    key: Option<String>,
46    tags: TagSet,
47    ttl: Option<Duration>,
48    refresh: Option<RefreshOptions>,
49    required_dimensions: Vec<String>,
50    key_dimension_labels: Vec<String>,
51    tag_dimension_labels: Vec<String>,
52    dimension_profile: Option<DimensionProfile>,
53    dimension_validation_mode: DimensionValidationMode,
54    dimension_allow: Vec<DimensionAllow>,
55    lint_metadata: Option<PolicyLintMetadata>,
56}
57
58impl QueryCachePolicy {
59    /// Create an empty cache policy.
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Create a short-lived policy for values that should smooth brief bursts.
65    ///
66    /// The preset uses a 30 second TTL and leaves key/tags to the caller.
67    ///
68    /// # Example
69    ///
70    /// ```rust
71    /// use std::time::Duration;
72    ///
73    /// use hydracache_db::QueryCachePolicy;
74    ///
75    /// let policy = QueryCachePolicy::short_lived().key("user:42");
76    ///
77    /// assert_eq!(policy.ttl_value(), Some(Duration::from_secs(30)));
78    /// assert_eq!(policy.key_value(), Some("user:42"));
79    /// ```
80    pub fn short_lived() -> Self {
81        Self::new().ttl(SHORT_LIVED_TTL)
82    }
83
84    /// Create a read-mostly policy for values that change rarely.
85    ///
86    /// The preset uses a 5 minute TTL. Pair it with entity or collection tags
87    /// so writes can still invalidate cached results explicitly.
88    pub fn read_mostly() -> Self {
89        Self::new().ttl(READ_MOSTLY_TTL)
90    }
91
92    /// Create a policy intended for one entity-shaped result.
93    ///
94    /// The preset uses a 5 minute TTL and expects the caller to add an entity
95    /// key/tag with [`QueryCachePolicy::for_entity`] or
96    /// [`QueryCachePolicy::for_cache_entity`].
97    pub fn per_entity() -> Self {
98        Self::new().ttl(PER_ENTITY_TTL)
99    }
100
101    /// Create a policy for explicit-invalidation-only values.
102    ///
103    /// No TTL is configured. The value remains cached until the caller
104    /// invalidates a key/tag, removes it, flushes the cache, or the backend
105    /// evicts it due to capacity pressure.
106    pub fn no_ttl_explicit_invalidation() -> Self {
107        Self::new()
108    }
109
110    /// Create a policy for caching negative lookups briefly.
111    ///
112    /// Use this for `Option<T>` or domain-specific "not found" results where
113    /// repeated misses are expensive but long-lived absence would be unsafe.
114    /// The preset uses a 30 second TTL.
115    pub fn negative_cache() -> Self {
116        Self::new().ttl(NEGATIVE_CACHE_TTL)
117    }
118
119    /// Create a cache policy with a diagnostic operation name.
120    pub fn named(name: impl Into<String>) -> Self {
121        Self::new().with_name(name)
122    }
123
124    /// Return the optional diagnostic operation name.
125    pub fn name(&self) -> Option<&str> {
126        self.name.as_deref()
127    }
128
129    /// Return the logical key, if one has been configured.
130    pub fn key_value(&self) -> Option<&str> {
131        self.key.as_deref()
132    }
133
134    /// Return configured invalidation tags.
135    pub fn tags_value(&self) -> &[String] {
136        self.tags.as_slice()
137    }
138
139    /// Return the optional per-entry TTL.
140    pub fn ttl_value(&self) -> Option<Duration> {
141        self.ttl
142    }
143
144    /// Return the optional refresh/stale policy.
145    pub fn refresh_policy_value(&self) -> Option<RefreshOptions> {
146        self.refresh
147    }
148
149    /// Return statically declared key dimensions required by this policy.
150    ///
151    /// These labels are diagnostics only; values are intentionally not stored.
152    pub fn required_dimensions_value(&self) -> &[String] {
153        &self.required_dimensions
154    }
155
156    /// Return static key dimension labels recorded by macro/profile tooling.
157    pub fn key_dimension_labels(&self) -> &[String] {
158        &self.key_dimension_labels
159    }
160
161    /// Return static tag dimension labels recorded by macro/profile tooling.
162    pub fn tag_dimension_labels(&self) -> &[String] {
163        &self.tag_dimension_labels
164    }
165
166    /// Return the optional required-dimension profile.
167    pub fn dimension_profile(&self) -> Option<&DimensionProfile> {
168        self.dimension_profile.as_ref()
169    }
170
171    /// Return the profile validation mode.
172    pub fn dimension_validation_mode(&self) -> DimensionValidationMode {
173        self.dimension_validation_mode
174    }
175
176    /// Return optional SQL dependency-lint metadata for CI/build-time tooling.
177    pub fn lint_metadata(&self) -> Option<&PolicyLintMetadata> {
178        self.lint_metadata.as_ref()
179    }
180
181    /// Set or replace the diagnostic operation name.
182    pub fn with_name(mut self, name: impl Into<String>) -> Self {
183        self.name = Some(name.into());
184        self
185    }
186
187    /// Set the logical cache key.
188    pub fn key(mut self, key: impl Into<String>) -> Self {
189        self.key = Some(key.into());
190        self
191    }
192
193    /// Set the logical cache key from a segmented key builder.
194    pub fn key_builder(self, key: CacheKeyBuilder) -> Self {
195        self.key(key.build_string())
196    }
197
198    /// Set the logical key and add the same entity invalidation tag.
199    pub fn for_entity(mut self, kind: impl ToString, id: impl ToString) -> Self {
200        let key = entity_key(kind, id);
201        self.key = Some(key.clone());
202        self.tags = self.tags.tag(key);
203        self
204    }
205
206    /// Set the logical key and tags from [`CacheEntity`] metadata.
207    pub fn for_cache_entity<T>(mut self, id: T::Id) -> Self
208    where
209        T: CacheEntity,
210    {
211        let key = T::cache_key_for(&id);
212        self.key = Some(key);
213        self.tags = self.tags.tag(T::entity_tag_for(&id));
214        self.tags = append_optional_tag(self.tags, T::collection_tag());
215        self
216    }
217
218    /// Set the logical key and invalidation tag for a collection result.
219    pub fn collection(mut self, name: impl ToString) -> Self {
220        let tag = collection_tag(name);
221        self.key = Some(tag.clone());
222        self.tags = self.tags.tag(tag);
223        self
224    }
225
226    /// Add one invalidation tag.
227    pub fn tag(mut self, tag: impl Into<String>) -> Self {
228        self.tags = self.tags.tag(tag);
229        self
230    }
231
232    /// Add a collection invalidation tag from one escaped key segment.
233    pub fn collection_tag(mut self, name: impl ToString) -> Self {
234        self.tags = self.tags.tag(collection_tag(name));
235        self
236    }
237
238    /// Add several invalidation tags.
239    pub fn tags<I, S>(mut self, tags: I) -> Self
240    where
241        I: IntoIterator<Item = S>,
242        S: Into<String>,
243    {
244        self.tags = self.tags.tags(tags);
245        self
246    }
247
248    /// Replace invalidation tags from a reusable [`TagSet`].
249    pub fn tag_set(mut self, tags: TagSet) -> Self {
250        self.tags = tags;
251        self
252    }
253
254    /// Set a per-entry TTL.
255    pub fn ttl(mut self, ttl: Duration) -> Self {
256        self.ttl = Some(ttl);
257        self
258    }
259
260    /// Set refresh/stale behavior for this query result.
261    pub fn refresh_policy(mut self, refresh: RefreshOptions) -> Self {
262        self.refresh = Some(refresh);
263        self
264    }
265
266    /// Store statically declared key dimensions for diagnostics and review.
267    pub fn required_dimensions<I, S>(mut self, dimensions: I) -> Self
268    where
269        I: IntoIterator<Item = S>,
270        S: Into<String>,
271    {
272        self.required_dimensions = dimensions.into_iter().map(Into::into).collect();
273        self
274    }
275
276    /// Add one statically declared key dimension for diagnostics and review.
277    pub fn required_dimension(mut self, dimension: impl Into<String>) -> Self {
278        self.required_dimensions.push(dimension.into());
279        self
280    }
281
282    /// Store key dimension labels for profile validation.
283    pub fn with_key_dimension_labels<I, S>(mut self, labels: I) -> Self
284    where
285        I: IntoIterator<Item = S>,
286        S: Into<String>,
287    {
288        self.key_dimension_labels = labels.into_iter().map(Into::into).collect();
289        self
290    }
291
292    /// Store tag dimension labels for profile validation.
293    pub fn with_tag_dimension_labels<I, S>(mut self, labels: I) -> Self
294    where
295        I: IntoIterator<Item = S>,
296        S: Into<String>,
297    {
298        self.tag_dimension_labels = labels.into_iter().map(Into::into).collect();
299        self
300    }
301
302    /// Attach a reusable required-dimension profile.
303    pub fn with_dimension_profile(mut self, profile: DimensionProfile) -> Self {
304        let required = profile
305            .requirements()
306            .into_iter()
307            .map(|requirement| requirement.label().to_owned());
308        self.required_dimensions.extend(required);
309        self.dimension_profile = Some(profile);
310        self
311    }
312
313    /// Set whether profile violations should warn or fail a CI/release gate.
314    pub fn with_dimension_validation_mode(mut self, mode: DimensionValidationMode) -> Self {
315        self.dimension_validation_mode = mode;
316        self
317    }
318
319    /// Allow one profile dimension violation with an explicit review reason.
320    pub fn allow_dimension_violation(
321        mut self,
322        label: impl Into<String>,
323        reason: impl Into<String>,
324    ) -> std::result::Result<Self, crate::DimensionAllowError> {
325        self.dimension_allow
326            .push(DimensionAllow::new(label, reason)?);
327        Ok(self)
328    }
329
330    /// Validate the configured profile against recorded key/tag labels.
331    pub fn validate_dimension_profile(&self) -> ProfileValidation {
332        let Some(profile) = &self.dimension_profile else {
333            return ProfileValidation::Pass;
334        };
335
336        let mut missing = Vec::new();
337        let mut unlinked = Vec::new();
338        for requirement in profile.requirements() {
339            let label = requirement.label();
340            if !self.key_dimension_labels.iter().any(|known| known == label) {
341                missing.push(label.to_owned());
342            } else if requirement.require_key_tag_link()
343                && !self.tag_dimension_labels.iter().any(|known| known == label)
344            {
345                unlinked.push(label.to_owned());
346            }
347        }
348
349        let status = if !missing.is_empty() {
350            ProfileValidation::MissingDimensions(missing)
351        } else if !unlinked.is_empty() {
352            ProfileValidation::UnlinkedDimensions(unlinked)
353        } else {
354            ProfileValidation::Pass
355        };
356
357        self.apply_dimension_allow(status)
358    }
359
360    /// Return an error when profile validation is in deny mode and fails.
361    pub fn enforce_dimension_profile(&self) -> crate::Result<()> {
362        let status = self.validate_dimension_profile();
363        if self.dimension_validation_mode == DimensionValidationMode::Deny && !status.is_pass() {
364            return Err(hydracache::CacheError::Backend(format!(
365                "query cache policy dimension profile violation: {status}"
366            ))
367            .into());
368        }
369        Ok(())
370    }
371
372    /// Attach SQL text for off-runtime dependency linting.
373    pub fn lint_sql(mut self, sql: impl Into<String>) -> Self {
374        self.lint_metadata_mut().sql = Some(sql.into());
375        self
376    }
377
378    /// Set the lint mode used by CI/build-time dependency checking.
379    pub fn dependency_lint_mode(mut self, mode: DeclaredLintMode) -> Self {
380        self.lint_metadata_mut().mode = mode;
381        self
382    }
383
384    /// Declare one relation that the query is expected to read.
385    pub fn declared_dependency(mut self, relation: DeclaredRelation) -> Self {
386        self.lint_metadata_mut().declared.push(relation);
387        self
388    }
389
390    /// Declare several relations that the query is expected to read.
391    pub fn declared_dependencies<I>(mut self, relations: I) -> Self
392    where
393        I: IntoIterator<Item = DeclaredRelation>,
394    {
395        self.lint_metadata_mut().declared.extend(relations);
396        self
397    }
398
399    /// Suppress one dependency-lint finding with an explicit reason.
400    pub fn lint_allow(mut self, finding: LintFinding, reason: impl Into<String>) -> Self {
401        self.lint_metadata_mut()
402            .suppressions
403            .push(crate::LintSuppression::new(finding, reason));
404        self
405    }
406
407    pub(crate) fn cache_options(&self) -> CacheOptions {
408        let mut options = CacheOptions::new().tag_set(self.tags.clone());
409        if let Some(ttl) = self.ttl {
410            options = options.ttl(ttl);
411        }
412        options
413    }
414
415    fn lint_metadata_mut(&mut self) -> &mut PolicyLintMetadata {
416        self.lint_metadata
417            .get_or_insert_with(PolicyLintMetadata::default)
418    }
419
420    fn apply_dimension_allow(&self, status: ProfileValidation) -> ProfileValidation {
421        let labels = match &status {
422            ProfileValidation::MissingDimensions(labels)
423            | ProfileValidation::UnlinkedDimensions(labels) => labels,
424            _ => return status,
425        };
426
427        let Some(allow) = self
428            .dimension_allow
429            .iter()
430            .find(|allow| labels.iter().any(|label| label == allow.label()))
431        else {
432            return status;
433        };
434
435        ProfileValidation::Allowed {
436            status: Box::new(status),
437            reason: allow.reason().to_owned(),
438        }
439    }
440}
441
442pub(crate) fn entity_key(kind: impl ToString, id: impl ToString) -> String {
443    CacheKeyBuilder::new().entity(kind, id).build_string()
444}
445
446pub(crate) fn collection_tag(name: impl ToString) -> String {
447    CacheKeyBuilder::from_segment(name).build_string()
448}
449
450fn append_optional_tag(tags: TagSet, tag: Option<String>) -> TagSet {
451    match tag {
452        Some(tag) => tags.tag(tag),
453        None => tags,
454    }
455}