Skip to main content

gpui_query/core/resource/
cache.rs

1//! Query resource cache logic.
2
3use crate::core::{QueryStatus, QueryTimestamp};
4
5use super::QueryResource;
6
7impl<T, E> QueryResource<T, E> {
8    /// Cache age in milliseconds.
9    pub fn cache_age_ms(&self, now_ms: u64) -> Option<u64> {
10        QueryTimestamp::from(now_ms).elapsed_since(self.last_updated_at?)
11    }
12
13    /// Whether the cache is fresh (within TTL).
14    ///
15    /// For all policies with a TTL, this checks that data exists and the age
16    /// is within the TTL window. The stale-while-revalidate window is NOT
17    /// considered fresh — it is stale-but-serveable (see [`is_stale_but_serveable`]).
18    ///
19    /// **Boundary behavior**: data at exactly TTL milliseconds old is considered
20    /// fresh (`age <= ttl_ms`). Data older than TTL is stale (`age > ttl_ms`).
21    /// This differs from HTTP `Cache-Control: max-age` where the boundary is
22    /// exclusive. The inclusive boundary is chosen so that the fresh/stale
23    /// partition is total: every age is either fresh or stale, with no gap.
24    pub fn is_cache_fresh(&self, now_ms: u64) -> bool {
25        self.has_data()
26            && self
27                .cache_policy
28                .ttl_ms()
29                .zip(self.cache_age_ms(now_ms))
30                .map(|(ttl_ms, age_ms)| age_ms <= ttl_ms)
31                .unwrap_or(false)
32    }
33
34    /// Whether the cache is stale but still within the stale-while-revalidate window.
35    ///
36    /// Returns `true` when:
37    /// - The policy is `StaleWhileRevalidate`
38    /// - Data exists
39    /// - Data age is past TTL but within `ttl_ms + stale_ms`
40    pub fn is_stale_but_serveable(&self, now_ms: u64) -> bool {
41        self.has_data()
42            && self
43                .cache_age_ms(now_ms)
44                .map(|age_ms| self.cache_policy.is_stale_but_serveable(age_ms))
45                .unwrap_or(false)
46    }
47
48    /// Whether the cache is fully expired (past the total valid window).
49    ///
50    /// For `StaleWhileRevalidate`, this means past `ttl_ms + stale_ms`.
51    /// For `Ttl`, this means past `ttl_ms`.
52    /// For `NoCache`, always returns `true` (no data is ever valid).
53    pub fn is_cache_expired(&self, now_ms: u64) -> bool {
54        if !self.has_data() {
55            return true;
56        }
57        self.cache_age_ms(now_ms)
58            .map(|age_ms| self.cache_policy.is_expired(age_ms))
59            .unwrap_or(true)
60    }
61
62    /// Whether the cache can short-circuit (fresh data, no fetch needed).
63    ///
64    /// Only returns `true` when the policy supports short-circuiting AND the
65    /// data is within the TTL window (fresh, not stale).
66    pub fn should_short_circuit_cache(&self, now_ms: u64) -> bool {
67        self.cache_policy.can_short_circuit() && self.is_cache_fresh(now_ms)
68    }
69
70    /// Whether the resource should serve stale data while triggering a background refetch.
71    ///
72    /// This is the core stale-while-revalidate check: data is past its TTL but
73    /// still within the stale window. The caller should:
74    /// 1. Return existing data to the consumer immediately.
75    /// 2. Start a background fetch to revalidate.
76    pub fn should_serve_stale_and_revalidate(&self, now_ms: u64) -> bool {
77        self.cache_policy.can_serve_stale() && self.is_stale_but_serveable(now_ms)
78    }
79
80    /// Record a cache hit.
81    ///
82    /// Increments the hit counter and transitions status to [`Success`](QueryStatus::Success)
83    /// **only if the resource is not in a terminal failure state** (`Failure` or `Cancelled`).
84    /// This prevents a surprising `Failure -> Success` transition without a new fetch
85    /// having occurred. The error is only cleared when transitioning to `Success`.
86    ///
87    /// A cache hit on data that was previously fetched successfully will still set
88    /// `Success` as expected.
89    pub(crate) fn record_cache_hit(&mut self) {
90        self.cache_hits = self.cache_hits.saturating_add(1);
91        // Only transition to Success from non-terminal states.
92        // Failure/Cancelled are terminal — a cache hit on old data should not
93        // silently clear the error a consumer is already handling.
94        if !matches!(self.status, QueryStatus::Failure | QueryStatus::Cancelled) {
95            self.status = QueryStatus::Success;
96            self.error = None;
97        }
98    }
99
100    /// Record a stale cache hit (data served from stale window).
101    ///
102    /// Increments cache hit counter and transitions status to
103    /// [`Success`](QueryStatus::Success) **only if the resource is not in a
104    /// terminal failure state** (`Failure` or `Cancelled`), mirroring
105    /// [`record_cache_hit`]. The caller is expected to also trigger a
106    /// background revalidation.
107    ///
108    /// [`record_cache_hit`]: Self::record_cache_hit
109    pub(crate) fn record_stale_cache_hit(&mut self) {
110        self.cache_hits = self.cache_hits.saturating_add(1);
111        // Mirror record_cache_hit: only transition to Success from
112        // non-terminal states, so a stale hit does not silently clear a
113        // failure error the consumer is already handling.
114        if !matches!(self.status, QueryStatus::Failure | QueryStatus::Cancelled) {
115            self.status = QueryStatus::Success;
116            self.error = None;
117        }
118    }
119
120    /// Invalidate the cache (clear last-updated timestamp).
121    ///
122    /// Data is retained but the resource is considered stale.
123    pub fn invalidate(&mut self) {
124        self.last_updated_at = None;
125    }
126}