gpui_query/core/resource/lifecycle.rs
1//! Query resource lifecycle: begin, cancel, reset, optimistic updates.
2
3use crate::core::{
4 QueryBeginResult, QueryFetchMode, QuerySignal, QueryStatus, QueryTimestamp, RequestGuard,
5 RequestId, RequestPolicy, RequestSequencer,
6};
7
8use super::QueryResource;
9
10/// Source of the [`RequestId`] for the shared `begin_request_inner` helper.
11///
12/// Mirrors the `MaybeRequestId` pattern already used by
13/// `InfiniteQueryResource` to dedup its four entry points. Keeping the two
14/// public `begin_request` / `begin_request_with_id` entry points sharing one
15/// implementation avoids the ~90% duplication flagged in N4, and threads the
16/// stored per-resource sequencer (N3) through the `None` path so transient
17/// callers no longer collide at `RequestId(1,1)`.
18enum MaybeRequestId<'a> {
19 FromSequencer(&'a mut RequestSequencer),
20 Provided(Option<RequestId>),
21}
22
23impl<T, E> QueryResource<T, E> {
24 /// Begin a new request on this resource.
25 ///
26 /// Respects the cache policy (may return `CacheHit`) and request policy
27 /// (`IgnoreWhileLoading` or `LatestWins`). When replacing an existing
28 /// request, the old signal is **cancelled** so the in-flight fetcher
29 /// can observe it and abort early.
30 pub fn begin_request(
31 &mut self,
32 sequencer: &mut RequestSequencer,
33 now_ms: u64,
34 fetch_mode: QueryFetchMode,
35 ) -> QueryBeginResult {
36 self.begin_request_inner(now_ms, fetch_mode, MaybeRequestId::FromSequencer(sequencer))
37 }
38
39 /// Like [`begin_request`](Self::begin_request) but accepts an optional
40 /// pre-generated `RequestId` instead of using a `RequestSequencer`.
41 ///
42 /// When `maybe_request_id` is `Some`, uses that ID directly (useful when
43 /// the bucket's co-located sequencer has already generated the ID).
44 /// When `None`, falls back to the resource's own stored sequencer so the
45 /// generated ids are monotonic and collision-free across calls (N3) rather
46 /// than every call producing a colliding `RequestId(1,1)`.
47 ///
48 /// This is the preferred entry point for the hook layer (audit fixes
49 /// #1/#5/#15/#18): it allows the bucket's persistent sequencer to provide
50 /// globally unique, monotonically increasing RequestIds.
51 pub fn begin_request_with_id(
52 &mut self,
53 maybe_request_id: Option<RequestId>,
54 now_ms: u64,
55 fetch_mode: QueryFetchMode,
56 ) -> QueryBeginResult {
57 self.begin_request_inner(
58 now_ms,
59 fetch_mode,
60 MaybeRequestId::Provided(maybe_request_id),
61 )
62 }
63
64 /// Shared implementation behind [`begin_request`](Self::begin_request) and
65 /// [`begin_request_with_id`](Self::begin_request_with_id) (N4).
66 ///
67 /// `id_source` selects where the request id comes from: an external
68 /// sequencer (for `begin_request`) or a pre-allocated id with a
69 /// per-resource fallback (for `begin_request_with_id`). The fallback uses
70 /// the resource's own stored sequencer (N3) instead of a fresh
71 /// `RequestSequencer::new()`.
72 fn begin_request_inner(
73 &mut self,
74 now_ms: u64,
75 fetch_mode: QueryFetchMode,
76 mut id_source: MaybeRequestId,
77 ) -> QueryBeginResult {
78 // Helper that resolves the next id from whichever source we were given,
79 // evaluated lazily so early-return guards never consume a sequence
80 // number (preserving the original counter-consumption behavior).
81 macro_rules! next_id {
82 () => {{
83 match &mut id_source {
84 MaybeRequestId::FromSequencer(seq) => seq.next_request(),
85 MaybeRequestId::Provided(maybe_id) => {
86 maybe_id.unwrap_or_else(|| self.transient_sequencer.next_request())
87 }
88 }
89 }};
90 }
91
92 // 1. Fresh cache hit — no fetch needed at all.
93 if fetch_mode == QueryFetchMode::Normal && self.should_short_circuit_cache(now_ms) {
94 self.record_cache_hit();
95 return QueryBeginResult::CacheHit;
96 }
97
98 // 2. Stale-while-revalidate: serve stale data immediately, trigger
99 // background refetch. This is checked before the IgnoreWhileLoading
100 // guard so we always revalidate stale data even if another request
101 // is in flight (the new request replaces it via LatestWins below).
102 if fetch_mode == QueryFetchMode::Normal && self.should_serve_stale_and_revalidate(now_ms) {
103 self.record_stale_cache_hit();
104
105 // If IgnoreWhileLoading and a request is already active, skip the
106 // background refetch — an in-flight request will refresh the data.
107 if self.request_policy == RequestPolicy::IgnoreWhileLoading
108 && let Some(active_request_id) = self.active_request_id
109 {
110 return QueryBeginResult::StaleCacheHit {
111 request_id: active_request_id,
112 status: self.status,
113 replaced_request_id: None,
114 };
115 }
116
117 let replaced_request_id = self.active_request_id;
118 if replaced_request_id.is_some() {
119 self.cancelled_count = self.cancelled_count.saturating_add(1);
120 }
121
122 let request_id = next_id!();
123 let status = self.begin_loading(request_id, now_ms);
124 return QueryBeginResult::StaleCacheHit {
125 request_id,
126 status,
127 replaced_request_id,
128 };
129 }
130
131 // 3. IgnoreWhileLoading guard for normal (non-stale) requests.
132 if self.request_policy == RequestPolicy::IgnoreWhileLoading
133 && let Some(active_request_id) = self.active_request_id
134 {
135 return QueryBeginResult::IgnoredWhileLoading { active_request_id };
136 }
137
138 // 4. Normal fetch — start a new request.
139 let replaced_request_id = self.active_request_id;
140 if replaced_request_id.is_some() {
141 self.cancelled_count = self.cancelled_count.saturating_add(1);
142 }
143
144 let request_id = next_id!();
145 let status = self.begin_loading(request_id, now_ms);
146 QueryBeginResult::Started {
147 request_id,
148 status,
149 replaced_request_id,
150 }
151 }
152
153 /// Internal: transition to a loading state.
154 ///
155 /// **v2 fix**: Cancels the old signal before creating a new one,
156 /// so in-flight fetchers for replaced requests can abort early.
157 ///
158 /// Note: This method performs no guard against the current status. Under
159 /// `LatestWins` policy, a second call while already `LoadingEmpty` is
160 /// intentional — it cancels the old request and starts a new one. The old
161 /// request's async task holds a stale `RequestId` and will be rejected by
162 /// `accept_current_request()`.
163 pub(crate) fn begin_loading(&mut self, request_id: RequestId, now_ms: u64) -> QueryStatus {
164 let status = if self.has_data() {
165 QueryStatus::LoadingWithData
166 } else {
167 QueryStatus::LoadingEmpty
168 };
169 self.status = status;
170 self.active_request_id = Some(request_id);
171 self.started_at = Some(QueryTimestamp::from(now_ms));
172 self.error = None;
173
174 // v2 fix: Cancel the OLD signal before replacing it.
175 if let Some(old_signal) = self.signal.as_ref() {
176 old_signal.cancel();
177 }
178 self.signal = Some(QuerySignal::new());
179
180 status
181 }
182
183 /// Whether the given request id is the current active request.
184 pub fn is_current_request(&self, request_id: RequestId) -> bool {
185 self.active_request_id == Some(request_id)
186 }
187
188 /// Accept a request for completion.
189 ///
190 /// Returns a [`RequestGuard`] if the request is still active, or `None`
191 /// if it was replaced or cancelled. The guard is a capability token for
192 /// the two-phase protocol (validate → complete).
193 pub fn accept_current_request(&mut self, request_id: RequestId) -> Option<RequestGuard> {
194 if self.is_current_request(request_id) {
195 self.active_request_id = None;
196 Some(RequestGuard::new(request_id))
197 } else {
198 self.mark_ignored_result();
199 None
200 }
201 }
202
203 /// Cancel the active request.
204 ///
205 /// Returns `false` if there is no active request.
206 /// The signal is cancelled so the in-flight fetcher can observe it.
207 ///
208 /// Data is preserved across cancellations. Current data (if any) is saved
209 /// to `previous_data` before being cleared, allowing recovery via
210 /// `rollback_to_previous()`. This matches TanStack Query behavior where
211 /// cancelling a refetch does not destroy existing data.
212 ///
213 /// When the resource was in `LoadingEmpty` status (no prior data existed),
214 /// both `data` and `previous_data` remain `None`. When the resource was in
215 /// `LoadingWithData` status (a refetch with existing data), the prior data
216 /// is saved to `previous_data` and `data` is set to `None`. Callers can use
217 /// `rollback_to_previous()` to recover the data if needed.
218 pub fn cancel(&mut self, error: E) -> bool {
219 if self.active_request_id.is_none() {
220 return false;
221 }
222
223 self.active_request_id = None;
224 self.status = QueryStatus::Cancelled;
225 self.error = Some(error);
226 self.cancelled_count = self.cancelled_count.saturating_add(1);
227
228 // Save current data to previous_data before clearing so
229 // rollback_to_previous() can recover it.
230 if self.data.is_some() {
231 self.previous_data = self.data.take();
232 }
233
234 if let Some(signal) = self.signal.as_ref() {
235 signal.cancel();
236 }
237
238 true
239 }
240
241 pub fn mark_ignored_result(&mut self) {
242 self.ignored_results = self.ignored_results.saturating_add(1);
243 }
244
245 /// Whether the current data was served from stale cache (i.e., a
246 /// stale-while-revalidate background refetch is in progress or failed).
247 ///
248 /// Returns `true` when the resource has data but the status indicates
249 /// the most recent fetch attempt failed or was cancelled. Consumers can
250 /// use this to distinguish "fresh success" from "stale data still being
251 /// displayed after a background refetch failure".
252 ///
253 /// Note: This is a heuristic check. A `true` result means data exists but
254 /// the last fetch did not succeed — the data may still be perfectly valid.
255 pub fn is_data_stale(&self) -> bool {
256 self.data.is_some()
257 && matches!(
258 self.status,
259 QueryStatus::LoadingWithData | QueryStatus::Failure | QueryStatus::Cancelled
260 )
261 }
262
263 /// Reset the resource back to idle, clearing state and diagnostic counters.
264 ///
265 /// **v2 fix**: Cancels the signal before clearing it.
266 ///
267 /// **Preserves**: `cache_policy`, `request_policy`, `retry_policy`, and `key`.
268 /// These are considered configuration, not runtime state, and persist across
269 /// resets. Use `QueryResource::new()` to create a fully fresh resource with
270 /// default policies.
271 ///
272 /// Calling `reset()` on an already-Idle resource resets diagnostic counters
273 /// (`cache_hits`, `cancelled_count`, `ignored_results`, `retry_count`) to zero.
274 /// This is intentional — `reset()` always resets counters regardless of current
275 /// state. If counter preservation is needed, read them before calling `reset()`.
276 pub fn reset(&mut self) {
277 // Cancel signal before dropping
278 if let Some(signal) = self.signal.as_ref() {
279 signal.cancel();
280 }
281 self.status = QueryStatus::Idle;
282 self.data = None;
283 self.error = None;
284 self.active_request_id = None;
285 self.started_at = None;
286 self.last_updated_at = None;
287 self.cache_hits = 0;
288 self.cancelled_count = 0;
289 self.ignored_results = 0;
290 self.retry_count = 0;
291 self.previous_data = None;
292 self.signal = None;
293 }
294
295 /// Roll back to the previous data (optimistic update undo).
296 ///
297 /// Clears any stored error to maintain the invariant that `Success`
298 /// implies `error is None` (mirroring `apply_success`).
299 pub fn rollback_to_previous(&mut self) -> bool {
300 if let Some(prev) = self.previous_data.take() {
301 self.data = Some(prev);
302 self.status = QueryStatus::Success;
303 self.error = None;
304 return true;
305 }
306 false
307 }
308
309 /// Apply an optimistic update. Current data is saved for rollback.
310 pub fn set_data(&mut self, data: T) {
311 self.previous_data = self.data.take();
312 self.data = Some(data);
313 }
314
315 /// Clear data optimistically. Current data is saved for rollback.
316 ///
317 /// Transitions status to `Idle` to maintain the invariant that `Success`
318 /// implies data is available (mirroring `apply_success_optional`'s `None`
319 /// branch). Without this, a `Success` resource with `data = None` would
320 /// panic on `data.unwrap()`.
321 pub fn clear_data(&mut self) {
322 self.previous_data = self.data.take();
323 if self.status == QueryStatus::Success {
324 self.status = QueryStatus::Idle;
325 }
326 }
327}