gpui_query/client/mod.rs
1//! Layer 1: GPUI `QueryClient` — global registry for query resources.
2//!
3//! `QueryClient` is a GPUI [`Global`] that manages type-partitioned buckets
4//! for queries, mutations, and observers. It provides bulk operations like
5//! `invalidate_queries`, `cancel_queries`, and garbage collection.
6//!
7//! # Audit 3 fixes
8//!
9//! - `gc()` accepts optional `now_ms` parameter via `gc_with_time()` to avoid
10//! redundant syscalls (finding 2)
11//! - `expect()` on TypeId downcast replaced with graceful recovery + type name
12//! in error message (findings 3, 4)
13//! - `cancel_queries()` added for bulk in-flight request cancellation (finding 5)
14//! - `get_query_data()` / `set_query_data()` for ergonomic cache access (finding 6)
15//! - `diagnostics()` now populates per-resource diagnostic details (finding 7)
16//! - `dehydrate()` / `hydrate()` for state serialization across restarts (finding 8)
17//! - `QueryPersister` trait and `persist()` / `restore()` for pluggable persistence (finding 9)
18//! - `fetch_query()` for imperative one-shot fetches (finding 10)
19//! - `prefetch_query()` for background cache warming (finding 11)
20
21mod bucket;
22mod devtools;
23mod erased;
24mod infinite_bucket;
25mod infinite_mutation_ops;
26mod lifecycle;
27mod mutation_bucket;
28#[cfg(feature = "persist")]
29mod mutation_signal;
30mod observer;
31#[cfg(feature = "persist")]
32mod persist;
33mod prepared_fetch;
34mod time;
35
36pub use bucket::QueryBucket;
37pub use devtools::{ClientDiagnostic, MutationDiagnostic, QueryDiagnostic};
38#[cfg(feature = "persist")]
39pub use devtools::{DehydratedEntry, DehydratedState};
40#[cfg(feature = "persist")]
41pub use erased::QueryPersister;
42pub use infinite_bucket::InfiniteQueryBucket;
43pub use mutation_bucket::MutationBucket;
44#[cfg(feature = "persist")]
45pub use mutation_signal::CacheMutation;
46pub use observer::{
47 InfiniteQueryObserver, MutationObserver, ObservableResource, Observer, ObserverConfig,
48 QueryObserver,
49};
50#[cfg(feature = "persist")]
51pub use persist::{
52 NoopPersister, PERSIST_VERSION, PersistError, PersistFilter, PersistHandle, PersistOptions,
53 PersistSnapshot, PersistedEntry, Persister, SerializerRegistry, hydrate,
54};
55pub use prepared_fetch::PreparedFetch;
56pub use time::current_time_ms;
57
58use std::any::TypeId;
59
60use ahash::AHashMap;
61use gpui::{App, Entity, Global};
62
63use crate::client::bucket::shared::GC_INTERVAL;
64use crate::client::bucket::types::MIN_GC_TIME_MS;
65use crate::client::erased::{ErasedBucket, ErasedInfiniteBucket, ErasedMutationBucket};
66use crate::core::{CachePolicy, QueryKey, QueryResource, RequestPolicy};
67
68/// Global registry for query and mutation resources.
69///
70/// Implements [`Global`] so it can be set once with `cx.set_global(QueryClient::default())`
71/// and accessed from any component via `cx.global::<QueryClient>()`.
72///
73/// # v2 Improvements
74///
75/// - `Default` impl (no required params)
76/// - `AHashMap` for ~2x faster lookups on trusted keys
77/// - Actual mutation GC (not a no-op)
78/// - Collect-then-update pattern to avoid nested entity borrows
79pub struct QueryClient {
80 pub(crate) buckets: AHashMap<TypeId, Box<dyn ErasedBucket>>,
81 pub(crate) infinite_buckets: AHashMap<TypeId, Box<dyn ErasedInfiniteBucket>>,
82 pub(crate) mutation_buckets: AHashMap<TypeId, Box<dyn ErasedMutationBucket>>,
83 pub(crate) default_cache_policy: CachePolicy,
84 pub(crate) default_request_policy: RequestPolicy,
85 pub(crate) gc_time_ms: u64,
86 /// Typed-serializer registry for the value-carrying persistence path
87 /// (`persist` feature). Populated by `register_serializer::<T, E>`.
88 #[cfg(feature = "persist")]
89 pub(crate) serializers: Option<crate::client::persist::SerializerRegistry>,
90 /// Typed-deserializer registry for [`hydrate`] (`persist` feature).
91 /// Populated by `register_deserializer::<T, E>`.
92 #[cfg(feature = "persist")]
93 pub(crate) deserializers: Option<crate::client::persist::DeserializerRegistry>,
94 /// Per-key opaque metadata captured from `Fetched::meta` at fetch
95 /// completion (`persist` feature), surfaced into
96 /// [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) at
97 /// collect time so HTTP `CacheMeta` and similar can round-trip through a
98 /// cold start. Entries for evicted keys are simply ignored at collect time.
99 #[cfg(feature = "persist")]
100 pub(crate) persisted_meta:
101 Option<std::collections::HashMap<crate::core::QueryKey, serde_json::Value>>,
102 /// Operation counter for opportunistic GC (audit CL1/#105). The GC
103 /// subsystem fires every `GC_INTERVAL` resource/mutation operations so it
104 /// actually runs in production without requiring hooks to call `gc()`.
105 op_count: u64,
106 /// Wall-clock ms of the last opportunistic GC sweep. Combined with the op
107 /// counter, this debounces GC so a burst of insertions (or a fast test that
108 /// creates many resources within `MIN_GC_TIME_MS`) does not trigger GC.
109 ///
110 /// **L11**: initialized to `0` (rather than `current_time_ms()`) so
111 /// `QueryClient` construction does not perform a syscall. The
112 /// `MIN_GC_TIME_MS` debounce in `maybe_opportunistic_gc` still suppresses
113 /// GC for the first ~1s of life because the very first sweep sets this to
114 /// the real clock on its way through.
115 last_gc_ms: u64,
116}
117
118impl Global for QueryClient {}
119
120impl Default for QueryClient {
121 /// **Audit fix #21**: Explicit `Default` impl that sets `gc_time_ms` to
122 /// `300_000` (5 minutes), matching `with_policies`. The previous derive
123 /// produced `gc_time_ms: 0`, which silently disabled GC — every
124 /// non-loading Idle/Failure resource would be evicted on every pass.
125 /// All other field defaults are identical to what the derive produced.
126 fn default() -> Self {
127 Self {
128 buckets: AHashMap::new(),
129 infinite_buckets: AHashMap::new(),
130 mutation_buckets: AHashMap::new(),
131 default_cache_policy: CachePolicy::default(),
132 default_request_policy: RequestPolicy::default(),
133 gc_time_ms: 300_000,
134 #[cfg(feature = "persist")]
135 serializers: None,
136 #[cfg(feature = "persist")]
137 deserializers: None,
138 #[cfg(feature = "persist")]
139 persisted_meta: None,
140 op_count: 0,
141 last_gc_ms: 0,
142 }
143 }
144}
145
146impl QueryClient {
147 /// Create a new client with default policies.
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 /// Create with custom default policies.
153 pub fn with_policies(
154 default_cache_policy: CachePolicy,
155 default_request_policy: RequestPolicy,
156 ) -> Self {
157 Self {
158 default_cache_policy,
159 default_request_policy,
160 gc_time_ms: 300_000, // 5 minutes
161 ..Default::default()
162 }
163 }
164
165 /// Set the garbage collection time (in milliseconds).
166 ///
167 /// Values below 1000ms are clamped to 1000ms during GC to prevent
168 /// aggressive eviction of all Idle/Failure resources on every GC pass.
169 pub fn with_gc_time(mut self, gc_time_ms: u64) -> Self {
170 self.gc_time_ms = gc_time_ms;
171 self
172 }
173
174 /// Record opaque metadata (e.g. a serialized HTTP `CacheMeta`) for `key`,
175 /// captured from a fetcher's [`Fetched::meta`](crate::core::Fetched) at
176 /// completion. Surfaced into
177 /// [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) at
178 /// collect time so the metadata round-trips through persistence. `persist`
179 /// feature only.
180 #[cfg(feature = "persist")]
181 pub(crate) fn record_meta(&mut self, key: crate::core::QueryKey, meta: serde_json::Value) {
182 self.persisted_meta
183 .get_or_insert_with(std::collections::HashMap::new)
184 .insert(key, meta);
185 }
186
187 /// Opportunistic GC trigger (audit CL1/#105). Runs GC every `GC_INTERVAL`
188 /// operations so the GC subsystem actually fires in production without
189 /// requiring hooks to call `gc()` explicitly. Without this trigger the
190 /// (now correct, live-state-reading) GC never runs in production, which
191 /// would render the memory-bound fixes (#1, #2, #8, #91, #108) academic.
192 ///
193 /// Debounced by BOTH operation count (every `GC_INTERVAL` ops) and wall
194 /// clock time (no sweep within `MIN_GC_TIME_MS` of the last). `gc_time_ms`
195 /// of 0 disables GC entirely.
196 ///
197 /// **L11**: `last_gc_ms` starts at `0` (no `current_time_ms` syscall at
198 /// construction). To preserve the "no GC in the first ~1s of life"
199 /// debounce that the prior `current_time_ms()` initialization provided,
200 /// the sentinel `0` is treated as "uninitialized": the first time
201 /// `maybe_opportunistic_gc` reaches the time check, it seeds `last_gc_ms`
202 /// to `now_ms` and skips that sweep, so a fast test that creates many
203 /// resources in well under a second never triggers GC.
204 fn maybe_opportunistic_gc(&mut self, cx: &App) {
205 if self.gc_time_ms == 0 {
206 return;
207 }
208 self.op_count = self.op_count.wrapping_add(1);
209 if !self.op_count.is_multiple_of(GC_INTERVAL as u64) {
210 return;
211 }
212 let now_ms = current_time_ms();
213 // L11: seed the debounce window on first reach instead of syscalling
214 // at construction.
215 if self.last_gc_ms == 0 {
216 self.last_gc_ms = now_ms;
217 return;
218 }
219 if now_ms.saturating_sub(self.last_gc_ms) < MIN_GC_TIME_MS {
220 return;
221 }
222 self.last_gc_ms = now_ms;
223 self.gc_with_time(now_ms, cx);
224 }
225
226 // ── Query operations ────────────────────────────────────────────────
227
228 /// Get or create a query resource for the given key and type pair.
229 pub fn resource<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
230 &mut self,
231 key: impl Into<QueryKey>,
232 cx: &mut App,
233 ) -> Entity<QueryResource<T, E>> {
234 self.resource_with_policies::<T, E>(
235 key,
236 self.default_cache_policy,
237 self.default_request_policy,
238 cx,
239 )
240 }
241
242 /// Get or create a query resource with explicit policies.
243 ///
244 /// Audit 3 fix (findings 3, 4): Uses graceful downcast recovery instead
245 /// of `expect()`. On type mismatch, logs the type name and creates a
246 /// fresh bucket, preventing application crashes from hypothetical
247 /// TypeId collisions.
248 pub fn resource_with_policies<
249 T: Clone + Send + Sync + 'static,
250 E: Clone + Send + Sync + 'static,
251 >(
252 &mut self,
253 key: impl Into<QueryKey>,
254 cache_policy: CachePolicy,
255 request_policy: RequestPolicy,
256 cx: &mut App,
257 ) -> Entity<QueryResource<T, E>> {
258 let type_id = TypeId::of::<(T, E)>();
259 let bucket = self
260 .buckets
261 .entry(type_id)
262 .or_insert_with(|| Box::new(QueryBucket::<T, E>::new()));
263
264 // M4: `bucket_or_recreate` downcasts once; `downcast_mut` already
265 // performs the TypeId check internally, so the prior redundant
266 // `bucket.type_id() != expected` pre-check is dropped (it was the
267 // double-check that audit fix #11 left in). On the (impossible)
268 // mismatch we log + swap in a fresh bucket + return it, all in one
269 // place — killing the 5x duplicated recovery block across the client.
270 let typed = Self::bucket_or_recreate::<T, E>(bucket);
271 let entity = typed.get_or_create(key.into(), cache_policy, request_policy, cx);
272 // Audit fix CL1/#105: opportunistically run GC on this op.
273 self.maybe_opportunistic_gc(cx);
274 entity
275 }
276
277 /// Get all query entities of a given type pair.
278 pub fn all_queries<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
279 &self,
280 ) -> Vec<Entity<QueryResource<T, E>>> {
281 let type_id = TypeId::of::<(T, E)>();
282 self.buckets
283 .get(&type_id)
284 .and_then(|b| b.as_any().downcast_ref::<QueryBucket<T, E>>())
285 .map(|b| b.all_entities())
286 .unwrap_or_default()
287 }
288
289 /// Get a specific query entity by key.
290 pub fn query<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
291 &self,
292 key: &QueryKey,
293 ) -> Option<Entity<QueryResource<T, E>>> {
294 let type_id = TypeId::of::<(T, E)>();
295 self.buckets
296 .get(&type_id)
297 .and_then(|b| b.as_any().downcast_ref::<QueryBucket<T, E>>())
298 .and_then(|b| b.get(key))
299 }
300
301 /// Use the bucket's co-located sequencer to generate a `RequestId` for a key.
302 ///
303 /// Returns `None` if no bucket entry exists for the key. The sequencer is
304 /// advanced in-place (mutated) so subsequent calls produce monotonically
305 /// increasing IDs. This is the fix for audit findings #1/#5/#15/#18:
306 /// using the bucket's persistent sequencer instead of a transient one
307 /// prevents every request from getting the same `RequestId(1, 1)`.
308 ///
309 /// Audit 3 fix (findings 3, 4): Graceful downcast recovery.
310 pub fn next_request_id_for_key<
311 T: Clone + Send + Sync + 'static,
312 E: Clone + Send + Sync + 'static,
313 >(
314 &mut self,
315 key: &QueryKey,
316 ) -> Option<crate::core::RequestId> {
317 let type_id = TypeId::of::<(T, E)>();
318 let bucket = self.buckets.get_mut(&type_id)?;
319 // M4: single downcast via the shared helper (redundant TypeId
320 // pre-check dropped).
321 let typed = Self::bucket_or_recreate::<T, E>(bucket);
322 typed.sequencer_mut(key).map(|seq| seq.next_request())
323 }
324
325 // ── Erased-bucket recovery helper (M4) ──────────────────────────────
326
327 /// Downcast an erased query bucket to `&mut QueryBucket<T, E>`, recreating
328 /// it in place on the (impossible) type mismatch.
329 ///
330 /// **M4**: this replaces the 5x duplicated `TypeId` pre-check, `eprintln`,
331 /// fresh-bucket, and `downcast_mut` match block. `Any::downcast_mut` checks
332 /// `TypeId` internally, so the explicit pre-check was redundant; we now
333 /// downcast once and, only on the (impossible-after-construction) `None`,
334 /// log, swap in a fresh typed bucket, and downcast *that* (which always
335 /// succeeds). No production panic.
336 fn bucket_or_recreate<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
337 bucket: &mut Box<dyn ErasedBucket>,
338 ) -> &mut QueryBucket<T, E> {
339 if bucket
340 .as_any_mut()
341 .downcast_mut::<QueryBucket<T, E>>()
342 .is_none()
343 {
344 eprintln!(
345 "QueryClient: type mismatch in bucket downcast for {}. \
346 Replacing with a fresh bucket.",
347 std::any::type_name::<(T, E)>()
348 );
349 *bucket = Box::new(QueryBucket::<T, E>::new());
350 debug_assert!(
351 bucket
352 .as_any_mut()
353 .downcast_mut::<QueryBucket<T, E>>()
354 .is_some(),
355 "QueryBucket downcast failed after fresh reconstruction"
356 );
357 }
358 // Unwrap is infallible here: either the original downcast succeeded,
359 // or we just replaced `*bucket` with a freshly-constructed typed one.
360 bucket
361 .as_any_mut()
362 .downcast_mut::<QueryBucket<T, E>>()
363 .expect("QueryBucket downcast succeeds after bucket_or_recreate")
364 }
365
366 // ── Data accessors (Audit 3, Finding 6) ─────────────────────────────
367
368 /// Read the cached data for a query key directly, without going through a hook.
369 ///
370 /// Returns `None` if no resource exists for the key, the entity was collected,
371 /// or the resource has no data (has not completed a fetch).
372 ///
373 /// This is the ergonomic equivalent of TanStack Query's `queryClient.getQueryData(key)`.
374 pub fn get_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
375 &self,
376 key: &QueryKey,
377 cx: &App,
378 ) -> Option<T> {
379 let entity = self.query::<T, E>(key)?;
380 entity.read_with(cx, |resource, _| resource.data().cloned())
381 }
382
383 /// Read the cached data for a query key via a borrow callback, with NO
384 /// clone of `T` (audit fix #L12).
385 ///
386 /// This is the zero-clone counterpart to [`get_query_data`](Self::get_query_data):
387 /// instead of returning `Option<T>` (which clones the value out of the
388 /// resource), it hands `f` a `&T` for the duration of the call. Use this
389 /// when the caller only needs to *inspect* the cached data (e.g. compute a
390 /// derived value, render a summary) and would otherwise pay for a full
391 /// `T::clone()` it discards immediately.
392 ///
393 /// Returns `None` if no resource exists for the key, the entity was
394 /// collected, or the resource has no data. Returns `Some(R)` (the value
395 /// produced by `f`) otherwise. `T` and `E` are unchanged from
396 /// `get_query_data`; `R` is the closure's return type and is independent of
397 /// `T`, so it does not shadow the crate's `T`/`E` conventions.
398 pub fn with_query_data<
399 T: Clone + Send + Sync + 'static,
400 E: Clone + Send + Sync + 'static,
401 R,
402 >(
403 &self,
404 key: &QueryKey,
405 cx: &App,
406 f: impl FnOnce(&T) -> R,
407 ) -> Option<R> {
408 let entity = self.query::<T, E>(key)?;
409 entity.read_with(cx, |resource, _| resource.data().map(f))
410 }
411
412 /// Write data directly into the cache for a query key, creating the resource
413 /// if it does not already exist.
414 ///
415 /// This is the ergonomic equivalent of TanStack Query's `queryClient.setQueryData(key, data)`.
416 /// The resource's previous data is saved for rollback via `rollback_to_previous()`.
417 /// The data is set via `set_data()` which saves previous data but does not
418 /// change the resource's status or timestamp. Use this for optimistic updates
419 /// and manual cache manipulation where you control the lifecycle.
420 pub fn set_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>(
421 &mut self,
422 key: impl Into<QueryKey>,
423 data: T,
424 cx: &mut App,
425 ) {
426 let key = key.into();
427 let entity = self.resource::<T, E>(key, cx);
428 entity.update(cx, |resource, cx| {
429 resource.set_data(data);
430 // B2: bump the precise dirty signal so `persist_with` schedules a
431 // save. `default_global` creates the marker if absent AND pushes
432 // GPUI's `NotifyGlobalObservers` effect (see gpui `App::default_global`),
433 // which wakes the `observe_global::<CacheMutation>` observer in
434 // `persist_with`. It is infallible, so the no-`persist_with` build's
435 // `set_query_data` path never panics on an absent marker.
436 #[cfg(feature = "persist")]
437 cx.default_global::<crate::client::CacheMutation>();
438 // In the default (no-persist) build the closure's `cx` is otherwise
439 // unused; reference it so the build stays warning-free.
440 #[cfg(not(feature = "persist"))]
441 let _ = cx;
442 });
443 }
444}