Skip to main content

relay_server/services/projects/cache/
state.rs

1use futures::StreamExt;
2use std::fmt;
3use std::sync::Arc;
4use std::time::Duration;
5use tokio::sync::Notify;
6use tokio::sync::futures::Notified;
7use tokio::time::Instant;
8
9use arc_swap::ArcSwap;
10use relay_base_schema::project::ProjectKey;
11use relay_config::ConfigSnapshot;
12use relay_quotas::CachedRateLimits;
13use relay_statsd::metric;
14
15use crate::services::projects::project::{ProjectState, Revision};
16use crate::services::projects::source::SourceProjectState;
17use crate::statsd::{RelayDistributions, RelayTimers};
18use crate::utils::{RetryBackoff, UniqueScheduledQueue};
19
20/// The backing storage for a project cache.
21///
22/// Exposes the only interface to delete from [`Shared`], guaranteed by
23/// requiring exclusive/mutable access to [`ProjectStore`].
24///
25/// [`Shared`] can be extended through [`Shared::get_or_create`], in which case
26/// the private state is missing. Users of [`Shared::get_or_create`] *must* trigger
27/// a fetch to create the private state and keep it updated.
28/// This guarantees that eventually the project state is populated, but for a undetermined,
29/// time it is possible that shared state exists without the respective private state.
30pub struct ProjectStore {
31    config: Config,
32    /// The shared state, which can be accessed concurrently.
33    shared: Arc<Shared>,
34    /// The private, mutably exclusive state, used to maintain the project state.
35    private: hashbrown::HashMap<ProjectKey, PrivateProjectState>,
36    /// Scheduled queue tracking all evictions.
37    evictions: UniqueScheduledQueue<ProjectKey>,
38    /// Scheduled queue tracking all refreshes.
39    refreshes: UniqueScheduledQueue<ProjectKey>,
40}
41
42impl ProjectStore {
43    pub fn new(config: &ConfigSnapshot) -> Self {
44        Self {
45            config: Config::new(config),
46            shared: Default::default(),
47            private: Default::default(),
48            evictions: Default::default(),
49            refreshes: Default::default(),
50        }
51    }
52
53    /// Retrieves a [`Shared`] handle which can be freely shared with multiple consumers.
54    pub fn shared(&self) -> Arc<Shared> {
55        Arc::clone(&self.shared)
56    }
57
58    /// Tries to begin a new fetch for the passed `project_key`.
59    ///
60    /// Returns `None` if no fetch is necessary or there is already a fetch ongoing.
61    /// A returned [`Fetch`] must be scheduled and completed with [`Fetch::complete`] and
62    /// [`Self::complete_fetch`].
63    pub fn try_begin_fetch(&mut self, project_key: ProjectKey) -> Option<Fetch> {
64        self.do_try_begin_fetch(project_key, false)
65    }
66
67    /// Completes a [`CompletedFetch`] started with [`Self::try_begin_fetch`].
68    ///
69    /// Returns a new [`Fetch`] if another fetch must be scheduled. This happens when the fetched
70    /// [`ProjectState`] is still pending or already deemed expired.
71    #[must_use = "an incomplete fetch must be retried"]
72    pub fn complete_fetch(&mut self, fetch: CompletedFetch) -> Option<Fetch> {
73        let project_key = fetch.project_key();
74
75        // Eviction is not possible for projects which are currently being fetched.
76        // Hence if there was a started fetch, the project state must always exist at this stage.
77        debug_assert!(self.shared.projects.pin().get(&project_key).is_some());
78        debug_assert!(self.private.get(&project_key).is_some());
79
80        let mut project = self.get_or_create(project_key);
81        // Schedule another fetch if necessary, usually should only happen if
82        // the completed fetch is pending.
83        let new_fetch = match project.complete_fetch(fetch) {
84            FetchResult::ReSchedule { refresh } => project.try_begin_fetch(refresh),
85            FetchResult::Done { expiry, refresh } => {
86                self.evictions.schedule(expiry.0, project_key);
87                if let Some(RefreshTime(refresh)) = refresh {
88                    self.refreshes.schedule(refresh, project_key);
89                }
90                None
91            }
92        };
93
94        metric!(
95            distribution(RelayDistributions::ProjectStateCacheSize) =
96                self.shared.projects.len() as u64,
97            storage = "shared"
98        );
99        metric!(
100            distribution(RelayDistributions::ProjectStateCacheSize) = self.private.len() as u64,
101            storage = "private"
102        );
103
104        new_fetch
105    }
106
107    /// Waits for the next scheduled action.
108    ///
109    /// The returned [`Action`] must be immediately turned in using the corresponding handlers,
110    /// [`Self::evict`] or [`Self::refresh`].
111    ///
112    /// The returned future is cancellation safe.
113    pub async fn poll(&mut self) -> Option<Action> {
114        let eviction = self.evictions.next();
115        let refresh = self.refreshes.next();
116
117        tokio::select! {
118            biased;
119
120            Some(e) = eviction => Some(Action::Eviction(Eviction(e))),
121            Some(r) = refresh => Some(Action::Refresh(Refresh(r))),
122            else => None,
123        }
124    }
125
126    /// Refreshes a project using an [`Refresh`] token returned from [`Self::poll`].
127    ///
128    /// Like [`Self::try_begin_fetch`], this returns a [`Fetch`], if there was no fetch
129    /// already started in the meantime.
130    ///
131    /// A returned [`Fetch`] must be scheduled and completed with [`Fetch::complete`] and
132    /// [`Self::complete_fetch`].
133    pub fn refresh(&mut self, Refresh(project_key): Refresh) -> Option<Fetch> {
134        self.do_try_begin_fetch(project_key, true)
135    }
136
137    /// Evicts a project using an [`Eviction`] token returned from [`Self::poll`].
138    pub fn evict(&mut self, Eviction(project_key): Eviction) {
139        // Remove the private part.
140        let Some(private) = self.private.remove(&project_key) else {
141            // Not possible if all invariants are upheld.
142            debug_assert!(false, "no private state for eviction");
143            return;
144        };
145
146        debug_assert!(
147            matches!(private.state, FetchState::Complete { .. }),
148            "private state must be completed"
149        );
150
151        // Remove the shared part.
152        let shared = self.shared.projects.pin();
153        let _removed = shared.remove(&project_key);
154        debug_assert!(
155            _removed.is_some(),
156            "an expired project must exist in the shared state"
157        );
158
159        // Cancel next refresh, while not necessary (trying to refresh a project which does not
160        // exist, will do nothing), but we can also spare us the extra work.
161        self.refreshes.remove(&project_key);
162    }
163
164    /// Internal handler to begin a new fetch for the passed `project_key`, which can also handle
165    /// refreshes.
166    fn do_try_begin_fetch(&mut self, project_key: ProjectKey, is_refresh: bool) -> Option<Fetch> {
167        let fetch = match is_refresh {
168            // A rogue refresh does not need to trigger an actual fetch.
169            // In practice this should never happen, as the refresh time is validated against
170            // the eviction time.
171            // But it may happen due to a race of the eviction and refresh (e.g. when setting them
172            // to close to the same value), in which case we don't want to re-populate the cache.
173            true => self.get(project_key)?,
174            false => self.get_or_create(project_key),
175        }
176        .try_begin_fetch(is_refresh);
177
178        // If there is a new fetch, remove the pending eviction, it will be re-scheduled once the
179        // fetch is completed.
180        if fetch.is_some() {
181            self.evictions.remove(&project_key);
182            // There is no need to clear the refresh here, if it triggers while a fetch is ongoing,
183            // it is simply discarded.
184        }
185
186        fetch
187    }
188
189    /// Get a reference to the current project or create a new project.
190    ///
191    /// For internal use only, a created project must always be fetched immediately.
192    fn get(&mut self, project_key: ProjectKey) -> Option<ProjectRef<'_>> {
193        let private = self.private.get_mut(&project_key)?;
194
195        // Same invariant as in `get_or_create`, we have exclusive access to the private
196        // project here, there must be a shared project if there is a private project.
197        debug_assert!(self.shared.projects.pin().contains_key(&project_key));
198
199        let shared = self
200            .shared
201            .projects
202            .pin()
203            .get_or_insert_with(project_key, Default::default)
204            .clone();
205
206        Some(ProjectRef {
207            private,
208            shared,
209            config: &self.config,
210        })
211    }
212
213    /// Get a reference to the current project or create a new project.
214    ///
215    /// For internal use only, a created project must always be fetched immediately.
216    fn get_or_create(&mut self, project_key: ProjectKey) -> ProjectRef<'_> {
217        #[cfg(debug_assertions)]
218        if self.private.contains_key(&project_key) {
219            // We have exclusive access to the private part, there are no concurrent deletions
220            // hence if we have a private state there must always be a shared state as well.
221            //
222            // The opposite is not true, the shared state may have been created concurrently
223            // through the shared access.
224            debug_assert!(self.shared.projects.pin().contains_key(&project_key));
225        }
226
227        let private = self
228            .private
229            .entry(project_key)
230            .or_insert_with(|| PrivateProjectState::new(project_key, &self.config));
231
232        let shared = self
233            .shared
234            .projects
235            .pin()
236            .get_or_insert_with(project_key, Default::default)
237            .clone();
238
239        ProjectRef {
240            private,
241            shared,
242            config: &self.config,
243        }
244    }
245}
246
247/// Configuration for a [`ProjectStore`].
248struct Config {
249    /// Expiry timeout for individual project configs.
250    ///
251    /// Note: the total expiry is the sum of the expiry and grace period.
252    expiry: Duration,
253    /// Grace period for a project config.
254    ///
255    /// A project config is considered stale and will be updated asynchronously,
256    /// after reaching the grace period.
257    grace_period: Duration,
258    /// Refresh interval for a single project.
259    ///
260    /// A project will be asynchronously refreshed repeatedly using this interval.
261    ///
262    /// The refresh interval is validated to be between expiration and grace period. An invalid refresh
263    /// time is ignored.
264    refresh_interval: Option<Duration>,
265    /// Maximum backoff for continuously failing project updates.
266    max_retry_backoff: Duration,
267}
268
269impl Config {
270    fn new(config: &ConfigSnapshot) -> Self {
271        let expiry = config.project_cache_expiry();
272        let grace_period = config.project_grace_period();
273
274        // Make sure the refresh time is:
275        // - at least the expiration, refreshing a non-stale project makes no sense.
276        // - at most the end of the grace period, refreshing an expired project also makes no sense.
277        let refresh_interval = config
278            .project_refresh_interval()
279            .filter(|rt| *rt < (expiry + grace_period))
280            .filter(|rt| *rt > expiry);
281
282        Self {
283            expiry: config.project_cache_expiry(),
284            grace_period: config.project_grace_period(),
285            refresh_interval,
286            max_retry_backoff: config.http_max_retry_interval(),
287        }
288    }
289}
290
291/// The shared and concurrently accessible handle to the project cache.
292#[derive(Default)]
293pub struct Shared {
294    projects: papaya::HashMap<ProjectKey, SharedProjectState, ahash::RandomState>,
295}
296
297impl Shared {
298    /// Returns the existing project state or creates a new one.
299    ///
300    /// The caller must ensure that the project cache is instructed to
301    /// [`super::ProjectCache::Fetch`] the retrieved project.
302    pub fn get_or_create(&self, project_key: ProjectKey) -> SharedProject {
303        self.get_or_create_inner(project_key).to_shared_project()
304    }
305
306    fn get_or_create_inner(&self, project_key: ProjectKey) -> SharedProjectState {
307        // The fast path, we expect the project to exist.
308        let projects = self.projects.pin();
309        if let Some(project) = projects.get(&project_key) {
310            return project.clone();
311        }
312
313        // The slow path, try to attempt to insert, somebody else may have been faster, but that's okay.
314        match projects.try_insert(project_key, Default::default()) {
315            Ok(inserted) => inserted.clone(),
316            Err(occupied) => occupied.current.clone(),
317        }
318    }
319}
320
321/// TEST ONLY bypass to make the project cache mockable.
322#[cfg(test)]
323impl Shared {
324    /// Updates the project state for a project.
325    ///
326    /// TEST ONLY!
327    pub fn test_set_project_state(&self, project_key: ProjectKey, state: ProjectState) {
328        self.projects
329            .pin()
330            .get_or_insert_with(project_key, Default::default)
331            .set_project_state(state);
332    }
333
334    /// Returns `true` if there exists a shared state for the passed `project_key`.
335    pub fn test_has_project_created(&self, project_key: ProjectKey) -> bool {
336        self.projects.pin().contains_key(&project_key)
337    }
338}
339
340impl fmt::Debug for Shared {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        f.debug_struct("Shared")
343            .field("num_projects", &self.projects.len())
344            .finish()
345    }
346}
347
348/// A single project from the [`Shared`] project cache.
349pub struct SharedProject(Arc<SharedProjectStateInner>);
350
351impl SharedProject {
352    /// Returns a reference to the contained [`ProjectState`].
353    pub fn project_state(&self) -> &ProjectState {
354        &self.0.state
355    }
356
357    /// Returns a reference to the contained [`CachedRateLimits`].
358    pub fn cached_rate_limits(&self) -> &CachedRateLimits {
359        // Exposing cached rate limits may be a bad idea, this allows mutation
360        // and caching of rate limits for pending projects, which may or may not be fine.
361        // Although, for now this is fine.
362        //
363        // Read only access is easily achievable if we return only the current rate limits.
364        &self.0.rate_limits
365    }
366
367    /// Waits for the event of a changed project state, triggered by [`SharedProjectState::set_project_state`].
368    ///
369    /// Note that the content of this instance does not change when the event is triggered.
370    pub fn outdated(&self) -> Notified<'_> {
371        self.0.notify.notified()
372    }
373}
374
375/// TEST ONLY bypass to make the project cache mockable.
376#[cfg(test)]
377impl SharedProject {
378    /// Creates a new [`SharedProject`] for testing only.
379    pub fn for_test(state: ProjectState) -> Self {
380        Self(Arc::new(SharedProjectStateInner {
381            state,
382            ..Default::default()
383        }))
384    }
385}
386
387/// Reference to a full project wrapping shared and private state.
388struct ProjectRef<'a> {
389    shared: SharedProjectState,
390    private: &'a mut PrivateProjectState,
391    config: &'a Config,
392}
393
394impl ProjectRef<'_> {
395    fn try_begin_fetch(&mut self, is_refresh: bool) -> Option<Fetch> {
396        let now = Instant::now();
397        self.private
398            .try_begin_fetch(now, is_refresh, self.config)
399            .map(|fetch| fetch.with_revision(self.shared.revision()))
400    }
401
402    fn complete_fetch(&mut self, fetch: CompletedFetch) -> FetchResult {
403        let now = Instant::now();
404
405        if let Some(latency) = fetch.latency() {
406            let delay = match fetch.delay() {
407                Some(delay) if delay.as_secs() <= 15 => "lte15s",
408                Some(delay) if delay.as_secs() <= 30 => "lte30s",
409                Some(delay) if delay.as_secs() <= 60 => "lte60s",
410                Some(delay) if delay.as_secs() <= 120 => "lte120",
411                Some(delay) if delay.as_secs() <= 300 => "lte300s",
412                Some(delay) if delay.as_secs() <= 600 => "lte600s",
413                Some(delay) if delay.as_secs() <= 1800 => "lte1800s",
414                Some(delay) if delay.as_secs() <= 3600 => "lte3600s",
415                Some(_) => "gt3600s",
416                None => "none",
417            };
418            metric!(
419                timer(RelayTimers::ProjectCacheUpdateLatency) = latency,
420                delay = delay
421            );
422        }
423
424        if !fetch.is_pending() {
425            let state = match fetch.state {
426                SourceProjectState::New(_) => "new",
427                SourceProjectState::NotModified => "not_modified",
428            };
429
430            metric!(
431                timer(RelayTimers::ProjectCacheFetchDuration) = fetch.duration(now),
432                state = state
433            );
434        }
435
436        // Update private and shared state with the new data.
437        let result = self.private.complete_fetch(&fetch, now, self.config);
438        match fetch.state {
439            // Keep the old state around if the current fetch is pending.
440            // It may still be useful to callers.
441            SourceProjectState::New(state) if !state.is_pending() => {
442                self.shared.set_project_state(state);
443            }
444            _ => {}
445        }
446
447        result
448    }
449}
450
451pub enum Action {
452    Eviction(Eviction),
453    Refresh(Refresh),
454}
455
456/// A [`Refresh`] token.
457///
458/// The token must be turned in using [`ProjectStore::refresh`].
459#[derive(Debug)]
460#[must_use = "a refresh must be used"]
461pub struct Refresh(ProjectKey);
462
463impl Refresh {
464    /// Returns the [`ProjectKey`] of the project that needs to be refreshed.
465    pub fn project_key(&self) -> ProjectKey {
466        self.0
467    }
468}
469
470/// A [`Eviction`] token.
471///
472/// The token must be turned in using [`ProjectStore::evict`].
473#[derive(Debug)]
474#[must_use = "an eviction must be used"]
475pub struct Eviction(ProjectKey);
476
477impl Eviction {
478    /// Returns the [`ProjectKey`] of the project that needs to be evicted.
479    pub fn project_key(&self) -> ProjectKey {
480        self.0
481    }
482}
483
484/// A [`Fetch`] token.
485///
486/// When returned it must be executed and completed using [`Self::complete`].
487#[must_use = "a fetch must be executed"]
488#[derive(Debug)]
489pub struct Fetch {
490    project_key: ProjectKey,
491    previous_fetch: Option<Instant>,
492    initiated: Instant,
493    when: Option<Instant>,
494    revision: Revision,
495}
496
497impl Fetch {
498    /// Returns the [`ProjectKey`] of the project to fetch.
499    pub fn project_key(&self) -> ProjectKey {
500        self.project_key
501    }
502
503    /// Returns when the fetch for the project should be scheduled.
504    ///
505    /// This can be now (as soon as possible, indicated by `None`) or a later point in time,
506    /// if the project is currently in a backoff.
507    pub fn when(&self) -> Option<Instant> {
508        self.when
509    }
510
511    /// Returns the revisions of the currently cached project.
512    ///
513    /// If the upstream indicates it does not have a different version of this project
514    /// we do not need to update the local state.
515    pub fn revision(&self) -> Revision {
516        self.revision.clone()
517    }
518
519    /// Completes the fetch with a result and returns a [`CompletedFetch`].
520    pub fn complete(self, state: SourceProjectState) -> CompletedFetch {
521        CompletedFetch { fetch: self, state }
522    }
523
524    fn with_revision(mut self, revision: Revision) -> Self {
525        self.revision = revision;
526        self
527    }
528}
529
530/// The result of an executed [`Fetch`].
531#[must_use = "a completed fetch must be acted upon"]
532#[derive(Debug)]
533pub struct CompletedFetch {
534    fetch: Fetch,
535    state: SourceProjectState,
536}
537
538impl CompletedFetch {
539    /// Returns the [`ProjectKey`] of the project which was fetched.
540    pub fn project_key(&self) -> ProjectKey {
541        self.fetch.project_key()
542    }
543
544    /// Returns the amount of time passed between the last successful fetch for this project and the start of this fetch.
545    ///
546    /// `None` if this is the first fetch.
547    fn delay(&self) -> Option<Duration> {
548        self.fetch
549            .previous_fetch
550            .map(|pf| self.fetch.initiated.duration_since(pf))
551    }
552
553    /// Returns the duration between first initiating the fetch and `now`.
554    fn duration(&self, now: Instant) -> Duration {
555        now.duration_since(self.fetch.initiated)
556    }
557
558    /// Returns the update latency of the fetched project config from the upstream.
559    ///
560    /// Is `None`, when no project config could be fetched, or if this was the first
561    /// fetch of a project config.
562    ///
563    /// Note: this latency is computed on access, it does not use the time when the [`Fetch`]
564    /// was marked as (completed)[`Fetch::complete`].
565    fn latency(&self) -> Option<Duration> {
566        // We're not interested in initial fetches. The latency on the first fetch
567        // has no meaning about how long it takes for an updated project config to be
568        // propagated to a Relay.
569        let is_first_fetch = self.fetch.revision().as_str().is_none();
570        if is_first_fetch {
571            return None;
572        }
573
574        let project_info = match &self.state {
575            SourceProjectState::New(ProjectState::Enabled(project_info)) => project_info,
576            // Not modified or deleted/disabled -> no latency to track.
577            //
578            // Currently we discard the last changed timestamp for disabled projects,
579            // it would be possible to do so and then also expose a latency for disabled projects.
580            _ => return None,
581        };
582
583        // A matching revision is not an update.
584        if project_info.rev == self.fetch.revision {
585            return None;
586        }
587
588        let elapsed = chrono::Utc::now() - project_info.last_change?;
589        elapsed.to_std().ok()
590    }
591
592    /// Returns `true` if the fetch completed with a pending status.
593    fn is_pending(&self) -> bool {
594        match &self.state {
595            SourceProjectState::New(state) => state.is_pending(),
596            SourceProjectState::NotModified => false,
597        }
598    }
599}
600
601/// The state of a project contained in the [`Shared`] project cache.
602///
603/// This state is interior mutable and allows updates to the project.
604#[derive(Debug, Default, Clone)]
605struct SharedProjectState(Arc<ArcSwap<SharedProjectStateInner>>);
606
607impl SharedProjectState {
608    /// Updates the project state.
609    fn set_project_state(&self, state: ProjectState) {
610        let prev = self.0.rcu(|stored| SharedProjectStateInner {
611            state: state.clone(),
612            rate_limits: Arc::clone(&stored.rate_limits),
613            notify: Arc::clone(&stored.notify),
614        });
615
616        // Finally, notify listeners:
617        prev.notify.notify_waiters();
618    }
619
620    /// Extracts and clones the revision from the contained project state.
621    fn revision(&self) -> Revision {
622        self.0.as_ref().load().state.revision().clone()
623    }
624
625    /// Transforms this interior mutable handle to an immutable [`SharedProject`].
626    fn to_shared_project(&self) -> SharedProject {
627        SharedProject(self.0.as_ref().load_full())
628    }
629}
630
631/// The data contained in a [`SharedProjectState`].
632///
633/// All fields must be cheap to clone and are ideally just a single `Arc`.
634/// Partial updates to [`SharedProjectState`], are performed using `rcu` cloning all fields.
635#[derive(Debug, Default)]
636struct SharedProjectStateInner {
637    state: ProjectState,
638    rate_limits: Arc<CachedRateLimits>,
639    notify: Arc<Notify>,
640}
641
642/// Current fetch state for a project.
643///
644/// ─────► Pending ◄─────┐
645///           │          │
646///           │          │Backoff
647///           ▼          │
648/// ┌───► InProgress ────┘
649/// │         │
650/// │         │
651/// │         ▼
652/// └───── Complete
653#[derive(Debug)]
654enum FetchState {
655    /// There is a fetch currently in progress.
656    InProgress {
657        /// Whether the current check in progress was triggered from a refresh.
658        ///
659        /// Triggering a non-refresh fetch while a refresh fetch is currently in progress,
660        /// will overwrite this property.
661        is_refresh: bool,
662    },
663    /// A successful fetch is pending.
664    ///
665    /// Projects which have not yet been fetched are in the pending state,
666    /// as well as projects which have a fetch in progress but were notified
667    /// from upstream that the project config is still pending.
668    ///
669    /// If the upstream notifies this instance about a pending config,
670    /// a backoff is applied, before trying again.
671    Pending {
672        /// Instant when the fetch was first initiated.
673        ///
674        /// A state may be transitioned multiple times from [`Self::Pending`] to [`Self::InProgress`]
675        /// and back to [`Self::Pending`]. This timestamp is the first time when the state
676        /// was transitioned from [`Self::Complete`] to [`Self::InProgress`].
677        ///
678        /// Only `None` on first fetch.
679        initiated: Option<Instant>,
680        /// Time when the next fetch should be attempted.
681        ///
682        /// `None` means soon as possible.
683        next_fetch_attempt: Option<Instant>,
684    },
685    /// There was a successful non-pending fetch.
686    Complete {
687        /// Time when the fetch was completed.
688        when: LastFetch,
689    },
690}
691
692/// Contains all mutable state necessary to maintain the project cache.
693struct PrivateProjectState {
694    /// Project key this state belongs to.
695    project_key: ProjectKey,
696
697    /// The current fetch state.
698    state: FetchState,
699    /// The current backoff used for calculating the next fetch attempt.
700    ///
701    /// The backoff is reset after a successful, non-pending fetch.
702    backoff: RetryBackoff,
703
704    /// The last time the state was successfully fetched.
705    ///
706    /// May be `None` when the state has never been successfully fetched.
707    ///
708    /// This is purely informational, all necessary information to make
709    /// state transitions is contained in [`FetchState`].
710    last_fetch: Option<Instant>,
711
712    /// The expiry time of this project.
713    ///
714    /// A refresh of the project, will not push the expiration time.
715    expiry: Option<Instant>,
716}
717
718impl PrivateProjectState {
719    fn new(project_key: ProjectKey, config: &Config) -> Self {
720        Self {
721            project_key,
722            state: FetchState::Pending {
723                initiated: None,
724                next_fetch_attempt: None,
725            },
726            backoff: RetryBackoff::new(config.max_retry_backoff),
727            last_fetch: None,
728            expiry: None,
729        }
730    }
731
732    fn try_begin_fetch(
733        &mut self,
734        now: Instant,
735        is_refresh: bool,
736        config: &Config,
737    ) -> Option<Fetch> {
738        let (initiated, when) = match &mut self.state {
739            FetchState::InProgress {
740                is_refresh: refresh_in_progress,
741            } => {
742                relay_log::trace!(
743                    tags.project_key = self.project_key.as_str(),
744                    "project fetch skipped, fetch in progress"
745                );
746                // Upgrade the refresh status if necessary.
747                *refresh_in_progress = *refresh_in_progress && is_refresh;
748                return None;
749            }
750            FetchState::Pending {
751                initiated,
752                next_fetch_attempt,
753            } => {
754                // Schedule a new fetch, even if there is a backoff, it will just be sleeping for a while.
755                (initiated.unwrap_or(now), *next_fetch_attempt)
756            }
757            FetchState::Complete { when } => {
758                // Sanity check to make sure timestamps do not drift.
759                debug_assert_eq!(Some(when.0), self.last_fetch);
760
761                if when.check_expiry(now, config).is_fresh() {
762                    // The current state is up to date, no need to start another fetch.
763                    relay_log::trace!(
764                        tags.project_key = self.project_key.as_str(),
765                        "project fetch skipped, already up to date"
766                    );
767                    return None;
768                }
769
770                (now, None)
771            }
772        };
773
774        // Mark a current fetch in progress.
775        self.state = FetchState::InProgress { is_refresh };
776
777        relay_log::trace!(
778            tags.project_key = &self.project_key.as_str(),
779            attempts = self.backoff.attempt() + 1,
780            "project state {} scheduled in {:?}",
781            if is_refresh { "refresh" } else { "fetch" },
782            when.unwrap_or(now).saturating_duration_since(now),
783        );
784
785        Some(Fetch {
786            project_key: self.project_key,
787            previous_fetch: self.last_fetch,
788            initiated,
789            when,
790            revision: Revision::default(),
791        })
792    }
793
794    fn complete_fetch(
795        &mut self,
796        fetch: &CompletedFetch,
797        now: Instant,
798        config: &Config,
799    ) -> FetchResult {
800        let FetchState::InProgress { is_refresh } = self.state else {
801            debug_assert!(
802                false,
803                "fetch completed while there was no current fetch registered"
804            );
805            // Be conservative in production.
806            return FetchResult::ReSchedule { refresh: false };
807        };
808
809        if fetch.is_pending() {
810            let next_backoff = self.backoff.next_backoff();
811            let next_fetch_attempt = match next_backoff.is_zero() {
812                false => now.checked_add(next_backoff),
813                true => None,
814            };
815            self.state = FetchState::Pending {
816                next_fetch_attempt,
817                initiated: Some(fetch.fetch.initiated),
818            };
819            relay_log::trace!(
820                tags.project_key = &self.project_key.as_str(),
821                "project state {} completed but still pending",
822                if is_refresh { "refresh" } else { "fetch" },
823            );
824
825            FetchResult::ReSchedule {
826                refresh: is_refresh,
827            }
828        } else {
829            relay_log::trace!(
830                tags.project_key = &self.project_key.as_str(),
831                "project state {} completed with non-pending config",
832                if is_refresh { "refresh" } else { "fetch" },
833            );
834
835            self.backoff.reset();
836            self.last_fetch = Some(now);
837
838            let when = LastFetch(now);
839
840            let refresh = when.refresh_time(config);
841            let expiry = match self.expiry {
842                Some(expiry) if is_refresh => ExpiryTime(expiry),
843                // Only bump/re-compute the expiry time if the fetch was not a refresh,
844                // to not keep refreshed projects forever in the cache.
845                Some(_) | None => when.expiry_time(config),
846            };
847            self.expiry = Some(expiry.0);
848
849            self.state = FetchState::Complete { when };
850            FetchResult::Done { expiry, refresh }
851        }
852    }
853}
854
855/// Result returned when completing a fetch.
856#[derive(Debug)]
857#[must_use = "fetch result must be used"]
858enum FetchResult {
859    /// Another fetch must be scheduled immediately.
860    ReSchedule {
861        /// Whether the fetch should be re-scheduled as a refresh.
862        refresh: bool,
863    },
864    /// The fetch is completed and should be registered for refresh and eviction.
865    Done {
866        /// When the project should be expired.
867        expiry: ExpiryTime,
868        /// When the project should be refreshed.
869        refresh: Option<RefreshTime>,
870    },
871}
872
873/// New type containing the last successful fetch time as an [`Instant`].
874#[derive(Debug, Copy, Clone)]
875struct LastFetch(Instant);
876
877impl LastFetch {
878    /// Returns the [`Expiry`] of the last fetch in relation to `now`.
879    fn check_expiry(&self, now: Instant, config: &Config) -> Expiry {
880        let elapsed = now.saturating_duration_since(self.0);
881
882        if elapsed >= config.expiry + config.grace_period {
883            Expiry::Expired
884        } else if elapsed >= config.expiry {
885            Expiry::Stale
886        } else {
887            Expiry::Fresh
888        }
889    }
890
891    /// Returns when the project needs to be queued for a refresh.
892    fn refresh_time(&self, config: &Config) -> Option<RefreshTime> {
893        config
894            .refresh_interval
895            .map(|duration| self.0 + duration)
896            .map(RefreshTime)
897    }
898
899    /// Returns when the project is based to expire based on the current [`LastFetch`].
900    fn expiry_time(&self, config: &Config) -> ExpiryTime {
901        ExpiryTime(self.0 + config.grace_period + config.expiry)
902    }
903}
904
905/// Expiry state of a project.
906#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
907enum Expiry {
908    /// The project state is perfectly up to date.
909    Fresh,
910    /// The project state is outdated but events depending on this project state can still be
911    /// processed. The state should be refreshed in the background though.
912    Stale,
913    /// The project state is completely outdated and events need to be buffered up until the new
914    /// state has been fetched.
915    Expired,
916}
917
918impl Expiry {
919    /// Returns `true` if the project is up-to-date and does not need to be fetched.
920    fn is_fresh(&self) -> bool {
921        matches!(self, Self::Fresh)
922    }
923}
924
925/// Instant when a project is scheduled for refresh.
926#[derive(Debug)]
927#[must_use = "an refresh time must be used to schedule a refresh"]
928struct RefreshTime(Instant);
929
930/// Instant when a project is scheduled for expiry.
931#[derive(Debug)]
932#[must_use = "an expiry time must be used to schedule an eviction"]
933struct ExpiryTime(Instant);
934
935#[cfg(test)]
936mod tests {
937    use std::time::Duration;
938
939    use relay_config::Config;
940
941    use super::*;
942
943    async fn collect_evicted(store: &mut ProjectStore) -> Vec<ProjectKey> {
944        let mut evicted = Vec::new();
945        // Small timeout to really only get what is ready to be evicted right now.
946        while let Ok(Some(Action::Eviction(eviction))) =
947            tokio::time::timeout(Duration::from_nanos(5), store.poll()).await
948        {
949            evicted.push(eviction.0);
950            store.evict(eviction);
951        }
952        evicted
953    }
954
955    macro_rules! assert_state {
956        ($store:ident, $project_key:ident, $state:pat) => {
957            assert!(matches!(
958                $store.shared().get_or_create($project_key).project_state(),
959                $state
960            ));
961        };
962    }
963
964    #[tokio::test(start_paused = true)]
965    async fn test_store_fetch() {
966        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
967        let mut store = ProjectStore::new(&Config::default().current());
968
969        let fetch = store.try_begin_fetch(project_key).unwrap();
970        assert_eq!(fetch.project_key(), project_key);
971        assert_eq!(fetch.when(), None);
972        assert_eq!(fetch.revision().as_str(), None);
973        assert_state!(store, project_key, ProjectState::Pending);
974
975        // Fetch already in progress, nothing to do.
976        assert!(store.try_begin_fetch(project_key).is_none());
977
978        // A pending fetch should trigger a new fetch immediately.
979        let fetch = fetch.complete(ProjectState::Pending.into());
980        let fetch = store.complete_fetch(fetch).unwrap();
981        assert_eq!(fetch.project_key(), project_key);
982        // First backoff is still immediately.
983        assert_eq!(fetch.when(), None);
984        assert_eq!(fetch.revision().as_str(), None);
985        assert_state!(store, project_key, ProjectState::Pending);
986
987        // Pending again.
988        let fetch = fetch.complete(ProjectState::Pending.into());
989        let fetch = store.complete_fetch(fetch).unwrap();
990        assert_eq!(fetch.project_key(), project_key);
991        // This time it needs to be in the future (backoff).
992        assert!(fetch.when() > Some(Instant::now()));
993        assert_eq!(fetch.revision().as_str(), None);
994        assert_state!(store, project_key, ProjectState::Pending);
995
996        // Now complete with disabled.
997        let fetch = fetch.complete(ProjectState::Disabled.into());
998        assert!(store.complete_fetch(fetch).is_none());
999        assert_state!(store, project_key, ProjectState::Disabled);
1000
1001        // A new fetch is not yet necessary.
1002        assert!(store.try_begin_fetch(project_key).is_none());
1003    }
1004
1005    #[tokio::test(start_paused = true)]
1006    async fn test_store_fetch_pending_does_not_replace_state() {
1007        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1008        let mut store = ProjectStore::new(
1009            &Config::from_json_value(serde_json::json!({
1010                "cache": {
1011                    "project_expiry": 5,
1012                    "project_grace_period": 5,
1013                }
1014            }))
1015            .unwrap()
1016            .current(),
1017        );
1018
1019        let fetch = store.try_begin_fetch(project_key).unwrap();
1020        let fetch = fetch.complete(ProjectState::Disabled.into());
1021        assert!(store.complete_fetch(fetch).is_none());
1022        assert_state!(store, project_key, ProjectState::Disabled);
1023
1024        tokio::time::advance(Duration::from_secs(6)).await;
1025
1026        let fetch = store.try_begin_fetch(project_key).unwrap();
1027        let fetch = fetch.complete(ProjectState::Pending.into());
1028        // We're returned a new fetch, because the current one completed pending.
1029        let fetch = store.complete_fetch(fetch).unwrap();
1030        // The old cached state is still available and not replaced.
1031        assert_state!(store, project_key, ProjectState::Disabled);
1032
1033        let fetch = fetch.complete(ProjectState::Dummy.into());
1034        assert!(store.complete_fetch(fetch).is_none());
1035        assert_state!(store, project_key, ProjectState::Dummy);
1036    }
1037
1038    #[tokio::test(start_paused = true)]
1039    async fn test_store_evict_projects() {
1040        let project_key1 = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1041        let project_key2 = ProjectKey::parse("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
1042        let mut store = ProjectStore::new(
1043            &Config::from_json_value(serde_json::json!({
1044                "cache": {
1045                    "project_expiry": 5,
1046                    "project_grace_period": 0,
1047                }
1048            }))
1049            .unwrap()
1050            .current(),
1051        );
1052
1053        let fetch = store.try_begin_fetch(project_key1).unwrap();
1054        let fetch = fetch.complete(ProjectState::Disabled.into());
1055        assert!(store.complete_fetch(fetch).is_none());
1056
1057        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1058        assert_state!(store, project_key1, ProjectState::Disabled);
1059
1060        // 3 seconds is not enough to expire any project.
1061        tokio::time::advance(Duration::from_secs(3)).await;
1062
1063        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1064        assert_state!(store, project_key1, ProjectState::Disabled);
1065
1066        let fetch = store.try_begin_fetch(project_key2).unwrap();
1067        let fetch = fetch.complete(ProjectState::Disabled.into());
1068        assert!(store.complete_fetch(fetch).is_none());
1069
1070        // A total of 6 seconds should expire the first project.
1071        tokio::time::advance(Duration::from_secs(3)).await;
1072
1073        assert_eq!(collect_evicted(&mut store).await, vec![project_key1]);
1074        assert_state!(store, project_key1, ProjectState::Pending);
1075        assert_state!(store, project_key2, ProjectState::Disabled);
1076    }
1077
1078    #[tokio::test(start_paused = true)]
1079    async fn test_store_evict_projects_pending_not_expired() {
1080        let project_key1 = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1081        let project_key2 = ProjectKey::parse("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
1082        let mut store = ProjectStore::new(
1083            &Config::from_json_value(serde_json::json!({
1084                "cache": {
1085                    "project_expiry": 5,
1086                    "project_grace_period": 0,
1087                }
1088            }))
1089            .unwrap()
1090            .current(),
1091        );
1092
1093        let fetch = store.try_begin_fetch(project_key1).unwrap();
1094        // Create a new project in a pending state, but never fetch it, this should also never expire.
1095        store.shared().get_or_create(project_key2);
1096
1097        tokio::time::advance(Duration::from_secs(6)).await;
1098
1099        // No evictions, project is pending.
1100        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1101
1102        // Complete the project.
1103        let fetch = fetch.complete(ProjectState::Disabled.into());
1104        assert!(store.complete_fetch(fetch).is_none());
1105
1106        // Still should not be evicted, because we do have 5 seconds to expire since completion.
1107        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1108        tokio::time::advance(Duration::from_secs(4)).await;
1109        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1110        assert_state!(store, project_key1, ProjectState::Disabled);
1111
1112        // Just enough to expire the project.
1113        tokio::time::advance(Duration::from_millis(1001)).await;
1114        assert_eq!(collect_evicted(&mut store).await, vec![project_key1]);
1115        assert_state!(store, project_key1, ProjectState::Pending);
1116        assert_state!(store, project_key2, ProjectState::Pending);
1117    }
1118
1119    #[tokio::test(start_paused = true)]
1120    async fn test_store_evict_projects_stale() {
1121        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1122        let mut store = ProjectStore::new(
1123            &Config::from_json_value(serde_json::json!({
1124                "cache": {
1125                    "project_expiry": 5,
1126                    "project_grace_period": 5,
1127                }
1128            }))
1129            .unwrap()
1130            .current(),
1131        );
1132
1133        let fetch = store.try_begin_fetch(project_key).unwrap();
1134        let fetch = fetch.complete(ProjectState::Disabled.into());
1135        assert!(store.complete_fetch(fetch).is_none());
1136
1137        // This is in the grace period, but not yet expired.
1138        tokio::time::advance(Duration::from_millis(9500)).await;
1139
1140        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1141        assert_state!(store, project_key, ProjectState::Disabled);
1142
1143        // Now it's expired.
1144        tokio::time::advance(Duration::from_secs(1)).await;
1145
1146        assert_eq!(collect_evicted(&mut store).await, vec![project_key]);
1147        assert_state!(store, project_key, ProjectState::Pending);
1148    }
1149
1150    #[tokio::test(start_paused = true)]
1151    async fn test_store_no_eviction_during_fetch() {
1152        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1153        let mut store = ProjectStore::new(
1154            &Config::from_json_value(serde_json::json!({
1155                "cache": {
1156                    "project_expiry": 5,
1157                    "project_grace_period": 5,
1158                }
1159            }))
1160            .unwrap()
1161            .current(),
1162        );
1163
1164        let fetch = store.try_begin_fetch(project_key).unwrap();
1165
1166        // Project is expired, but there is an ongoing fetch.
1167        tokio::time::advance(Duration::from_millis(10500)).await;
1168        // No evictions, there is a fetch ongoing!
1169        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1170
1171        // Complete the project.
1172        let fetch = fetch.complete(ProjectState::Disabled.into());
1173        assert!(store.complete_fetch(fetch).is_none());
1174        // But start a new fetch asap (after grace period).
1175        tokio::time::advance(Duration::from_millis(5001)).await;
1176        let fetch = store.try_begin_fetch(project_key).unwrap();
1177
1178        // Again, expire the project.
1179        tokio::time::advance(Duration::from_millis(10500)).await;
1180        // No evictions, there is a fetch ongoing!
1181        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1182
1183        // Complete the project.
1184        let fetch = fetch.complete(ProjectState::Disabled.into());
1185        assert!(store.complete_fetch(fetch).is_none());
1186
1187        // Not quite yet expired.
1188        tokio::time::advance(Duration::from_millis(9500)).await;
1189        assert_eq!(collect_evicted(&mut store).await, Vec::new());
1190        // Now it's expired.
1191        tokio::time::advance(Duration::from_millis(501)).await;
1192        assert_eq!(collect_evicted(&mut store).await, vec![project_key]);
1193        assert_state!(store, project_key, ProjectState::Pending);
1194    }
1195
1196    #[tokio::test(start_paused = true)]
1197    async fn test_store_refresh() {
1198        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1199        let mut store = ProjectStore::new(
1200            &Config::from_json_value(serde_json::json!({
1201                "cache": {
1202                    "project_expiry": 5,
1203                    "project_grace_period": 5,
1204                    "project_refresh_interval": 7,
1205                }
1206            }))
1207            .unwrap()
1208            .current(),
1209        );
1210
1211        let fetch = store.try_begin_fetch(project_key).unwrap();
1212        let fetch = fetch.complete(ProjectState::Disabled.into());
1213        assert!(store.complete_fetch(fetch).is_none());
1214        assert_state!(store, project_key, ProjectState::Disabled);
1215
1216        // Wait for a refresh.
1217        let Some(Action::Refresh(refresh)) = store.poll().await else {
1218            panic!();
1219        };
1220        assert_eq!(refresh.project_key(), project_key);
1221
1222        let fetch = store.refresh(refresh).unwrap();
1223        // Upgrade the pending refresh fetch to a non-refresh fetch.
1224        assert!(store.try_begin_fetch(project_key).is_none());
1225        let fetch = fetch.complete(ProjectState::Disabled.into());
1226        assert!(store.complete_fetch(fetch).is_none());
1227
1228        // Since the previous refresh has been upgraded to a proper fetch.
1229        // Expiration has been rescheduled and a new refresh is planned to happen in 7 seconds from
1230        // now.
1231        let Some(Action::Refresh(refresh)) = store.poll().await else {
1232            panic!();
1233        };
1234        let fetch = store.refresh(refresh).unwrap();
1235        let fetch = fetch.complete(ProjectState::Disabled.into());
1236        assert!(store.complete_fetch(fetch).is_none());
1237
1238        // At this point the refresh is through, but expiration is around the corner.
1239        // Because the refresh doesn't bump the expiration deadline.
1240        let Some(Action::Eviction(eviction)) = store.poll().await else {
1241            panic!();
1242        };
1243        assert_eq!(eviction.project_key(), project_key);
1244    }
1245
1246    #[tokio::test(start_paused = true)]
1247    async fn test_store_refresh_overtaken_by_eviction() {
1248        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1249        let mut store = ProjectStore::new(
1250            &Config::from_json_value(serde_json::json!({
1251                "cache": {
1252                    "project_expiry": 5,
1253                    "project_grace_period": 5,
1254                    "project_refresh_interval": 7,
1255                }
1256            }))
1257            .unwrap()
1258            .current(),
1259        );
1260
1261        let fetch = store.try_begin_fetch(project_key).unwrap();
1262        let fetch = fetch.complete(ProjectState::Disabled.into());
1263        assert!(store.complete_fetch(fetch).is_none());
1264        assert_state!(store, project_key, ProjectState::Disabled);
1265
1266        // Move way past the expiration time.
1267        tokio::time::advance(Duration::from_secs(20)).await;
1268
1269        // The eviction should be prioritized, there is no reason to refresh an already evicted
1270        // project.
1271        let Some(Action::Eviction(eviction)) = store.poll().await else {
1272            panic!();
1273        };
1274        assert_eq!(eviction.project_key(), project_key);
1275        store.evict(eviction);
1276
1277        // Make sure there is not another refresh queued.
1278        // This would not technically be necessary because refresh code must be able to handle
1279        // refreshes for non-fetched projects, but the current implementation should enforce this.
1280        assert!(
1281            tokio::time::timeout(Duration::from_secs(60), store.poll())
1282                .await
1283                .is_err()
1284        );
1285    }
1286
1287    #[tokio::test(start_paused = true)]
1288    async fn test_store_refresh_during_eviction() {
1289        let project_key = ProjectKey::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1290        let mut store = ProjectStore::new(
1291            &Config::from_json_value(serde_json::json!({
1292                "cache": {
1293                    "project_expiry": 5,
1294                    "project_grace_period": 5,
1295                    "project_refresh_interval": 7,
1296                }
1297            }))
1298            .unwrap()
1299            .current(),
1300        );
1301
1302        let fetch = store.try_begin_fetch(project_key).unwrap();
1303        let fetch = fetch.complete(ProjectState::Disabled.into());
1304        assert!(store.complete_fetch(fetch).is_none());
1305        assert_state!(store, project_key, ProjectState::Disabled);
1306
1307        // Move way past the expiration time.
1308        tokio::time::advance(Duration::from_secs(20)).await;
1309
1310        // Poll both the eviction and refresh token, while a proper implementation should prevent
1311        // this, it's a good way to test that a refresh for an evicted project does not fetch the
1312        // project.
1313        let Some(Action::Eviction(eviction)) = store.poll().await else {
1314            panic!();
1315        };
1316        let Some(Action::Refresh(refresh)) = store.poll().await else {
1317            panic!();
1318        };
1319        assert_eq!(eviction.project_key(), project_key);
1320        assert_eq!(refresh.project_key(), project_key);
1321        store.evict(eviction);
1322
1323        assert!(store.refresh(refresh).is_none());
1324    }
1325
1326    #[tokio::test(start_paused = true)]
1327    async fn test_ready_state() {
1328        let shared = SharedProjectState::default();
1329
1330        let shared_project = shared.to_shared_project();
1331        assert!(shared_project.project_state().is_pending());
1332        let mut listener = std::pin::pin!(shared_project.outdated());
1333
1334        // After five seconds, project state is still pending:
1335        let result = tokio::time::timeout(Duration::from_secs(5), listener.as_mut()).await;
1336        assert!(result.is_err()); // timed out before notify
1337        assert!(shared.to_shared_project().project_state().is_pending());
1338
1339        // Change the state:
1340        shared.set_project_state(ProjectState::Disabled);
1341
1342        // The listener gets notified immediately:
1343        let result = tokio::time::timeout(Duration::from_secs(1), listener).await;
1344        assert!(result.is_ok()); // notified before timeout
1345
1346        // The old snapshot is still pending:
1347        assert!(shared_project.project_state().is_pending());
1348
1349        // The up-to-date snapshot is Disabled:
1350        assert!(matches!(
1351            shared.to_shared_project().project_state(),
1352            &ProjectState::Disabled
1353        ));
1354    }
1355}