Browse Source

feat(issues): Add cache to issues stream (#59387)

Scott Cooper 1 year ago
parent
commit
b50bc31bd7

+ 86 - 0
static/app/stores/IssueListCacheStore.tsx

@@ -0,0 +1,86 @@
+import isEqual from 'lodash/isEqual';
+import {createStore} from 'reflux';
+
+import type {Group} from 'sentry/types';
+
+import {CommonStoreDefinition} from './types';
+
+/**
+ * The type here doesn't really matter it just needs to be compared via isEqual
+ */
+type LooseParamsType = Record<string, any>;
+
+interface IssueListCache {
+  groups: Group[];
+  pageLinks: string;
+  queryCount: number;
+  queryMaxCount: number;
+}
+
+interface IssueListCacheState {
+  /**
+   * The data that was cached
+   */
+  cache: IssueListCache;
+  /**
+   * Do not use this directly, use `getFromCache` instead
+   */
+  expiration: number;
+  /**
+   * The params that were used to generate the cache
+   * eg - {query: 'Some query'}
+   */
+  params: LooseParamsType;
+}
+
+interface InternalDefinition {
+  state: IssueListCacheState | null;
+}
+
+// 30 seconds
+const CACHE_EXPIRATION = 30 * 1000;
+
+interface IssueListCacheStoreDefinition
+  extends CommonStoreDefinition<IssueListCache | null>,
+    InternalDefinition {
+  getFromCache(params: LooseParamsType): IssueListCache | null;
+  reset(): void;
+  save(params: LooseParamsType, data: IssueListCache): void;
+}
+
+const storeConfig: IssueListCacheStoreDefinition = {
+  state: null,
+
+  init() {},
+
+  reset() {
+    this.state = null;
+  },
+
+  save(params: LooseParamsType, data: IssueListCache) {
+    this.state = {
+      params,
+      cache: data,
+      expiration: Date.now() + CACHE_EXPIRATION,
+    };
+  },
+
+  getFromCache(params: LooseParamsType) {
+    if (
+      this.state &&
+      this.state.expiration > Date.now() &&
+      isEqual(this.state?.params, params)
+    ) {
+      return this.state.cache;
+    }
+
+    return null;
+  },
+
+  getState() {
+    return this.state?.cache ?? null;
+  },
+};
+
+const IssueListCacheStore = createStore(storeConfig);
+export default IssueListCacheStore;

+ 48 - 0
static/app/views/issueList/overview.spec.tsx

@@ -436,6 +436,54 @@ describe('IssueList', function () {
       expect(screen.getByRole('button', {name: 'My Default Search'})).toBeInTheDocument();
     });
 
+    it('caches the search results', async function () {
+      issuesRequest = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [...new Array(25)].map((_, i) => ({id: i})),
+        headers: {
+          Link: DEFAULT_LINKS_HEADER,
+          'X-Hits': '500',
+          'X-Max-Hits': '1000',
+        },
+      });
+
+      const defaultProps = {
+        ...props,
+        ...routerProps,
+        useOrgSavedSearches: true,
+        selection: {
+          projects: [],
+          environments: [],
+          datetime: {period: '14d'},
+        },
+        organization: Organization({
+          features: ['issue-stream-performance', 'issue-stream-performance-cache'],
+          projects: [],
+        }),
+      };
+      const {unmount} = render(<IssueListWithStores {...defaultProps} />, {
+        context: routerContext,
+        organization: defaultProps.organization,
+      });
+
+      expect(
+        await screen.findByText(textWithMarkupMatcher('1-25 of 500'))
+      ).toBeInTheDocument();
+      expect(issuesRequest).toHaveBeenCalledTimes(1);
+      unmount();
+
+      // Mount component again, getting from cache
+      render(<IssueListWithStores {...defaultProps} />, {
+        context: routerContext,
+        organization: defaultProps.organization,
+      });
+
+      expect(
+        await screen.findByText(textWithMarkupMatcher('1-25 of 500'))
+      ).toBeInTheDocument();
+      expect(issuesRequest).toHaveBeenCalledTimes(1);
+    });
+
     it('1 search', async function () {
       const localSavedSearch = {...savedSearch, projectId: null};
       savedSearchesRequest = MockApiClient.addMockResponse({

+ 67 - 4
static/app/views/issueList/overview.tsx

@@ -27,6 +27,7 @@ import ProcessingIssueList from 'sentry/components/stream/processingIssueList';
 import {DEFAULT_QUERY, DEFAULT_STATS_PERIOD} from 'sentry/constants';
 import {t, tct, tn} from 'sentry/locale';
 import GroupStore from 'sentry/stores/groupStore';
+import IssueListCacheStore from 'sentry/stores/IssueListCacheStore';
 import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
 import {space} from 'sentry/styles/space';
 import {
@@ -124,7 +125,7 @@ type State = {
   query?: string;
 };
 
-type EndpointParams = Partial<PageFilters['datetime']> & {
+interface EndpointParams extends Partial<PageFilters['datetime']> {
   environment: string[];
   project: number[];
   cursor?: string;
@@ -133,7 +134,7 @@ type EndpointParams = Partial<PageFilters['datetime']> & {
   query?: string;
   sort?: string;
   statsPeriod?: string | null;
-};
+}
 
 type CountsEndpointParams = Omit<EndpointParams, 'cursor' | 'page' | 'query'> & {
   query: string[];
@@ -185,7 +186,10 @@ class IssueListOverview extends Component<Props, State> {
       !this.props.savedSearchLoading ||
       this.props.organization.features.includes('issue-stream-performance')
     ) {
-      this.fetchData();
+      const loadedFromCache = this.loadFromCache();
+      if (!loadedFromCache) {
+        this.fetchData();
+      }
     }
     this.fetchTags();
     this.fetchMemberList();
@@ -206,6 +210,7 @@ class IssueListOverview extends Component<Props, State> {
     // If the project selection has changed reload the member list and tag keys
     // allowing autocomplete and tag sidebar to be more accurate.
     if (!isEqual(prevProps.selection.projects, this.props.selection.projects)) {
+      this.loadFromCache();
       this.fetchMemberList();
       this.fetchTags();
     }
@@ -227,7 +232,10 @@ class IssueListOverview extends Component<Props, State> {
       prevProps.savedSearchLoading &&
       !this.props.organization.features.includes('issue-stream-performance')
     ) {
-      this.fetchData();
+      const loadedFromCache = this.loadFromCache();
+      if (!loadedFromCache) {
+        this.fetchData();
+      }
       return;
     }
 
@@ -271,6 +279,20 @@ class IssueListOverview extends Component<Props, State> {
   }
 
   componentWillUnmount() {
+    const groups = GroupStore.getState() as Group[];
+    if (
+      groups.length > 0 &&
+      !this.state.issuesLoading &&
+      !this.state.realtimeActive &&
+      this.props.organization.features.includes('issue-stream-performance-cache')
+    ) {
+      IssueListCacheStore.save(this.getCacheEndpointParams(), {
+        groups,
+        queryCount: this.state.queryCount,
+        queryMaxCount: this.state.queryMaxCount,
+        pageLinks: this.state.pageLinks,
+      });
+    }
     this._poller.disable();
     SelectedGroupStore.reset();
     GroupStore.reset();
@@ -314,6 +336,39 @@ class IssueListOverview extends Component<Props, State> {
     return DEFAULT_ISSUE_STREAM_SORT;
   }
 
+  /**
+   * Load the previous
+   * @returns Returns true if the data was loaded from cache
+   */
+  loadFromCache(): boolean {
+    if (!this.props.organization.features.includes('issue-stream-performance-cache')) {
+      return false;
+    }
+
+    const cache = IssueListCacheStore.getFromCache(this.getCacheEndpointParams());
+    if (!cache) {
+      return false;
+    }
+
+    this.setState(
+      {
+        issuesLoading: false,
+        queryCount: cache.queryCount,
+        queryMaxCount: cache.queryMaxCount,
+        pageLinks: cache.pageLinks,
+      },
+      () => {
+        // Handle this in the next tick to avoid being overwritten by GroupStore.reset
+        // Group details clears the GroupStore at the same time this component mounts
+        GroupStore.add(cache.groups);
+        // Clear cache after loading
+        IssueListCacheStore.reset();
+      }
+    );
+
+    return true;
+  }
+
   getQuery(): string {
     return this.getQueryFromSavedSearchOrLocation({
       savedSearch: this.props.savedSearch,
@@ -376,6 +431,14 @@ class IssueListOverview extends Component<Props, State> {
     return pickBy(params, v => defined(v)) as EndpointParams;
   };
 
+  getCacheEndpointParams = (): EndpointParams => {
+    const cursor = this.props.location.query.cursor;
+    return {
+      ...this.getEndpointParams(),
+      cursor,
+    };
+  };
+
   getSelectedProjectIds = (): string[] => {
     return this.props.selection.projects.map(projectId => String(projectId));
   };