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}