Skip to main content

gpui_query/core/
mutation.rs

1//! Mutation resource for tracking async write operations.
2
3use serde::{Deserialize, Serialize};
4
5use super::{QueryError, QueryKey, QuerySignal, RetryPolicy};
6
7/// Status of a mutation operation.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
9pub enum MutationStatus {
10    /// No mutation has been started yet.
11    #[default]
12    Idle,
13    /// Mutation is in progress.
14    Loading,
15    /// Mutation completed successfully.
16    Success,
17    /// Mutation failed.
18    Failure,
19}
20
21impl MutationStatus {
22    /// Human-readable label.
23    pub fn label(self) -> &'static str {
24        match self {
25            Self::Idle => "Idle",
26            Self::Loading => "Loading",
27            Self::Success => "Success",
28            Self::Failure => "Failure",
29        }
30    }
31
32    /// Whether the mutation is currently loading.
33    pub fn is_loading(self) -> bool {
34        matches!(self, Self::Loading)
35    }
36
37    /// Whether the mutation is idle.
38    pub fn is_idle(self) -> bool {
39        matches!(self, Self::Idle)
40    }
41
42    /// Whether the mutation succeeded.
43    pub fn is_success(self) -> bool {
44        matches!(self, Self::Success)
45    }
46
47    /// Whether the mutation failed.
48    pub fn is_failure(self) -> bool {
49        matches!(self, Self::Failure)
50    }
51}
52
53/// A mutation resource that tracks the state of a single mutation.
54///
55/// `V` is the variables (input) type, `T` is the success output type,
56/// and `E` is the error type.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct MutationResource<V, T, E = QueryError> {
59    key: Option<QueryKey>,
60    status: MutationStatus,
61    data: Option<T>,
62    error: Option<E>,
63    variables: Option<V>,
64    retry_count: u32,
65    cancelled_count: u64,
66    retry_policy: RetryPolicy,
67    /// Wall-clock ms of the most recent terminal completion (success/failure);
68    /// `None` until the mutation first completes. Read by `MutationBucket`'s GC
69    /// so recency is measured from completion time, not insertion time
70    /// (audit #112). `#[serde(skip)]` — runtime state, not persisted.
71    #[serde(skip)]
72    last_updated_at_ms: Option<u64>,
73    #[serde(skip)]
74    signal: Option<QuerySignal>,
75    /// In-flight background mutation task (audit #6). Stored so that a
76    /// replacement mutation or entity drop (component unmount) aborts the prior
77    /// in-flight task instead of leaving it detached. `#[cfg(feature = "client")]`
78    /// because `gpui::Task` is only available with the client feature.
79    #[cfg(feature = "client")]
80    #[serde(skip)]
81    pub(crate) current_task: crate::core::current_task::CurrentTask,
82}
83
84/// Current wall-clock ms since the Unix epoch, clamped to 0 if the system
85/// clock is before the epoch (mirrors the client/hook `current_time_ms`). Used
86/// to stamp `MutationResource::last_updated_at_ms` on terminal completion.
87fn completion_now_ms() -> u64 {
88    std::time::SystemTime::now()
89        .duration_since(std::time::UNIX_EPOCH)
90        .map(|d| d.as_millis() as u64)
91        .unwrap_or_default()
92}
93
94impl<V, T, E> MutationResource<V, T, E> {
95    /// Create a new mutation resource with the given retry policy.
96    pub fn new(retry_policy: RetryPolicy) -> Self {
97        Self {
98            key: None,
99            status: MutationStatus::Idle,
100            data: None,
101            error: None,
102            variables: None,
103            retry_count: 0,
104            cancelled_count: 0,
105            retry_policy,
106            last_updated_at_ms: None,
107            signal: None,
108            #[cfg(feature = "client")]
109            current_task: crate::core::current_task::CurrentTask::default(),
110        }
111    }
112
113    /// Current status.
114    pub fn status(&self) -> MutationStatus {
115        self.status
116    }
117
118    /// Wall-clock ms of the most recent terminal completion, or `None` if the
119    /// mutation has never completed. Used by `MutationBucket` GC to measure
120    /// recency from completion time rather than insertion time (audit #112).
121    // Only the `client` layer reads this accessor; core-only builds (e.g.
122    // wasm32 core) have no caller yet, so silence dead_code there.
123    #[cfg_attr(not(feature = "client"), allow(dead_code))]
124    pub(crate) fn last_updated_at_ms(&self) -> Option<u64> {
125        self.last_updated_at_ms
126    }
127
128    /// Most recent successful data.
129    pub fn data(&self) -> Option<&T> {
130        self.data.as_ref()
131    }
132
133    /// Most recent error.
134    pub fn error(&self) -> Option<&E> {
135        self.error.as_ref()
136    }
137
138    /// Variables for the current or most recent mutation.
139    pub fn variables(&self) -> Option<&V> {
140        self.variables.as_ref()
141    }
142
143    /// Current retry count.
144    pub fn retry_count(&self) -> u32 {
145        self.retry_count
146    }
147
148    /// Number of times this mutation has been cancelled.
149    pub fn cancelled_count(&self) -> u64 {
150        self.cancelled_count
151    }
152
153    /// The retry policy.
154    pub fn retry_policy(&self) -> &RetryPolicy {
155        &self.retry_policy
156    }
157
158    /// Set the retry policy.
159    ///
160    /// Mirrors `QueryResource::set_retry_policy` /
161    /// `InfiniteQueryResource::set_retry_policy` for API consistency.
162    pub fn set_retry_policy(&mut self, policy: RetryPolicy) {
163        self.retry_policy = policy;
164    }
165
166    /// Whether the mutation is currently loading.
167    pub fn is_loading(&self) -> bool {
168        self.status.is_loading()
169    }
170
171    /// Whether the mutation is idle.
172    pub fn is_idle(&self) -> bool {
173        self.status.is_idle()
174    }
175
176    /// Whether the mutation succeeded.
177    pub fn is_success(&self) -> bool {
178        self.status.is_success()
179    }
180
181    /// Whether the mutation failed.
182    pub fn is_failure(&self) -> bool {
183        self.status.is_failure()
184    }
185
186    /// Optional query key for this mutation.
187    pub fn key(&self) -> Option<&QueryKey> {
188        self.key.as_ref()
189    }
190
191    /// Associate a query key with this mutation.
192    ///
193    /// Forward-compatibility hook: the hook layer does not currently set a key
194    /// on mutations, so `key` remains `None` in production. Kept for callers
195    /// that want to tag a mutation with a key for diagnostics/invalidation.
196    pub fn with_key(mut self, key: QueryKey) -> Self {
197        self.key = Some(key);
198        self
199    }
200
201    /// Start a mutation with the given variables.
202    ///
203    /// Transitions to `Loading`, stores variables, clears error, creates signal.
204    /// Cancels any previous in-flight signal so a prior fetcher observes cancellation.
205    /// Resets `retry_count` so each mutation invocation starts fresh, matching
206    /// `QueryResource`'s behavior where the hook layer resets retries on success.
207    pub fn begin(&mut self, variables: V) {
208        // Cancel old signal before replacing, matching QueryResource/InfiniteQueryResource pattern.
209        if let Some(old_signal) = self.signal.as_ref() {
210            old_signal.cancel();
211        }
212        self.status = MutationStatus::Loading;
213        self.variables = Some(variables);
214        self.error = None;
215        self.retry_count = 0;
216        self.last_updated_at_ms = None;
217        self.signal = Some(QuerySignal::new());
218    }
219
220    /// Complete successfully.
221    pub fn complete_success(&mut self, data: T) {
222        self.status = MutationStatus::Success;
223        self.data = Some(data);
224        self.error = None;
225        self.last_updated_at_ms = Some(completion_now_ms());
226        self.signal = None;
227    }
228
229    /// Complete with failure.
230    ///
231    /// Clears `data` so consumers do not see stale success data alongside
232    /// a `Failure` status. Increments `retry_count` with saturating add to
233    /// prevent wraparound.
234    pub fn complete_failure(&mut self, error: E) {
235        self.status = MutationStatus::Failure;
236        self.data = None;
237        self.error = Some(error);
238        self.retry_count = self.retry_count.saturating_add(1);
239        self.last_updated_at_ms = Some(completion_now_ms());
240        self.signal = None;
241    }
242
243    /// Whether another retry is allowed.
244    pub fn should_retry(&self) -> bool {
245        self.retry_policy.should_retry(self.retry_count)
246    }
247
248    /// Retry by transitioning back to Loading.
249    ///
250    /// Only valid from `Failure` when retries remain.
251    /// A fresh cancellation signal is created.
252    pub fn retry(&mut self) -> bool {
253        if self.status != MutationStatus::Failure || !self.should_retry() {
254            return false;
255        }
256        self.status = MutationStatus::Loading;
257        self.error = None;
258        self.signal = Some(QuerySignal::new());
259        true
260    }
261
262    /// Reset to idle, clearing everything.
263    pub fn reset(&mut self) {
264        if let Some(signal) = self.signal.as_ref() {
265            signal.cancel();
266        }
267        self.status = MutationStatus::Idle;
268        self.data = None;
269        self.error = None;
270        self.variables = None;
271        self.retry_count = 0;
272        self.cancelled_count = 0;
273        // Clear the completion timestamp so MutationBucket GC does not measure
274        // recency from a stale pre-reset completion (mirrors QueryResource::reset
275        // and InfiniteQueryResource::reset clearing last_updated_at).
276        self.last_updated_at_ms = None;
277        self.signal = None;
278    }
279
280    /// The cancellation signal.
281    pub fn signal(&self) -> Option<&QuerySignal> {
282        self.signal.as_ref()
283    }
284
285    /// Increment the retry counter.
286    ///
287    /// Used by the mutation retry loop to track how many attempts have been made
288    /// without transitioning through a terminal `Failure` state.
289    pub fn increment_retry(&mut self) {
290        self.retry_count = self.retry_count.saturating_add(1);
291    }
292
293    /// Prepare for a retry by refreshing the signal without transitioning
294    /// through `Failure`.
295    ///
296    /// Avoids a transient `Failure` status that would cause observers to see a
297    /// brief Failure flash between retry attempts. The mutation stays in
298    /// `Loading` state, the old signal is cancelled, and a fresh signal is
299    /// created for the next attempt.
300    pub fn prepare_retry(&mut self) {
301        if self.status != MutationStatus::Loading {
302            return;
303        }
304        // Cancel the old signal before creating a new one.
305        if let Some(old_signal) = self.signal.as_ref() {
306            old_signal.cancel();
307        }
308        self.error = None;
309        self.signal = Some(QuerySignal::new());
310    }
311
312    /// Reset the retry counter to zero.
313    ///
314    /// Called on terminal failure so that `retry_count` is clean for the
315    /// next mutation invocation.
316    pub fn reset_retry_count(&mut self) {
317        self.retry_count = 0;
318    }
319
320    /// Cancel the mutation.
321    ///
322    /// Only has effect when the mutation is in `Loading` state. Returns without
323    /// side effects if the mutation is already `Idle`, `Success`, or `Failure`,
324    /// matching the `QueryResource::cancel` behavior where a no-op cancel is silent.
325    ///
326    /// When effective, increments `cancelled_count` for diagnostics and sets
327    /// status to `Failure`.
328    pub fn cancel(&mut self, error: E) {
329        if self.status != MutationStatus::Loading {
330            return;
331        }
332        self.cancelled_count = self.cancelled_count.saturating_add(1);
333        self.status = MutationStatus::Failure;
334        self.error = Some(error);
335        if let Some(signal) = self.signal.as_ref() {
336            signal.cancel();
337        }
338        self.signal = None;
339    }
340}
341
342#[cfg(feature = "client")]
343impl<V, T, E> MutationResource<V, T, E> {
344    /// Store a new background mutation task, cancelling any previously stored
345    /// task (audit #6). Called from the hook spawn sites so a replacement
346    /// mutation or entity drop aborts the prior in-flight task.
347    pub(crate) fn set_current_task(&mut self, task: gpui::Task<()>) {
348        self.current_task.set(task);
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn new_mutation_is_idle() {
358        let m: MutationResource<String, String> = MutationResource::new(RetryPolicy::no_retries());
359        assert!(m.is_idle());
360        assert_eq!(m.status(), MutationStatus::Idle);
361    }
362
363    #[test]
364    fn begin_transitions_to_loading() {
365        let mut m: MutationResource<String, String> =
366            MutationResource::new(RetryPolicy::no_retries());
367        m.begin("vars".to_string());
368        assert!(m.is_loading());
369        assert_eq!(m.variables(), Some(&"vars".to_string()));
370    }
371
372    #[test]
373    fn complete_success_stores_data() {
374        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
375        m.begin("vars".to_string());
376        m.complete_success(42);
377        assert!(m.is_success());
378        assert_eq!(m.data(), Some(&42));
379    }
380
381    #[test]
382    fn complete_failure_stores_error() {
383        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
384        m.begin("vars".to_string());
385        m.complete_failure(QueryError::response("bad"));
386        assert!(m.is_failure());
387        assert_eq!(m.retry_count(), 1);
388        assert!(m.data().is_none(), "data should be cleared on failure");
389    }
390
391    #[test]
392    fn retry_from_failure() {
393        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(2));
394        m.begin("vars".to_string());
395        m.complete_failure(QueryError::response("fail"));
396        assert!(m.retry());
397        assert!(m.is_loading());
398    }
399
400    #[test]
401    fn retry_respects_max() {
402        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(1));
403        m.begin("vars".to_string());
404        m.complete_failure(QueryError::response("fail"));
405        assert!(!m.should_retry()); // retry_count=1, max=1
406        assert!(!m.retry());
407    }
408
409    #[test]
410    fn reset_clears_everything() {
411        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(3));
412        m.begin("vars".to_string());
413        m.complete_success(99);
414        m.reset();
415        assert!(m.is_idle());
416        assert!(m.data().is_none());
417        assert_eq!(m.retry_count(), 0);
418    }
419
420    #[test]
421    fn cancel_cancels_signal() {
422        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
423        m.begin("vars".to_string());
424        let signal = m.signal().unwrap().clone();
425        assert!(!signal.is_cancelled());
426        m.cancel(QueryError::cancelled("aborted"));
427        assert!(signal.is_cancelled());
428    }
429
430    #[test]
431    fn begin_cancels_old_signal() {
432        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
433        m.begin("first".to_string());
434        let old_signal = m.signal().unwrap().clone();
435        assert!(!old_signal.is_cancelled());
436        // Starting a new mutation should cancel the old signal.
437        m.begin("second".to_string());
438        assert!(old_signal.is_cancelled());
439        // New signal should not be cancelled.
440        assert!(!m.signal().unwrap().is_cancelled());
441    }
442
443    #[test]
444    fn complete_failure_clears_previous_data() {
445        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(2));
446        m.begin("vars".to_string());
447        m.complete_success(42);
448        assert_eq!(m.data(), Some(&42));
449        // Succeed then fail: data should be cleared.
450        m.begin("vars2".to_string());
451        m.complete_failure(QueryError::response("fail"));
452        assert!(m.is_failure());
453        assert!(
454            m.data().is_none(),
455            "data from previous success must be cleared on failure"
456        );
457    }
458
459    #[test]
460    fn cancel_increments_cancelled_count() {
461        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
462        assert_eq!(m.cancelled_count(), 0);
463        m.begin("vars".to_string());
464        m.cancel(QueryError::cancelled("aborted"));
465        assert_eq!(m.cancelled_count(), 1);
466        m.begin("vars2".to_string());
467        m.cancel(QueryError::cancelled("aborted2"));
468        assert_eq!(m.cancelled_count(), 2);
469    }
470
471    #[test]
472    fn reset_clears_cancelled_count() {
473        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
474        m.begin("vars".to_string());
475        m.cancel(QueryError::cancelled("aborted"));
476        assert_eq!(m.cancelled_count(), 1);
477        m.reset();
478        assert_eq!(m.cancelled_count(), 0);
479    }
480
481    #[test]
482    fn cancel_on_idle_is_noop() {
483        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
484        assert!(m.is_idle());
485        m.cancel(QueryError::cancelled("aborted"));
486        assert!(m.is_idle(), "cancel on Idle should be a no-op");
487        assert_eq!(m.cancelled_count(), 0);
488        assert!(m.error().is_none());
489    }
490
491    #[test]
492    fn cancel_on_success_is_noop() {
493        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
494        m.begin("vars".to_string());
495        m.complete_success(42);
496        m.cancel(QueryError::cancelled("aborted"));
497        assert!(m.is_success(), "cancel on Success should be a no-op");
498        assert_eq!(m.cancelled_count(), 0);
499        assert_eq!(m.data(), Some(&42));
500    }
501
502    #[test]
503    fn cancel_on_failure_is_noop() {
504        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::no_retries());
505        m.begin("vars".to_string());
506        m.complete_failure(QueryError::response("fail"));
507        m.cancel(QueryError::cancelled("aborted"));
508        assert!(m.is_failure(), "cancel on Failure should be a no-op");
509        assert_eq!(m.cancelled_count(), 0);
510    }
511
512    #[test]
513    fn begin_resets_retry_count() {
514        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(2));
515        m.begin("vars".to_string());
516        m.complete_failure(QueryError::response("fail"));
517        assert_eq!(m.retry_count(), 1);
518        // Starting a new invocation resets retry_count.
519        m.begin("vars2".to_string());
520        assert_eq!(m.retry_count(), 0, "begin() should reset retry_count");
521    }
522
523    #[test]
524    fn begin_resets_retry_count_allows_fresh_retries() {
525        let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(1));
526        // First invocation: fail, exhaust retries.
527        m.begin("vars".to_string());
528        m.complete_failure(QueryError::response("fail"));
529        assert_eq!(m.retry_count(), 1);
530        assert!(
531            !m.should_retry(),
532            "retries exhausted after first invocation"
533        );
534        // Second invocation: begin resets retry_count, so retries are fresh.
535        m.begin("vars2".to_string());
536        assert_eq!(m.retry_count(), 0);
537        assert!(
538            m.should_retry(),
539            "should_retry should be true after begin resets retry_count"
540        );
541        m.complete_failure(QueryError::response("fail again"));
542        assert_eq!(m.retry_count(), 1);
543        assert!(
544            !m.should_retry(),
545            "retries exhausted after second invocation"
546        );
547    }
548}