gpui_query/core/policy.rs
1//! Cache and request policies for query resources.
2
3use serde::{Deserialize, Serialize};
4
5use super::{QueryStatus, RequestId};
6
7/// How cached data is treated when a query is accessed.
8///
9/// - [`NoCache`](CachePolicy::NoCache): Always fetch fresh data.
10/// - [`Ttl`](CachePolicy::Ttl): Use cached data if fresh (within TTL).
11/// - [`StaleWhileRevalidate`](CachePolicy::StaleWhileRevalidate): Return stale
12/// data immediately while fetching fresh data in the background.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub enum CachePolicy {
15 /// Never cache — always fetch fresh data.
16 NoCache,
17 /// Cache with a time-to-live. Data is considered fresh within the TTL.
18 ///
19 /// **Note:** `ttl_ms` should be greater than zero. A `ttl_ms` of `0` is
20 /// equivalent to [`NoCache`](Self::NoCache) for all practical purposes —
21 /// data is only "fresh" at the exact instant it is stored, so every
22 /// `begin_request` call triggers a new fetch. This is not validated at
23 /// runtime in release builds, but a `debug_assert` will fire in debug
24 /// builds if `ttl_ms` is zero.
25 Ttl { ttl_ms: u64 },
26 /// Return stale data immediately while revalidating in the background.
27 ///
28 /// Data within `ttl_ms` is served as a fresh cache hit (no refetch).
29 /// Data between `ttl_ms` and `ttl_ms + stale_ms` is served as stale data
30 /// **and** a background revalidation is triggered.
31 /// After `ttl_ms + stale_ms`, data is considered expired and a normal
32 /// fetch is performed (no stale data served).
33 ///
34 /// **Note:** `stale_ms` should be greater than zero. Setting `stale_ms`
35 /// to `0` effectively disables the stale-while-revalidate feature,
36 /// degenerating to pure TTL behavior (the stale window is an empty set).
37 /// Both `ttl_ms` and `stale_ms` should be greater than zero. This is not
38 /// validated at runtime in release builds, but `debug_assert`s will fire
39 /// in debug builds if either value is zero.
40 StaleWhileRevalidate { ttl_ms: u64, stale_ms: u64 },
41}
42
43impl Default for CachePolicy {
44 fn default() -> Self {
45 Self::Ttl { ttl_ms: 60_000 } // 1 minute default
46 }
47}
48
49impl CachePolicy {
50 /// Human-readable label.
51 ///
52 /// Sub-second values are shown with millisecond precision (e.g. "500ms")
53 /// rather than truncating to "0s" via integer division.
54 ///
55 /// Thin wrapper around the [`Display`](std::fmt::Display) impl that
56 /// allocates a `String`. Prefer `format!("{policy}")` or writing directly
57 /// to a formatter to avoid the heap allocation for log/diagnostic callers.
58 // Audit fix #45: keep label for backward compat; Display writes directly.
59 pub fn label(self) -> String {
60 self.to_string()
61 }
62
63 /// Whether this policy can short-circuit (return cached data without fetching).
64 ///
65 /// Returns `true` for `Ttl` and `StaleWhileRevalidate` since both can serve
66 /// cached data when it is fresh (within the TTL window). The actual freshness
67 /// check is done separately in [`is_fresh`](Self::is_fresh).
68 pub fn can_short_circuit(self) -> bool {
69 matches!(self, Self::Ttl { .. } | Self::StaleWhileRevalidate { .. })
70 }
71
72 /// Whether this policy allows serving stale data while revalidating.
73 pub fn can_serve_stale(self) -> bool {
74 matches!(self, Self::StaleWhileRevalidate { .. })
75 }
76
77 /// The TTL in milliseconds, if applicable.
78 pub fn ttl_ms(self) -> Option<u64> {
79 match self {
80 Self::NoCache => None,
81 Self::Ttl { ttl_ms } | Self::StaleWhileRevalidate { ttl_ms, .. } => Some(ttl_ms),
82 }
83 }
84
85 /// The stale-while-revalidate window in milliseconds beyond TTL.
86 ///
87 /// Returns `None` for policies that are not `StaleWhileRevalidate`.
88 pub fn stale_ms(self) -> Option<u64> {
89 match self {
90 Self::StaleWhileRevalidate { stale_ms, .. } => Some(stale_ms),
91 _ => None,
92 }
93 }
94
95 /// Total valid window (TTL + stale) in milliseconds.
96 ///
97 /// This is the maximum age at which data can still be served under this policy.
98 /// For `Ttl`, this equals `ttl_ms`. For `StaleWhileRevalidate`, it equals
99 /// `ttl_ms + stale_ms`. Returns `None` for `NoCache`.
100 ///
101 /// On overflow (extremely large `ttl_ms + stale_ms`), saturates to `u64::MAX`,
102 /// effectively treating the data as indefinitely valid.
103 pub fn total_valid_ms(self) -> Option<u64> {
104 match self {
105 Self::NoCache => None,
106 Self::Ttl { ttl_ms } => {
107 debug_assert!(
108 ttl_ms > 0,
109 "CachePolicy::Ttl with ttl_ms=0 behaves like NoCache"
110 );
111 Some(ttl_ms)
112 }
113 Self::StaleWhileRevalidate { ttl_ms, stale_ms } => {
114 debug_assert!(
115 ttl_ms > 0,
116 "CachePolicy::StaleWhileRevalidate with ttl_ms=0 behaves like NoCache"
117 );
118 debug_assert!(
119 stale_ms > 0,
120 "CachePolicy::StaleWhileRevalidate with stale_ms=0 degenerates to Ttl-only behavior"
121 );
122 Some(ttl_ms.saturating_add(stale_ms))
123 }
124 }
125 }
126
127 /// Whether the data is fresh (within the TTL window).
128 ///
129 /// Returns `false` if the policy has no TTL or the data age exceeds TTL.
130 pub fn is_fresh(self, age_ms: u64) -> bool {
131 self.ttl_ms().map(|ttl| age_ms <= ttl).unwrap_or(false)
132 }
133
134 /// Whether the data is stale but still within the stale-while-revalidate window.
135 ///
136 /// Data is "stale-but-serveable" when:
137 /// - The policy is `StaleWhileRevalidate`
138 /// - Data age is past TTL but within `ttl_ms + stale_ms`
139 pub fn is_stale_but_serveable(self, age_ms: u64) -> bool {
140 match self {
141 Self::StaleWhileRevalidate { ttl_ms, stale_ms } => {
142 let total = ttl_ms.saturating_add(stale_ms);
143 age_ms > ttl_ms && age_ms <= total
144 }
145 _ => false,
146 }
147 }
148
149 /// Whether the data is expired (past the total valid window).
150 ///
151 /// Returns `true` if the data age exceeds the total valid window for this policy.
152 pub fn is_expired(self, age_ms: u64) -> bool {
153 self.total_valid_ms()
154 .map(|total| age_ms > total)
155 .unwrap_or(true) // NoCache always considers data expired
156 }
157}
158
159impl std::fmt::Display for CachePolicy {
160 /// Reproduces the exact strings produced by [`CachePolicy::label`].
161 ///
162 /// The duration formatting is inlined here (mirroring [`format_duration`])
163 /// so that no intermediate `String` is allocated when writing to a
164 /// formatter — the whole point of the `Display` impl.
165 // Audit fix #45: write directly to the formatter, avoiding String allocs.
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 match self {
168 Self::NoCache => write!(f, "No cache"),
169 Self::Ttl { ttl_ms } => {
170 write!(f, "Cache TTL ")?;
171 write_duration(f, *ttl_ms)
172 }
173 Self::StaleWhileRevalidate { ttl_ms, stale_ms } => {
174 write!(f, "Stale-while-revalidate TTL ")?;
175 write_duration(f, *ttl_ms)?;
176 write!(f, " stale ")?;
177 write_duration(f, *stale_ms)
178 }
179 }
180 }
181}
182
183/// Write a duration (in milliseconds) directly to a formatter, mirroring
184/// [`format_duration`] exactly: seconds for `>= 1000ms`, milliseconds otherwise.
185///
186/// This avoids the `String` allocation that `format_duration` performs, while
187/// producing byte-identical output.
188fn write_duration(f: &mut std::fmt::Formatter<'_>, ms: u64) -> std::fmt::Result {
189 if ms >= 1_000 {
190 write!(f, "{}s", ms / 1_000)
191 } else {
192 write!(f, "{ms}ms")
193 }
194}
195
196/// How concurrent requests are handled.
197///
198/// - [`LatestWins`](RequestPolicy::LatestWins): New requests cancel in-flight ones.
199/// - [`IgnoreWhileLoading`](RequestPolicy::IgnoreWhileLoading): New requests are
200/// ignored if one is already in progress.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub enum RequestPolicy {
203 /// New requests replace in-flight ones (default).
204 #[default]
205 LatestWins,
206 /// Ignore new requests while one is already loading.
207 IgnoreWhileLoading,
208}
209
210impl RequestPolicy {
211 /// Human-readable label.
212 pub fn label(self) -> &'static str {
213 match self {
214 Self::LatestWins => "Latest wins",
215 Self::IgnoreWhileLoading => "Ignore while loading",
216 }
217 }
218}
219
220/// Whether the fetch is a normal request or forced (ignoring cache).
221#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
222pub enum QueryFetchMode {
223 /// Normal fetch — respects cache policy.
224 #[default]
225 Normal,
226 /// Force fetch — ignores cache freshness.
227 Force,
228}
229
230/// The result of calling `begin_request` on a query resource.
231#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232#[must_use]
233pub enum QueryBeginResult {
234 /// A new request was started.
235 Started {
236 request_id: RequestId,
237 status: QueryStatus,
238 replaced_request_id: Option<RequestId>,
239 },
240 /// Cache is fresh — no fetch needed.
241 CacheHit,
242 /// Stale data was served and a background revalidation was started.
243 ///
244 /// The caller should:
245 /// 1. Return the existing stale data to the consumer immediately.
246 /// 2. Use the `request_id` to perform a background fetch.
247 /// 3. Complete the request normally via `complete_success`/`complete_failure`.
248 StaleCacheHit {
249 request_id: RequestId,
250 status: QueryStatus,
251 replaced_request_id: Option<RequestId>,
252 },
253 /// A request is already loading and the policy is `IgnoreWhileLoading`.
254 IgnoredWhileLoading { active_request_id: RequestId },
255}
256
257/// Format a duration in milliseconds as a human-readable string.
258///
259/// Shows seconds for values >= 1000ms, milliseconds otherwise.
260/// This avoids the misleading "0s" label that integer division produces
261/// for sub-second values.
262/// Reference formatting impl. The `Display` impls below intentionally inline
263/// this logic (writing directly to the `Formatter`) to avoid the `String`
264/// allocation; this standalone version is retained as the documented reference
265/// and is exercised by the unit tests below. Allowed dead because the lib-only
266/// build has no non-test caller.
267#[allow(dead_code)]
268fn format_duration(ms: u64) -> String {
269 if ms >= 1_000 {
270 format!("{}s", ms / 1_000)
271 } else {
272 format!("{ms}ms")
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn format_duration_shows_millis_for_subsecond() {
282 assert_eq!(format_duration(0), "0ms");
283 assert_eq!(format_duration(500), "500ms");
284 assert_eq!(format_duration(999), "999ms");
285 }
286
287 #[test]
288 fn format_duration_shows_seconds_for_one_second_and_above() {
289 assert_eq!(format_duration(1_000), "1s");
290 assert_eq!(format_duration(60_000), "60s");
291 }
292
293 #[test]
294 fn ttl_zero_label_uses_ms() {
295 let policy = CachePolicy::Ttl { ttl_ms: 0 };
296 assert_eq!(policy.label(), "Cache TTL 0ms");
297 }
298
299 #[test]
300 fn swr_zero_values_label_uses_ms() {
301 let policy = CachePolicy::StaleWhileRevalidate {
302 ttl_ms: 0,
303 stale_ms: 0,
304 };
305 assert_eq!(policy.label(), "Stale-while-revalidate TTL 0ms stale 0ms");
306 }
307
308 #[test]
309 fn total_valid_ms_saturates_on_overflow() {
310 let policy = CachePolicy::StaleWhileRevalidate {
311 ttl_ms: u64::MAX,
312 stale_ms: u64::MAX,
313 };
314 assert_eq!(policy.total_valid_ms(), Some(u64::MAX));
315 }
316}