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}