Skip to main content

gpui_query/client/
lifecycle.rs

1//! Lifecycle operations on `QueryClient`: GC, diagnostics, serialization,
2//! persistence, and imperative fetch/prefetch.
3//!
4//! This module contains `impl QueryClient` methods for:
5//! - Garbage collection (`gc`, `gc_with_time`)
6//! - Test helpers for deterministic GC (snapshot updates, retain/release)
7//! - Diagnostics
8//! - Dehydration/hydration for state serialization
9//! - Persistence via `QueryPersister`
10//! - Imperative fetch and prefetch operations
11
12use gpui::App;
13
14use crate::client::devtools::ClientDiagnostic;
15#[cfg(feature = "persist")]
16use crate::client::devtools::{DehydratedEntry, DehydratedState};
17use crate::client::prepared_fetch::PreparedFetch;
18use crate::client::time::current_time_ms;
19use crate::core::{CachePolicy, QueryKey, RequestPolicy};
20#[cfg(feature = "persist")]
21use crate::core::{MutationStatus, QueryStatus};
22
23use super::QueryClient;
24#[cfg(feature = "persist")]
25use crate::client::erased::QueryPersister;
26
27impl QueryClient {
28    // ── Garbage collection ──────────────────────────────────────────────
29
30    /// Run garbage collection on all buckets.
31    ///
32    /// Calls `current_time_ms()` internally to get the current time. If you
33    /// already have a cached time value, use [`gc_with_time`] to avoid the
34    /// syscall overhead (Audit 3, Finding 2).
35    pub fn gc(&mut self, cx: &App) {
36        let now_ms = current_time_ms();
37        self.gc_with_time(now_ms, cx);
38    }
39
40    /// Run garbage collection with a pre-computed time value (Audit 3, Finding 2).
41    ///
42    /// Use this when you call GC frequently and want to amortize the cost of
43    /// `SystemTime::now()` across multiple calls. The `now_ms` parameter should
44    /// be milliseconds since the UNIX epoch (as returned by [`current_time_ms`]).
45    ///
46    /// **L5**: sets `self.last_gc_ms = now_ms` at the top so a *manual* GC call
47    /// debounces the next opportunistic GC sweep (otherwise the caller's
48    /// explicit `gc()` would not push back the `MIN_GC_TIME_MS` window and the
49    /// next op could immediately re-trigger GC).
50    pub fn gc_with_time(&mut self, now_ms: u64, cx: &App) {
51        self.last_gc_ms = now_ms;
52        for bucket in self.buckets.values_mut() {
53            bucket.gc(now_ms, self.gc_time_ms, cx);
54        }
55        for bucket in self.infinite_buckets.values_mut() {
56            bucket.gc(now_ms, self.gc_time_ms, cx);
57        }
58        for bucket in self.mutation_buckets.values_mut() {
59            bucket.gc(now_ms, self.gc_time_ms, cx);
60        }
61    }
62
63    // ── Test helpers ───────────────────────────────────────────────────
64    //
65    // The previous `update_*_snapshot` / `retain_*` / `release_*` helpers were
66    // removed: GC now reads live entity state directly via `entity.read(cx)`
67    // (audit #CL2/#106), so there is no cached `StatusSnapshot` to set; and
68    // `observer_count` was removed (audit #8) in favor of `WeakEntity::upgrade()`
69    // liveness, so there is no retain/release to drive. Tests that need a
70    // specific GC state now simply transition the entity itself (e.g.
71    // `apply_success`, `begin_fetch_next`) — GC observes that real state.
72
73    // ── Diagnostics (Audit 3, Finding 7) ────────────────────────────────
74
75    /// Get diagnostics for all queries and mutations.
76    ///
77    /// Returns aggregate counts and per-resource diagnostic details. The
78    /// `queries` and `mutations` vectors are populated by iterating all bucket
79    /// entries, upgrading weak references, and reading entity state. Dead
80    /// entries (collected entities) are skipped.
81    ///
82    /// **Audit 3 fix**: Previously returned empty `queries: Vec::new()` and
83    /// `mutations: Vec::new()` vectors. Now fully populates per-resource
84    /// diagnostics via `collect_diagnostics` on each erased bucket.
85    pub fn diagnostics(&self, cx: &App) -> ClientDiagnostic {
86        let now_ms = current_time_ms();
87        // L1: pre-size the diagnostic Vecs from the bucket `count()` sums so the
88        // per-bucket `collect_diagnostics_into` pushes (L3) don't repeatedly
89        // reallocate the destination Vec as it grows. `count()` is
90        // `entries.len()` — exact for live entries, an upper bound for the
91        // diagnostics (dead entries are skipped), so this never under-allocates.
92        let mut query_count = 0;
93        let mut mutation_count = 0;
94        for bucket in self.buckets.values() {
95            query_count += bucket.count();
96        }
97        for bucket in self.infinite_buckets.values() {
98            query_count += bucket.count();
99        }
100        for bucket in self.mutation_buckets.values() {
101            mutation_count += bucket.count();
102        }
103        let mut queries = Vec::with_capacity(query_count);
104        let mut mutations = Vec::with_capacity(mutation_count);
105
106        // L3: push each bucket's diagnostics straight into the single pre-sized
107        // Vec via the sink variant — avoids the per-bucket `Vec` allocation +
108        // `extend` that the returning `collect_diagnostics` variant forces.
109        for bucket in self.buckets.values() {
110            bucket.collect_diagnostics_into(now_ms, cx, &mut queries);
111        }
112        for bucket in self.infinite_buckets.values() {
113            bucket.collect_diagnostics_into(now_ms, cx, &mut queries);
114        }
115        for bucket in self.mutation_buckets.values() {
116            bucket.collect_diagnostics_into(cx, &mut mutations);
117        }
118
119        ClientDiagnostic {
120            query_count,
121            mutation_count,
122            queries,
123            mutations,
124        }
125    }
126
127    // ── Serialization / Hydration (Audit 3, Finding 8) ──────────────────
128
129    /// Serialize all cached query state into a portable format.
130    ///
131    /// Extracts all live query resources, recording their keys, status, and
132    /// type information. The resulting [`DehydratedState`] can be persisted
133    /// to disk or stored for later restoration via [`hydrate`].
134    ///
135    /// Only resources with `Success` status are included. Resources in
136    /// `Idle`, `Loading`, `Failure`, or `Cancelled` states are skipped.
137    ///
138    /// **Note**: Full data serialization requires type-specific code at the
139    /// call site. Use `get_query_data::<T, E>(key, cx)` to extract typed
140    /// data and serialize it externally. The `DehydratedState` provides
141    /// the metadata (keys, type IDs) needed for typed restoration.
142    #[cfg(feature = "persist")]
143    pub fn dehydrate(&self, cx: &App) -> DehydratedState {
144        // L2: pre-size the entries Vec from the bucket `count()` sums. Only
145        // `Success` entries are pushed, so this is an upper bound — never
146        // under-allocates, avoids reallocation churn as entries accumulate.
147        // (The three maps hold different erased trait objects, so they are
148        // summed separately rather than chained.)
149        let cap = self.buckets.values().map(|b| b.count()).sum::<usize>()
150            + self
151                .infinite_buckets
152                .values()
153                .map(|b| b.count())
154                .sum::<usize>()
155            + self
156                .mutation_buckets
157                .values()
158                .map(|b| b.count())
159                .sum::<usize>();
160        let mut entries = Vec::with_capacity(cap);
161
162        // Audit fix #L13: collapse all three loops (query / infinite / mutation)
163        // into a single helper. The previous shape used a `push_status_queries`
164        // closure that handled only the two query-shaped loops (both
165        // `Vec<(String, QueryStatus)>`) and left the mutation loop inlined
166        // separately — its items are `(Option<String>, MutationStatus)`, so it
167        // couldn't reuse the closure. `push_status` below is generic over the
168        // status type and the success sentinel, so all three kinds share one
169        // code path. The emitted `DehydratedState` JSON shape is byte-identical
170        // to the previous implementation (only entries whose real status equals
171        // the success sentinel are pushed).
172        //
173        // Audit fix #L14: the `data_json` field is gone (it was always `None`),
174        // so we no longer pass the dead initializer here.
175        //
176        // Audit fix #94 / #9: this still drives the lightweight `collect_key_status`
177        // (key + status only), skipping the per-entry allocations that
178        // `collect_diagnostics` builds (`cache_policy`, `cache_age_ms`,
179        // `cache_hits`, `retry_count`).
180        //
181        // Audit fix #113: mutations are included; keyless mutations are skipped
182        // (a keyless mutation can't be meaningfully addressed for typed
183        // restoration).
184        fn push_status<S>(
185            entries: &mut Vec<DehydratedEntry>,
186            type_id: std::any::TypeId,
187            pairs: impl IntoIterator<Item = (Option<String>, S)>,
188            success: S,
189            kind: &'static str,
190        ) where
191            S: PartialEq,
192        {
193            for (key, status) in pairs {
194                if status == success
195                    && let Some(key) = key
196                {
197                    entries.push(DehydratedEntry { key, type_id, kind });
198                }
199            }
200        }
201
202        // L3: reuse two buffers across all buckets instead of allocating a fresh
203        // `Vec` per bucket (the returning `collect_key_status` variant). Each
204        // bucket appends into the shared buffer via the sink; the buffer is
205        // drained per bucket so it never grows unbounded and the keys move
206        // (no clone) into `entries`.
207        let mut q_pairs: Vec<(String, QueryStatus)> = Vec::new();
208        let mut m_pairs: Vec<(Option<String>, MutationStatus)> = Vec::new();
209
210        for (type_id, bucket) in &self.buckets {
211            bucket.collect_key_status_into(cx, &mut q_pairs);
212            push_status(
213                &mut entries,
214                *type_id,
215                q_pairs.drain(..).map(|(k, s)| (Some(k), s)),
216                QueryStatus::Success,
217                "query",
218            );
219        }
220        for (type_id, bucket) in &self.infinite_buckets {
221            bucket.collect_key_status_into(cx, &mut q_pairs);
222            push_status(
223                &mut entries,
224                *type_id,
225                q_pairs.drain(..).map(|(k, s)| (Some(k), s)),
226                QueryStatus::Success,
227                "infinite",
228            );
229        }
230        for (type_id, bucket) in &self.mutation_buckets {
231            bucket.collect_key_status_into(cx, &mut m_pairs);
232            push_status(
233                &mut entries,
234                *type_id,
235                m_pairs.drain(..),
236                MutationStatus::Success,
237                "mutation",
238            );
239        }
240
241        DehydratedState { entries }
242    }
243
244    /// Restore query state from a previously dehydrated snapshot.
245    ///
246    /// Full hydration requires type-specific deserialization. The `DehydratedState`
247    /// contains `type_id` keys but downcasting requires knowing the concrete types
248    /// at the call site. Callers should iterate `state.entries` and call
249    /// `set_query_data::<T, E>()` for each entry where they know the types.
250    ///
251    /// This method is provided as a hook point for typed hydration and to
252    /// document the intended API shape matching TanStack Query's
253    /// `queryClient.hydrate()`.
254    #[cfg(feature = "persist")]
255    pub fn hydrate(&mut self, _state: DehydratedState, _cx: &mut App) {
256        // Full hydration requires type-specific deserialization. The DehydratedState
257        // contains type_id keys but downcasting requires knowing the concrete types
258        // at the call site. Callers should iterate state.entries and call
259        // set_query_data::<T, E> for each entry where they know the types.
260    }
261
262    // ── Persistence (Audit 3, Finding 9) ────────────────────────────────
263
264    /// Persist all cached data using the provided persister.
265    ///
266    /// Dehydrates the current state and saves it via the persister. This can
267    /// be called periodically (e.g., during GC) or on app shutdown to ensure
268    /// cached data survives across app restarts.
269    #[cfg(feature = "persist")]
270    pub fn persist(&self, persister: &dyn QueryPersister, cx: &App) {
271        let state = self.dehydrate(cx);
272        persister.save(state.entries);
273    }
274
275    /// Restore cached data from a persister.
276    ///
277    /// Loads entries from the persister. Since type information is erased in
278    /// the persister, callers must iterate and restore typed data themselves
279    /// using `set_query_data`. This method loads the raw entries and returns
280    /// them for inspection and typed restoration.
281    ///
282    /// **L4**: this is an *associated* function rather than a method — it does
283    /// not read any `&self` state, so callers invoke it as
284    /// `QueryClient::restore(&persister)` instead of `client.restore(...)`,
285    /// avoiding the need for a borrow on the client.
286    #[cfg(feature = "persist")]
287    pub fn restore(persister: &dyn QueryPersister) -> Vec<DehydratedEntry> {
288        persister.load()
289    }
290
291    // ── Imperative fetch (Audit 3, Finding 10) ──────────────────────────
292
293    /// Prepare an imperative fetch for a query key, creating the resource if needed.
294    ///
295    /// This creates (or reuses) the resource entity and begins a forced request,
296    /// returning a [`PreparedFetch`] containing the entity, request ID, and signal.
297    /// The caller is responsible for calling the fetcher and completing the request
298    /// using `complete_fetch` or by directly calling `complete_current_success` /
299    /// `complete_current_failure` on the entity.
300    ///
301    /// This is the equivalent of TanStack Query's `queryClient.fetchQuery()`.
302    /// Unlike `use_query`, this does not subscribe or create an observer.
303    ///
304    /// Returns `None` if the cache is fresh (cache hit) and no fetch is needed.
305    /// In that case, use `get_query_data` to read the cached data.
306    ///
307    /// # Example
308    ///
309    /// ```no_run
310    /// use gpui_query::client::QueryClient;
311    /// use gpui_query::core::QueryKey;
312    /// # #[derive(Clone)]
313    /// # struct UserData;
314    /// # #[derive(Clone, Debug)]
315    /// # struct QueryError;
316    /// # fn _doc(client: &mut QueryClient, cx: &mut gpui::App) {
317    ///
318    /// if let Some(prepared) = client.prepare_fetch_query::<UserData, QueryError>(
319    ///     QueryKey::from("user/42"),
320    ///     cx,
321    /// ) {
322    ///     // prepared.entity, prepared.signal, and prepared.request_id are now available.
323    ///     // Use cx.spawn() to run your async fetcher, then call
324    ///     // prepared.complete_success(data, cx) or prepared.complete_failure(e, cx).
325    /// }
326    /// # }
327    /// ```
328    pub fn prepare_fetch_query<
329        T: Clone + Send + Sync + 'static,
330        E: Clone + Send + Sync + 'static,
331    >(
332        &mut self,
333        key: impl Into<QueryKey>,
334        cx: &mut App,
335    ) -> Option<PreparedFetch<T, E>> {
336        let key = key.into();
337        let entity = self.resource::<T, E>(key.clone(), cx);
338        let now_ms = current_time_ms();
339
340        // Get or create a request ID via the bucket's sequencer
341        let request_id = self.next_request_id_for_key::<T, E>(&key);
342
343        // Begin the request on the resource purely for its side effect.
344        // **L7**: the previous code captured a `started` boolean from
345        // `begin_request_with_id`, matched it exhaustively, and then discarded
346        // it via `let _ = started;` — `prepare_fetch_query` (force mode)
347        // always returns a `PreparedFetch` regardless, so the value was
348        // useless. We now call `begin_request_with_id` for its side effect
349        // only, dropping the dead match + binding.
350        entity.update(cx, |resource, _| {
351            if let Some(rid) = request_id {
352                let _ = resource.begin_request_with_id(
353                    Some(rid),
354                    now_ms,
355                    crate::core::QueryFetchMode::Force,
356                );
357            }
358        });
359
360        // Re-read to get the signal and request ID
361        let (request_id, signal) = entity.read_with(cx, |resource, _| {
362            let rid = resource.active_request_id()?;
363            let signal = resource.signal().cloned()?;
364            Some((rid, signal))
365        })?;
366
367        Some(PreparedFetch {
368            entity,
369            request_id,
370            signal,
371            now_ms,
372        })
373    }
374
375    // ── Prefetch (Audit 3, Finding 11) ──────────────────────────────────
376
377    /// Prepare a prefetch for a key that will be needed soon.
378    ///
379    /// Creates the resource entity (or reuses an existing one) and begins a
380    /// request if the cache is stale or empty. The resource is NOT subscribed
381    /// -- no observer is attached. When a component later calls `use_query`
382    /// with the same key, it will find the prefetched data in the cache.
383    ///
384    /// This is the equivalent of TanStack Query's `queryClient.prefetchQuery()`.
385    ///
386    /// If the resource already has fresh data (cache hit), returns `None`.
387    /// Use `prepare_fetch_query` with forced mode to override this behavior.
388    ///
389    /// Returns a [`PreparedFetch`] containing the entity, request ID, and
390    /// signal. The caller is responsible for calling the fetcher and completing
391    /// the request.
392    pub fn prepare_prefetch_query<
393        T: Clone + Send + Sync + 'static,
394        E: Clone + Send + Sync + 'static,
395    >(
396        &mut self,
397        key: impl Into<QueryKey>,
398        cache_policy: CachePolicy,
399        request_policy: RequestPolicy,
400        cx: &mut App,
401    ) -> Option<PreparedFetch<T, E>> {
402        let key = key.into();
403        let entity =
404            self.resource_with_policies::<T, E>(key.clone(), cache_policy, request_policy, cx);
405        let now_ms = current_time_ms();
406
407        // Get request ID from sequencer
408        let request_id = self.next_request_id_for_key::<T, E>(&key);
409
410        // Begin the request (respects cache policy — will skip if fresh)
411        let started = entity.update(cx, |resource, _| {
412            if let Some(rid) = request_id {
413                let result = resource.begin_request_with_id(
414                    Some(rid),
415                    now_ms,
416                    crate::core::QueryFetchMode::Normal,
417                );
418                matches!(
419                    result,
420                    crate::core::QueryBeginResult::Started { .. }
421                        | crate::core::QueryBeginResult::StaleCacheHit { .. }
422                )
423            } else {
424                false
425            }
426        });
427
428        if !started {
429            return None;
430        }
431
432        // Re-read to get the signal and request ID
433        let (request_id, signal) = entity.read_with(cx, |resource, _| {
434            let rid = resource.active_request_id()?;
435            let signal = resource.signal().cloned()?;
436            Some((rid, signal))
437        })?;
438
439        Some(PreparedFetch {
440            entity,
441            request_id,
442            signal,
443            now_ms,
444        })
445    }
446}