Skip to main content

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}