From c2ec95845e1f2af1120bd8f3fd8157727bdfe2ed Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 09:22:40 +0800 Subject: [PATCH 1/6] request count of library items only when needed --- packages/api/src/resolvers/article/index.ts | 41 +++++++++++-------- .../api/src/resolvers/function_resolvers.ts | 17 ++++++++ .../web/lib/networking/library_items/gql.tsx | 4 +- .../library_items/useLibraryItems.tsx | 17 ++++---- packages/web/pages/settings/account.tsx | 26 +++++------- packages/web/pages/tools/bulk.tsx | 31 +++++++------- 6 files changed, 76 insertions(+), 60 deletions(-) diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 44874c156..52bdfad0b 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -37,6 +37,7 @@ import { MutationSaveArticleReadingProgressArgs, MutationSetBookmarkArticleArgs, MutationSetFavoriteArticleArgs, + PageInfo, PageType, QueryArticleArgs, QuerySearchArgs, @@ -79,7 +80,8 @@ import { countLibraryItems, createOrUpdateLibraryItem, findLibraryItemsByPrefix, - searchAndCountLibraryItems, + SearchArgs, + searchLibraryItems, softDeleteLibraryItem, sortParamsToSort, updateLibraryItem, @@ -582,8 +584,15 @@ export const saveArticleReadingProgressResolver = authorized< export type PartialLibraryItem = Merge type PartialSearchItemEdge = Merge +export type PartialPageInfo = Merge< + PageInfo, + { searchLibraryItemArgs?: SearchArgs } +> export const searchResolver = authorized< - Merge }>, + Merge< + SearchSuccess, + { edges: Array; pageInfo: PartialPageInfo } + >, SearchError, QuerySearchArgs >(async (_obj, params, { uid }) => { @@ -595,18 +604,17 @@ export const searchResolver = authorized< return { errorCodes: [SearchErrorCode.QueryTooLong] } } - const { libraryItems, count } = await searchAndCountLibraryItems( - { - from: Number(startCursor), - size: first + 1, // fetch one more item to get next cursor - includePending: true, - includeContent: params.includeContent ?? true, // by default include content for offline use for now - includeDeleted: params.query?.includes('in:trash'), - query: params.query, - useFolders: params.query?.includes('use:folders'), - }, - uid - ) + const searchLibraryItemArgs = { + from: Number(startCursor), + size: first + 1, // fetch one more item to get next cursor + includePending: true, + includeContent: params.includeContent ?? true, // by default include content for offline use for now + includeDeleted: params.query?.includes('in:trash'), + query: params.query, + useFolders: params.query?.includes('use:folders'), + } + + const libraryItems = await searchLibraryItems(searchLibraryItemArgs, uid) const start = startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 @@ -631,7 +639,7 @@ export const searchResolver = authorized< startCursor, hasNextPage, endCursor, - totalCount: count, + searchLibraryItemArgs, }, } }) @@ -675,7 +683,7 @@ export const updatesSinceResolver = authorized< folder ? ' in:' + folder : '' } sort:${sort.by}-${sort.order}` - const { libraryItems, count } = await searchAndCountLibraryItems( + const libraryItems = await searchLibraryItems( { from: Number(startCursor), size: size + 1, // fetch one more item to get next cursor @@ -714,7 +722,6 @@ export const updatesSinceResolver = authorized< startCursor, hasNextPage, endCursor, - totalCount: count, }, } }) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 3f9ef86ec..f38352c82 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -30,6 +30,7 @@ import { } from '../generated/graphql' import { getAISummary } from '../services/ai-summaries' import { findUserFeatures } from '../services/features' +import { countLibraryItems } from '../services/library_item' import { Merge } from '../util' import { isBase64Image, validatedDate, wordsCount } from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' @@ -43,6 +44,7 @@ import { emptyTrashResolver, fetchContentResolver, PartialLibraryItem, + PartialPageInfo, } from './article' import { addDiscoverFeedResolver, @@ -588,6 +590,21 @@ export const functionResolvers = { highlightsCount: (item: LibraryItem) => item.highlightAnnotations?.length, ...readingProgressHandlers, }, + PageInfo: { + async totalCount( + pageInfo: PartialPageInfo, + _: unknown, + ctx: ResolverContext + ) { + if (pageInfo.totalCount) return pageInfo.totalCount + + if (pageInfo.searchLibraryItemArgs && ctx.claims) { + return countLibraryItems(pageInfo.searchLibraryItemArgs, ctx.claims.uid) + } + + return 0 + }, + }, Subscription: { newsletterEmail(subscription: Subscription) { return subscription.newsletterEmail?.address diff --git a/packages/web/lib/networking/library_items/gql.tsx b/packages/web/lib/networking/library_items/gql.tsx index 543b4c1c8..c5193056f 100644 --- a/packages/web/lib/networking/library_items/gql.tsx +++ b/packages/web/lib/networking/library_items/gql.tsx @@ -18,7 +18,7 @@ export const recommendationFragment = gql` } ` -export const GQL_SEARCH_QUERY = gql` +export const gqlSearchQuery = (includeTotalCount = false) => gql` query Search( $after: String $first: Int @@ -77,7 +77,7 @@ export const GQL_SEARCH_QUERY = gql` hasPreviousPage startCursor endCursor - totalCount + totalCount @include(if: ${includeTotalCount}) } } ... on SearchError { diff --git a/packages/web/lib/networking/library_items/useLibraryItems.tsx b/packages/web/lib/networking/library_items/useLibraryItems.tsx index 27d2762db..6ac065da6 100644 --- a/packages/web/lib/networking/library_items/useLibraryItems.tsx +++ b/packages/web/lib/networking/library_items/useLibraryItems.tsx @@ -1,4 +1,3 @@ -import { GraphQLClient } from 'graphql-request' import { QueryClient, useInfiniteQuery, @@ -6,25 +5,24 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' +import { GraphQLClient } from 'graphql-request' +import { gqlEndpoint } from '../../appConfig' import { ContentReader, PageType, State } from '../fragments/articleFragment' import { Highlight } from '../fragments/highlightFragment' -import { requestHeaders } from '../networkHelpers' import { Label } from '../fragments/labelFragment' +import { requestHeaders } from '../networkHelpers' import { + gqlSearchQuery, GQL_BULK_ACTION, GQL_DELETE_LIBRARY_ITEM, - GQL_GET_LIBRARY_ITEM, GQL_GET_LIBRARY_ITEM_CONTENT, GQL_MOVE_ITEM_TO_FOLDER, GQL_SAVE_ARTICLE_READING_PROGRESS, GQL_SAVE_URL, - GQL_SEARCH_QUERY, GQL_SET_LABELS, GQL_SET_LINK_ARCHIVED, GQL_UPDATE_LIBRARY_ITEM, } from './gql' -import { gqlEndpoint } from '../../appConfig' -import { useState } from 'react' function gqlFetcher( query: string, @@ -213,7 +211,7 @@ export const insertItemInCache = ( export function useGetLibraryItems( folder: string | undefined, - { limit, searchQuery }: LibraryItemsQueryInput, + { limit, searchQuery, includeCount }: LibraryItemsQueryInput, enabled = true ) { const fullQuery = folder @@ -223,7 +221,7 @@ export function useGetLibraryItems( return useInfiniteQuery({ queryKey: ['libraryItems', fullQuery], queryFn: async ({ pageParam }) => { - const response = (await gqlFetcher(GQL_SEARCH_QUERY, { + const response = (await gqlFetcher(gqlSearchQuery(includeCount), { after: pageParam, first: limit, query: fullQuery, @@ -531,7 +529,7 @@ export function useRefreshProcessingItems() { itemIds: string[] }) => { const fullQuery = `in:all includes:${variables.itemIds.join(',')}` - const result = (await gqlFetcher(GQL_SEARCH_QUERY, { + const result = (await gqlFetcher(gqlSearchQuery(), { first: 10, query: fullQuery, includeContent: false, @@ -945,6 +943,7 @@ export type LibraryItemsQueryInput = { searchQuery?: string cursor?: string includeContent?: boolean + includeCount?: boolean } type LibraryItemsData = { diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 612f9efeb..a86ad183e 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -1,11 +1,4 @@ -import { - ChangeEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Toaster } from 'react-hot-toast' import { Button } from '../../components/elements/Button' import { @@ -19,22 +12,22 @@ import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' import { SettingsLayout } from '../../components/templates/SettingsLayout' import { styled, theme } from '../../components/tokens/stitches.config' import { userHasFeature } from '../../lib/featureFlag' +import { useGetLibraryItems } from '../../lib/networking/library_items/useLibraryItems' import { emptyTrashMutation } from '../../lib/networking/mutations/emptyTrashMutation' +import { optInFeature } from '../../lib/networking/mutations/optIntoFeatureMutation' +import { scheduleDigest } from '../../lib/networking/mutations/scheduleDigest' +import { updateDigestConfigMutation } from '../../lib/networking/mutations/updateDigestConfigMutation' import { updateEmailMutation } from '../../lib/networking/mutations/updateEmailMutation' import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation' import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation' -import { useGetLibraryItems } from '../../lib/networking/library_items/useLibraryItems' -import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' -import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' -import { applyStoredTheme } from '../../lib/themeUpdater' -import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' import { DigestChannel, useGetUserPersonalization, } from '../../lib/networking/queries/useGetUserPersonalization' -import { updateDigestConfigMutation } from '../../lib/networking/mutations/updateDigestConfigMutation' -import { scheduleDigest } from '../../lib/networking/mutations/scheduleDigest' -import { optInFeature } from '../../lib/networking/mutations/optIntoFeatureMutation' +import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' +import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' const ACCOUNT_LIMIT = 50_000 @@ -103,6 +96,7 @@ export default function Account(): JSX.Element { limit: 0, searchQuery: '', sortDescending: false, + includeCount: true, }) const libraryCount = useMemo(() => { diff --git a/packages/web/pages/tools/bulk.tsx b/packages/web/pages/tools/bulk.tsx index 028d9a716..b5083f8a4 100644 --- a/packages/web/pages/tools/bulk.tsx +++ b/packages/web/pages/tools/bulk.tsx @@ -1,25 +1,23 @@ -import { useCallback, useEffect, useState } from 'react' -import { applyStoredTheme } from '../../lib/themeUpdater' - -import { VStack } from '../../components/elements/LayoutPrimitives' - -import { StyledText } from '../../components/elements/StyledText' -import { ProfileLayout } from '../../components/templates/ProfileLayout' -import { BulkAction } from '../../lib/networking/library_items/useLibraryItems' -import { Button } from '../../components/elements/Button' -import { theme } from '../../components/tokens/stitches.config' -import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' -import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' import { useRouter } from 'next/router' -import { - useBulkActions, - useGetLibraryItems, -} from '../../lib/networking/library_items/useLibraryItems' +import { useCallback, useEffect, useState } from 'react' +import { Button } from '../../components/elements/Button' import { BorderedFormInput, FormLabel, } from '../../components/elements/FormElements' +import { VStack } from '../../components/elements/LayoutPrimitives' +import { StyledText } from '../../components/elements/StyledText' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { ProfileLayout } from '../../components/templates/ProfileLayout' +import { theme } from '../../components/tokens/stitches.config' import { DEFAULT_HOME_PATH } from '../../lib/navigations' +import { + BulkAction, + useBulkActions, + useGetLibraryItems, +} from '../../lib/networking/library_items/useLibraryItems' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' type RunningState = 'none' | 'confirming' | 'running' | 'completed' @@ -39,6 +37,7 @@ export default function BulkPerformer(): JSX.Element { searchQuery: query, limit: 1, sortDescending: false, + includeCount: true, }) useEffect(() => { From d00a76ca3fc9184d4348db73c77cb02da2723a7e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 09:26:34 +0800 Subject: [PATCH 2/6] hash args in the cache key --- packages/api/src/services/library_item.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index e42dcdb9f..3eebe4efd 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -35,6 +35,7 @@ import { logger } from '../utils/logger' import { parseSearchQuery } from '../utils/search' import { HighlightEvent } from './highlights' import { addLabelsToLibraryItem, LabelEvent } from './labels' +import { stringToHash } from '../utils/helpers' const columnsToDelete = [ 'user', @@ -704,7 +705,9 @@ export const createSearchQueryBuilder = ( } export const countLibraryItems = async (args: SearchArgs, userId: string) => { - const cacheKey = `countLibraryItems:${userId}:${JSON.stringify(args)}` + // hash the arguments to create a unique cache key + const argsHash = stringToHash(JSON.stringify(args)) + const cacheKey = `countLibraryItems:${userId}:${argsHash}` const cachedCount = await redisDataSource.redisClient?.get(cacheKey) if (cachedCount) { return parseInt(cachedCount, 10) From 81ff5a05f986641353af4ae919c0d851ed262139 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 09:27:18 +0800 Subject: [PATCH 3/6] extend count cache ttl to 600 seconds --- packages/api/src/services/library_item.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 3eebe4efd..8c6b46a9d 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -721,7 +721,7 @@ export const countLibraryItems = async (args: SearchArgs, userId: string) => { } ) - await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 60) + await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 600) return count } From bcdff19a3d4d8a3ce2dfeda227d9c51232cbf1d4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 09:37:40 +0800 Subject: [PATCH 4/6] cache total count of library items in page info only --- packages/api/src/resolvers/article/index.ts | 25 +++++++++++-------- .../api/src/resolvers/function_resolvers.ts | 23 +++++++++++++++-- packages/api/src/services/library_item.ts | 14 +---------- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 52bdfad0b..a5fdfe7e4 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -665,7 +665,10 @@ export const typeaheadSearchResolver = authorized< }) export const updatesSinceResolver = authorized< - Merge }>, + Merge< + UpdatesSinceSuccess, + { edges: Array; pageInfo: PartialPageInfo } + >, UpdatesSinceError, QueryUpdatesSinceArgs >(async (_obj, { since, first, after, sort: sortParams, folder }, { uid }) => { @@ -683,16 +686,15 @@ export const updatesSinceResolver = authorized< folder ? ' in:' + folder : '' } sort:${sort.by}-${sort.order}` - const libraryItems = await searchLibraryItems( - { - from: Number(startCursor), - size: size + 1, // fetch one more item to get next cursor - includeDeleted: true, - query, - includeContent: true, // by default include content for offline use for now - }, - uid - ) + const searchLibraryItemArgs = { + from: Number(startCursor), + size: size + 1, // fetch one more item to get next cursor + includeDeleted: true, + query, + includeContent: true, // by default include content for offline use for now + } + + const libraryItems = await searchLibraryItems(searchLibraryItemArgs, uid) const start = startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 @@ -722,6 +724,7 @@ export const updatesSinceResolver = authorized< startCursor, hasNextPage, endCursor, + searchLibraryItemArgs, }, } }) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index f38352c82..485187ecf 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -28,11 +28,17 @@ import { PageType, User, } from '../generated/graphql' +import { redisDataSource } from '../redis_data_source' import { getAISummary } from '../services/ai-summaries' import { findUserFeatures } from '../services/features' import { countLibraryItems } from '../services/library_item' import { Merge } from '../util' -import { isBase64Image, validatedDate, wordsCount } from '../utils/helpers' +import { + isBase64Image, + stringToHash, + validatedDate, + wordsCount, +} from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' import { contentConverter } from '../utils/parser' import { @@ -599,7 +605,20 @@ export const functionResolvers = { if (pageInfo.totalCount) return pageInfo.totalCount if (pageInfo.searchLibraryItemArgs && ctx.claims) { - return countLibraryItems(pageInfo.searchLibraryItemArgs, ctx.claims.uid) + const args = pageInfo.searchLibraryItemArgs + const userId = ctx.claims.uid + // hash the arguments to create a unique cache key + const argsHash = stringToHash(JSON.stringify(args)) + const cacheKey = `countLibraryItems:${userId}:${argsHash}` + const cachedCount = await redisDataSource.redisClient?.get(cacheKey) + if (cachedCount) { + return parseInt(cachedCount, 10) + } + + const count = await countLibraryItems(args, userId) + + await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 600) + return count } return 0 diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 8c6b46a9d..5fa422103 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -35,7 +35,6 @@ import { logger } from '../utils/logger' import { parseSearchQuery } from '../utils/search' import { HighlightEvent } from './highlights' import { addLabelsToLibraryItem, LabelEvent } from './labels' -import { stringToHash } from '../utils/helpers' const columnsToDelete = [ 'user', @@ -705,24 +704,13 @@ export const createSearchQueryBuilder = ( } export const countLibraryItems = async (args: SearchArgs, userId: string) => { - // hash the arguments to create a unique cache key - const argsHash = stringToHash(JSON.stringify(args)) - const cacheKey = `countLibraryItems:${userId}:${argsHash}` - const cachedCount = await redisDataSource.redisClient?.get(cacheKey) - if (cachedCount) { - return parseInt(cachedCount, 10) - } - - const count = await authTrx( + return authTrx( async (tx) => createSearchQueryBuilder(args, userId, tx).getCount(), { uid: userId, replicationMode: 'replica', } ) - - await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 600) - return count } export const searchLibraryItems = async ( From e05ff942b10711043a2f1a1874c84ce20c28457e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 10:09:37 +0800 Subject: [PATCH 5/6] cache features for 600 seconds --- packages/api/src/resolvers/features/index.ts | 11 ++++- .../api/src/resolvers/function_resolvers.ts | 42 +++++++++++-------- packages/api/src/resolvers/user/index.ts | 15 ++++++- packages/api/src/services/features.ts | 24 +++++++++++ packages/api/src/services/library_item.ts | 31 +++++++++++++- packages/api/src/services/user.ts | 21 ++++++++++ 6 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/api/src/resolvers/features/index.ts b/packages/api/src/resolvers/features/index.ts index 6bb12c2cb..21999e590 100644 --- a/packages/api/src/resolvers/features/index.ts +++ b/packages/api/src/resolvers/features/index.ts @@ -6,8 +6,10 @@ import { } from '../../generated/graphql' import { getFeatureName, + getFeaturesCache, isOptInFeatureErrorCode, optInFeature, + setFeaturesCache, signFeatureToken, } from '../../services/features' import { authorized } from '../../utils/gql-utils' @@ -34,7 +36,8 @@ export const optInFeatureResolver = authorized< } } - const optedInFeature = await optInFeature(featureName, claims.uid) + const userId = claims.uid + const optedInFeature = await optInFeature(featureName, userId) if (isOptInFeatureErrorCode(optedInFeature)) { return { errorCodes: [optedInFeature], @@ -42,7 +45,11 @@ export const optInFeatureResolver = authorized< } log.info('Opted in to a feature', optedInFeature) - const token = signFeatureToken(optedInFeature, claims.uid) + const cachedFeatures = (await getFeaturesCache(userId)) || [] + const updatedFeatures = [...cachedFeatures, optedInFeature] + await setFeaturesCache(userId, updatedFeatures) + + const token = signFeatureToken(optedInFeature, userId) return { feature: { diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 485187ecf..3412d9187 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -28,17 +28,19 @@ import { PageType, User, } from '../generated/graphql' -import { redisDataSource } from '../redis_data_source' import { getAISummary } from '../services/ai-summaries' -import { findUserFeatures } from '../services/features' -import { countLibraryItems } from '../services/library_item' -import { Merge } from '../util' import { - isBase64Image, - stringToHash, - validatedDate, - wordsCount, -} from '../utils/helpers' + findUserFeatures, + getFeaturesCache, + setFeaturesCache, +} from '../services/features' +import { + countLibraryItems, + getCachedTotalCount, + setCachedTotalCount, +} from '../services/library_item' +import { Merge } from '../util' +import { isBase64Image, validatedDate, wordsCount } from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' import { contentConverter } from '../utils/parser' import { @@ -406,7 +408,16 @@ export const functionResolvers = { return undefined } - return findUserFeatures(ctx.claims.uid) + const userId = ctx.claims.uid + const cachedFeatures = await getFeaturesCache(userId) + if (cachedFeatures) { + return cachedFeatures + } + + const features = await findUserFeatures(userId) + await setFeaturesCache(userId, features) + + return features }, picture: (user: UserEntity) => user.profile.pictureUrl, // not implemented yet @@ -607,17 +618,12 @@ export const functionResolvers = { if (pageInfo.searchLibraryItemArgs && ctx.claims) { const args = pageInfo.searchLibraryItemArgs const userId = ctx.claims.uid - // hash the arguments to create a unique cache key - const argsHash = stringToHash(JSON.stringify(args)) - const cacheKey = `countLibraryItems:${userId}:${argsHash}` - const cachedCount = await redisDataSource.redisClient?.get(cacheKey) - if (cachedCount) { - return parseInt(cachedCount, 10) - } + const cachedCount = await getCachedTotalCount(userId, args) + if (cachedCount) return cachedCount const count = await countLibraryItems(args, userId) + await setCachedTotalCount(userId, args, count) - await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 600) return count } diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 9c496df8c..4dc6b5921 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -41,7 +41,7 @@ import { import { userRepository } from '../../repository/user' import { createUser } from '../../services/create_user' import { sendAccountChangeEmail } from '../../services/send_emails' -import { softDeleteUser } from '../../services/user' +import { cacheUser, getCachedUser, softDeleteUser } from '../../services/user' import { Merge } from '../../util' import { authorized } from '../../utils/gql-utils' import { validateUsername } from '../../utils/usernamePolicy' @@ -254,11 +254,19 @@ export const getMeUserResolver: ResolverFn< return undefined } + const userId = claims.uid + const cachedUser = await getCachedUser(userId) + if (cachedUser) { + return cachedUser + } + const user = await userRepository.findById(claims.uid) if (!user) { return undefined } + await cacheUser(user) + return user } catch (error) { return undefined @@ -355,6 +363,11 @@ export const updateEmailResolver = authorized< }) ) + await cacheUser({ + ...user, + email, + }) + return { email } } diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 5e410842f..877701629 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -6,6 +6,7 @@ import { LibraryItem } from '../entity/library_item' import { Subscription, SubscriptionStatus } from '../entity/subscription' import { env } from '../env' import { OptInFeatureErrorCode } from '../generated/graphql' +import { redisDataSource } from '../redis_data_source' import { authTrx, getRepository } from '../repository' import { logger } from '../utils/logger' @@ -201,3 +202,26 @@ export const userDigestEligible = async (uid: string): Promise => { return subscriptionsCount >= 2 && libraryItemsCount >= 10 } + +const featuresCacheKey = (userId: string) => `features:${userId}` + +export const getFeaturesCache = async (userId: string) => { + const cachedFeatures = await redisDataSource.redisClient?.get( + featuresCacheKey(userId) + ) + if (!cachedFeatures) { + return undefined + } + + return JSON.parse(cachedFeatures) as Feature[] +} + +export const setFeaturesCache = async (userId: string, features: Feature[]) => { + const value = JSON.stringify(features) + return redisDataSource.redisClient?.set( + featuresCacheKey(userId), + value, + 'EX', + 600 + ) +} diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 5fa422103..84886511c 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -30,7 +30,11 @@ import { } from '../repository' import { libraryItemRepository } from '../repository/library_item' import { Merge, PickTuple } from '../util' -import { deepDelete, setRecentlySavedItemInRedis } from '../utils/helpers' +import { + deepDelete, + setRecentlySavedItemInRedis, + stringToHash, +} from '../utils/helpers' import { logger } from '../utils/logger' import { parseSearchQuery } from '../utils/search' import { HighlightEvent } from './highlights' @@ -1711,3 +1715,28 @@ export const filterItemEvents = ( throw new Error('Unexpected state.') } + +const totalCountCacheKey = (userId: string, args: SearchArgs) => { + return `cache:library_items_count:${userId}:${stringToHash( + JSON.stringify(args) + )}` +} + +export const getCachedTotalCount = async (userId: string, args: SearchArgs) => { + const cacheKey = totalCountCacheKey(userId, args) + const cachedCount = await redisDataSource.redisClient?.get(cacheKey) + if (!cachedCount) { + return undefined + } + + return parseInt(cachedCount, 10) +} + +export const setCachedTotalCount = async ( + userId: string, + args: SearchArgs, + count: number +) => { + const cacheKey = totalCountCacheKey(userId, args) + await redisDataSource.redisClient?.set(cacheKey, count, 'EX', 600) +} diff --git a/packages/api/src/services/user.ts b/packages/api/src/services/user.ts index 6b81a7352..3f7ed7d6c 100644 --- a/packages/api/src/services/user.ts +++ b/packages/api/src/services/user.ts @@ -2,6 +2,7 @@ import { Notification } from 'firebase-admin/messaging' import { DeepPartial, FindOptionsWhere, In } from 'typeorm' import { Profile } from '../entity/profile' import { StatusType, User } from '../entity/user' +import { redisDataSource } from '../redis_data_source' import { authTrx, getRepository, queryBuilderToRawSql } from '../repository' import { userRepository } from '../repository/user' import { SetClaimsRole } from '../utils/dictionary' @@ -156,3 +157,23 @@ export const findUserAndPersonalization = async (id: string) => { } ) } + +const userCacheKey = (id: string) => `cache:user:${id}` + +export const getCachedUser = async (id: string) => { + const user = await redisDataSource.redisClient?.get(userCacheKey(id)) + if (!user) { + return undefined + } + + return JSON.parse(user) as User +} + +export const cacheUser = async (user: User) => { + await redisDataSource.redisClient?.set( + userCacheKey(user.id), + JSON.stringify(user), + 'EX', + 600 + ) +} From 45fdc0d474736e6ff9b88a72cf19311bd6ec4280 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 14 Aug 2024 10:18:11 +0800 Subject: [PATCH 6/6] cache shortcuts for 600 seconds --- packages/api/src/routers/shortcuts_router.ts | 39 +++++++++++++------ packages/api/src/services/features.ts | 2 +- .../api/src/services/user_personalization.ts | 31 ++++++++++++--- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/api/src/routers/shortcuts_router.ts b/packages/api/src/routers/shortcuts_router.ts index a6dd88f8a..7683f1de1 100644 --- a/packages/api/src/routers/shortcuts_router.ts +++ b/packages/api/src/routers/shortcuts_router.ts @@ -2,15 +2,16 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import cors from 'cors' import express from 'express' -import { env } from '../env' -import { getClaimsByToken, getTokenByRequest } from '../utils/auth' -import { corsConfig } from '../utils/corsConfig' -import { logger } from '../utils/logger' import { + cacheShortcuts, getShortcuts, + getShortcutsCache, resetShortcuts, setShortcuts, } from '../services/user_personalization' +import { getClaimsByToken, getTokenByRequest } from '../utils/auth' +import { corsConfig } from '../utils/corsConfig' +import { logger } from '../utils/logger' export function shortcutsRouter() { const router = express.Router() @@ -32,9 +33,19 @@ export function shortcutsRouter() { } try { - const shortcuts = await getShortcuts(claims.uid) + const userId = claims.uid + const cachedShortcuts = await getShortcutsCache(userId) + if (cachedShortcuts) { + return res.send({ + shortcuts: cachedShortcuts, + }) + } + + const shortcuts = await getShortcuts(userId) + await cacheShortcuts(userId, shortcuts) + return res.send({ - shortcuts: shortcuts ?? [], + shortcuts, }) } catch (e) { logger.info('error getting shortcuts', e) @@ -61,9 +72,12 @@ export function shortcutsRouter() { } try { - const shortcuts = await setShortcuts(claims.uid, req.body.shortcuts) + const userId = claims.uid + const shortcuts = await setShortcuts(userId, req.body.shortcuts) + await cacheShortcuts(userId, shortcuts) + return res.send({ - shortcuts: shortcuts ?? [], + shortcuts, }) } catch (e) { logger.info('error settings shortcuts', e) @@ -89,11 +103,14 @@ export function shortcutsRouter() { } try { - const success = await resetShortcuts(claims.uid) + const userId = claims.uid + const success = await resetShortcuts(userId) if (success) { - const shortcuts = await getShortcuts(claims.uid) + const shortcuts = await getShortcuts(userId) + await cacheShortcuts(userId, shortcuts) + return res.send({ - shortcuts: shortcuts ?? [], + shortcuts, }) } } catch (e) { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 877701629..28d097ec2 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -203,7 +203,7 @@ export const userDigestEligible = async (uid: string): Promise => { return subscriptionsCount >= 2 && libraryItemsCount >= 10 } -const featuresCacheKey = (userId: string) => `features:${userId}` +const featuresCacheKey = (userId: string) => `cache:features:${userId}` export const getFeaturesCache = async (userId: string) => { const cachedFeatures = await redisDataSource.redisClient?.get( diff --git a/packages/api/src/services/user_personalization.ts b/packages/api/src/services/user_personalization.ts index cf8075b34..5661d960a 100644 --- a/packages/api/src/services/user_personalization.ts +++ b/packages/api/src/services/user_personalization.ts @@ -1,10 +1,10 @@ -import { DeepPartial, IsNull } from 'typeorm' -import { Shortcut, UserPersonalization } from '../entity/user_personalization' -import { authTrx } from '../repository' -import { findLabelsByUserId } from './labels' -import { findSubscriptionById } from './subscriptions' +import { DeepPartial } from 'typeorm' import { Filter } from '../entity/filter' import { Subscription, SubscriptionStatus } from '../entity/subscription' +import { Shortcut, UserPersonalization } from '../entity/user_personalization' +import { redisDataSource } from '../redis_data_source' +import { authTrx } from '../repository' +import { findLabelsByUserId } from './labels' export const findUserPersonalization = async (userId: string) => { return authTrx( @@ -173,3 +173,24 @@ const userDefaultShortcuts = async (userId: string): Promise => { }, ] } + +const shortcutsCacheKey = (userId: string) => `cache:shortcuts:${userId}` + +export const getShortcutsCache = async (userId: string) => { + const cachedShortcuts = await redisDataSource.redisClient?.get( + shortcutsCacheKey(userId) + ) + if (!cachedShortcuts) { + return undefined + } + return JSON.parse(cachedShortcuts) as Shortcut[] +} + +export const cacheShortcuts = async (userId: string, shortcuts: Shortcut[]) => { + await redisDataSource.redisClient?.set( + shortcutsCacheKey(userId), + JSON.stringify(shortcuts), + 'EX', + 600 + ) +}