Merge pull request #4260 from omnivore-app/perf/cache-api-response

perf/cache api response
This commit is contained in:
Hongbo Wu
2024-08-14 11:32:19 +08:00
committed by GitHub
13 changed files with 269 additions and 102 deletions

View File

@ -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<LibraryItem, { format?: string }>
type PartialSearchItemEdge = Merge<SearchItemEdge, { node: PartialLibraryItem }>
export type PartialPageInfo = Merge<
PageInfo,
{ searchLibraryItemArgs?: SearchArgs }
>
export const searchResolver = authorized<
Merge<SearchSuccess, { edges: Array<PartialSearchItemEdge> }>,
Merge<
SearchSuccess,
{ edges: Array<PartialSearchItemEdge>; 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,
},
}
})
@ -657,7 +665,10 @@ export const typeaheadSearchResolver = authorized<
})
export const updatesSinceResolver = authorized<
Merge<UpdatesSinceSuccess, { edges: Array<PartialSearchItemEdge> }>,
Merge<
UpdatesSinceSuccess,
{ edges: Array<PartialSearchItemEdge>; pageInfo: PartialPageInfo }
>,
UpdatesSinceError,
QueryUpdatesSinceArgs
>(async (_obj, { since, first, after, sort: sortParams, folder }, { uid }) => {
@ -675,16 +686,15 @@ export const updatesSinceResolver = authorized<
folder ? ' in:' + folder : ''
} sort:${sort.by}-${sort.order}`
const { libraryItems, count } = await searchAndCountLibraryItems(
{
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
@ -714,7 +724,7 @@ export const updatesSinceResolver = authorized<
startCursor,
hasNextPage,
endCursor,
totalCount: count,
searchLibraryItemArgs,
},
}
})

View File

@ -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: {

View File

@ -29,7 +29,16 @@ import {
User,
} from '../generated/graphql'
import { getAISummary } from '../services/ai-summaries'
import { findUserFeatures } from '../services/features'
import {
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'
@ -43,6 +52,7 @@ import {
emptyTrashResolver,
fetchContentResolver,
PartialLibraryItem,
PartialPageInfo,
} from './article'
import {
addDiscoverFeedResolver,
@ -398,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
@ -588,6 +607,29 @@ 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) {
const args = pageInfo.searchLibraryItemArgs
const userId = ctx.claims.uid
const cachedCount = await getCachedTotalCount(userId, args)
if (cachedCount) return cachedCount
const count = await countLibraryItems(args, userId)
await setCachedTotalCount(userId, args, count)
return count
}
return 0
},
},
Subscription: {
newsletterEmail(subscription: Subscription) {
return subscription.newsletterEmail?.address

View File

@ -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 }
}

View File

@ -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) {

View File

@ -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<boolean> => {
return subscriptionsCount >= 2 && libraryItemsCount >= 10
}
const featuresCacheKey = (userId: string) => `cache: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
)
}

View File

@ -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'
@ -704,22 +708,13 @@ export const createSearchQueryBuilder = (
}
export const countLibraryItems = async (args: SearchArgs, userId: string) => {
const cacheKey = `countLibraryItems:${userId}:${JSON.stringify(args)}`
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', 60)
return count
}
export const searchLibraryItems = async (
@ -1720,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)
}

View File

@ -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
)
}

View File

@ -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<Shortcut[]> => {
},
]
}
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
)
}

View File

@ -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 {

View File

@ -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,
@ -532,7 +530,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,
@ -951,6 +949,7 @@ export type LibraryItemsQueryInput = {
searchQuery?: string
cursor?: string
includeContent?: boolean
includeCount?: boolean
}
type LibraryItemsData = {

View File

@ -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(() => {

View File

@ -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(() => {