Skip to main content

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}