Skip to content

Commit

Permalink
Merge pull request #9286 from ForumMagnum/cached-for-non-hybrid-recom…
Browse files Browse the repository at this point in the history
…mendations

implement caching for non-hybrid recommendations feed
  • Loading branch information
b0b3rt committed May 15, 2024
2 parents b3e0a63 + b8f776b commit f8ad121
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 70 deletions.
38 changes: 21 additions & 17 deletions packages/lesswrong/components/posts/RecombeePostsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,33 +75,37 @@ const isWithinLoadMoreWindow = (recGeneratedAt: Date) => {
};

const getLoadMoreSettings = (resolverName: RecombeeResolver, results: RecommendedPost[], loadMoreCount: number): LoadMoreSettings => {
switch (resolverName) {
case DEFAULT_RESOLVER_NAME:
const prevRecommId = results.find(result => result.recommId)?.recommId;
if (!prevRecommId) {
return undefined;
}
return { loadMore: { prevRecommId } };
case HYBRID_RESOLVER_NAME:
const staleRecommIds = filterNonnull(uniq(
results
.filter(({ generatedAt }) => generatedAt && !isWithinLoadMoreWindow(generatedAt))
.map(({ recommId }) => recommId)
));
const staleRecommIds = filterNonnull(uniq(
results
.filter(({ generatedAt }) => generatedAt && !isWithinLoadMoreWindow(generatedAt))
.map(({ recommId }) => recommId)
));

const staleRecomms = results.filter(({ recommId }) => recommId && staleRecommIds.includes(recommId));
const freshRecomms = results.filter(({ recommId }) => recommId && !staleRecommIds.includes(recommId));
const staleRecomms = results.filter(({ recommId }) => recommId && staleRecommIds.includes(recommId));
const freshRecomms = results.filter(({ recommId }) => recommId && !staleRecommIds.includes(recommId));

const excludedPostIds = staleRecomms.map(({ post: { _id } }) => _id);
const [firstRecommId, secondRecommId] = filterNonnull(uniq(freshRecomms.map(({ recommId }) => recommId)));
const excludedPostIds = staleRecomms.map(({ post: { _id } }) => _id);
const [firstRecommId, secondRecommId] = filterNonnull(uniq(freshRecomms.map(({ recommId }) => recommId)));

switch (resolverName) {
case DEFAULT_RESOLVER_NAME: {
if (staleRecomms.length && !freshRecomms.length) {
return { excludedPostIds };
} else if (!staleRecomms.length && freshRecomms.length) {
return { loadMore: { prevRecommId: firstRecommId } };
} else {
return { excludedPostIds, loadMore: { prevRecommId: firstRecommId } };
}
}
case HYBRID_RESOLVER_NAME: {
if (staleRecomms.length && !freshRecomms.length) {
return { excludedPostIds, loadMore: { prevRecommIds: [undefined, undefined], loadMoreCount } };
} else if (!staleRecomms.length && freshRecomms.length) {
return { loadMore: { prevRecommIds: [firstRecommId, secondRecommId], loadMoreCount } };
} else {
return { excludedPostIds, loadMore: { prevRecommIds: [firstRecommId, secondRecommId], loadMoreCount } };
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface RecombeeConfiguration {
loadMore?: {
prevRecommId?: string,
},
excludedPostIds?: string[],
}

export interface RecombeeRecommendationArgs extends RecombeeConfiguration {
Expand Down
113 changes: 60 additions & 53 deletions packages/lesswrong/server/recombee/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface RecombeeRecommendedPost {
post: Partial<DbPost>,
scenario: string,
recommId: string,
generatedAt: Date,
generatedAt?: Date,
curated?: never,
stickied?: never,
}
Expand Down Expand Up @@ -114,6 +114,14 @@ interface AssignRecommendationResultMetadataArgs {
curatedPostIds: string[],
}

interface GetCachedRecommendationsArgs {
recRequest: RecRequest,
scenario: string,
batch: boolean,
skipCache?: boolean,
context: ResolverContext,
}

const recombeePostFieldMappings = {
title: ({ post }: UpsertPostData) => post.title,
author: ({ post }: UpsertPostData) => post.author,
Expand Down Expand Up @@ -227,7 +235,7 @@ const helpers = {
},

async getOnsitePostInfo(lwAlgoSettings: HybridRecombeeConfiguration | RecombeeConfiguration, context: ResolverContext, skipOnLoadMore = true): Promise<OnsitePostRecommendationsInfo> {
if (lwAlgoSettings.loadMore && skipOnLoadMore) {
if (helpers.isLoadMoreOperation(lwAlgoSettings) && skipOnLoadMore) {
return {
curatedPostIds: [],
stickiedPostIds: [],
Expand Down Expand Up @@ -340,17 +348,18 @@ const helpers = {
},

getCuratedPostsReadStatuses(lwAlgoSettings: HybridRecombeeConfiguration | RecombeeConfiguration, curatedPostIds: string[], userId: string, context: ResolverContext) {
return lwAlgoSettings.loadMore
return helpers.isLoadMoreOperation(lwAlgoSettings)
? Promise.resolve([])
: context.ReadStatuses.find({ postId: { $in: curatedPostIds.slice(1) }, userId, isRead: true }).fetch();
},

assignRecommendationResultMetadata({ post, recsWithMetadata, stickiedPostIds, curatedPostIds }: AssignRecommendationResultMetadataArgs) {
assignRecommendationResultMetadata({ post, recsWithMetadata, stickiedPostIds, curatedPostIds }: AssignRecommendationResultMetadataArgs): RecommendedPost {
// _id isn't going to be filtered out by `accessFilterMultiple`
const postId = post._id!;
const { recommId, scenario, generatedAt } = recsWithMetadata.find(({ id }) => id === postId) ?? {};
const recombeeRec = recsWithMetadata.find(({ id }) => id === postId);

if (recommId) {
if (recombeeRec) {
const { recommId, scenario, generatedAt } = recombeeRec;
return { post, recommId, scenario, generatedAt };
} else {
const stickied = stickiedPostIds.includes(postId);
Expand All @@ -364,12 +373,12 @@ const helpers = {
}
},

sendRecRequestWithPerfMetrics<T extends RecRequest>(recRequest: T, backfill = false) {
sendRecRequestWithPerfMetrics<T extends RecRequest>(recRequest: T, batch: boolean, backfill = false) {
const client = getRecombeeClientOrThrow();

return wrapWithPerfMetric(
() => client.send(recRequest),
() => helpers.openRecombeeRecsPerfMetric(recRequest, backfill)
() => client.send(recRequest) as Promise<RecommendationResponse>,
() => helpers.openRecombeeRecsPerfMetric(recRequest, batch, backfill)
);
},

Expand Down Expand Up @@ -398,9 +407,9 @@ const helpers = {
void context.RecommendationsCaches.rawCollection().bulkWrite(recsToInsert);
},

async getCachedRecommendations(recRequest: RecRequest, scenario: string, context: ResolverContext): Promise<RecResponse[]> {
if (recRequest instanceof requests.RecommendNextItems) {
const recResponse = await helpers.sendRecRequestWithPerfMetrics(recRequest);
async getCachedRecommendations({ recRequest, scenario, batch, skipCache, context }: GetCachedRecommendationsArgs): Promise<RecResponse[]> {
if (recRequest instanceof requests.RecommendNextItems || skipCache) {
const recResponse = await helpers.sendRecRequestWithPerfMetrics(recRequest, batch);
return [{ ...recResponse, scenario }];
}

Expand All @@ -412,8 +421,8 @@ const helpers = {
const unexpiredRecommendations = cachedRecommendations.filter(rec => currentTimestampMs < (rec.createdAt.getTime() + rec.ttlMs));

let formattedRecommendations: RecResponse[];
if (unexpiredRecommendations.length < recRequest.count) {
const recResponse = await helpers.sendRecRequestWithPerfMetrics(recRequest);
if (unexpiredRecommendations.length < (recRequest.count / 2)) {
const recResponse = await helpers.sendRecRequestWithPerfMetrics(recRequest, batch);
formattedRecommendations = [{
...recResponse,
recomms: recResponse.recomms.map(rec => ({ ...rec, generatedAt: new Date(currentTimestampMs) })),
Expand All @@ -435,7 +444,7 @@ const helpers = {
.sendRecRequestWithPerfMetrics(recRequest, true)
.then((recResponse) => helpers.backfillRecommendationsCache(userId, scenario, recResponse, context));

return formattedRecommendations;
return formattedRecommendations.slice(0, recRequest.count);
},

openRecombeeBatchRecsPerfMetric<T extends RecRequest>(firstRequest: T, secondRequest: T) {
Expand All @@ -452,21 +461,26 @@ const helpers = {
});
},

openRecombeeRecsPerfMetric(recombeeRequest: RecRequest, backfill: boolean) {
openRecombeeRecsPerfMetric(recombeeRequest: RecRequest, batch: boolean, backfill: boolean) {
const requestType = recombeeRequest.constructor.name;

const backfillPrefix = backfill ? 'backfill_' : '';
const batchPrefix = batch ? 'batch_' : '';

const opName = recombeeRequest instanceof requests.RecommendNextItems
? `${backfillPrefix}batch_${requestType}`
: `${backfillPrefix}batch_${requestType}_${recombeeRequest.bodyParameters().scenario ?? 'unknown'}`;
? `${backfillPrefix}${batchPrefix}${requestType}`
: `${backfillPrefix}${batchPrefix}${requestType}_${recombeeRequest.bodyParameters().scenario ?? 'unknown'}`;

return openPerfMetric({
op_type: 'recombee',
op_name: opName,
...getParentTraceId()
});
},

isLoadMoreOperation(lwAlgoSettings: HybridRecombeeConfiguration | RecombeeConfiguration) {
return !!(lwAlgoSettings.loadMore || lwAlgoSettings.excludedPostIds);
},
};

const curatedPostTerms: PostsViewTerms = {
Expand All @@ -476,53 +490,39 @@ const curatedPostTerms: PostsViewTerms = {

const recombeeApi = {
async getRecommendationsForUser(userId: string, count: number, lwAlgoSettings: RecombeeRecommendationArgs, context: ResolverContext) {
const client = getRecombeeClientOrThrow();

const {
curatedPostIds,
stickiedPostIds,
excludedPostFilter
} = await helpers.getOnsitePostInfo(lwAlgoSettings, context);
const reqIsLoadMore = helpers.isLoadMoreOperation(lwAlgoSettings);
const { curatedPostIds, stickiedPostIds, excludedPostFilter } = await helpers.getOnsitePostInfo(lwAlgoSettings, context);

const curatedPostReadStatuses = await (
lwAlgoSettings.loadMore
? Promise.resolve([])
: context.ReadStatuses.find({ postId: { $in: curatedPostIds.slice(1) }, userId, isRead: true }).fetch()
);
const curatedPostReadStatuses = await helpers.getCuratedPostsReadStatuses(lwAlgoSettings, curatedPostIds, userId, context);

const includedCuratedPostIds = curatedPostIds.filter(id => !curatedPostReadStatuses.find(readStatus => readStatus.postId === id));
const curatedAndStickiedPostCount = includedCuratedPostIds.length + stickiedPostIds.length;
const includedCuratedAndStickiedPostIds = reqIsLoadMore
? []
: [...includedCuratedPostIds, ...stickiedPostIds];

const curatedAndStickiedPostCount = includedCuratedAndStickiedPostIds.length;
const modifiedCount = count - curatedAndStickiedPostCount;
const recommendationsRequestBody = helpers.createRecommendationsForUserRequest(userId, modifiedCount, { ...lwAlgoSettings, filter: excludedPostFilter });

// We need the type cast here because recombee's type definitions can't handle inferring response types for union request types, even if they have the same response type
const recombeeResponse = await client.send(recommendationsRequestBody) as RecommendationResponse;
const { recomms, recommId } = recombeeResponse;
const [recombeeResponseWithScenario] = await helpers.getCachedRecommendations({
recRequest: recommendationsRequestBody,
scenario: lwAlgoSettings.scenario,
batch: false,
skipCache: reqIsLoadMore,
context
});

const recommendationIdPairs = recomms.map(rec => [rec.id, recommId] as const);
const { recomms, recommId, scenario } = recombeeResponseWithScenario;
const recsWithMetadata = recomms.map(rec => ({ ...rec, recommId, scenario }));
const recommendedPostIds = recomms.map(({ id }) => id);
const postIds = [...includedCuratedPostIds, ...stickiedPostIds, ...recommendedPostIds];
const postIds = [...includedCuratedAndStickiedPostIds, ...recommendedPostIds];

const posts = filterNonnull(await loadByIds(context, 'Posts', postIds));
const filteredPosts = await accessFilterMultiple(context.currentUser, context.Posts, posts, context)

const mappedPosts = filteredPosts.map(post => {
// _id isn't going to be filtered out by `accessFilterMultiple`
const postId = post._id!;
const recommId = recommendationIdPairs.find(([id]) => id === postId)?.[1];
if (recommId) {
return { post, recommId, scenario: lwAlgoSettings.scenario, generatedAt: new Date() };
} else {
return {
post,
curated: curatedPostIds.includes(postId),
stickied: stickiedPostIds.includes(postId)
};
}
});
const postsWithMetadata = filteredPosts.map(post => helpers.assignRecommendationResultMetadata({ post, recsWithMetadata, curatedPostIds, stickiedPostIds }));

return mappedPosts;
return postsWithMetadata;
},

async getHybridRecommendationsForUser(userId: string, count: number, lwAlgoSettings: HybridRecombeeConfiguration, context: ResolverContext) {
Expand All @@ -531,10 +531,11 @@ const recombeeApi = {
const { curatedPostIds, stickiedPostIds, excludedPostFilter } = await helpers.getOnsitePostInfo(lwAlgoSettings, context, false);

const curatedPostReadStatuses = await helpers.getCuratedPostsReadStatuses(lwAlgoSettings, curatedPostIds, userId, context);
const reqIsLoadMore = helpers.isLoadMoreOperation(lwAlgoSettings);
const includedCuratedPostIds = curatedPostIds.filter(id => !curatedPostReadStatuses.find(readStatus => readStatus.postId === id));
const excludeFromLatestPostIds = [...includedCuratedPostIds, ...stickiedPostIds];
// We only want to fetch the curated and stickied posts if this is the first load, not on any load more
const includedCuratedAndStickiedPostIds = lwAlgoSettings.loadMore
const includedCuratedAndStickiedPostIds = reqIsLoadMore
? []
: excludeFromLatestPostIds;

Expand All @@ -561,7 +562,13 @@ const recombeeApi = {
const recombeeRequest = helpers.createRecommendationsForUserRequest(userId, configurableArmCount, recombeeRequestSettings);

try {
recombeeResponsesWithScenario = await helpers.getCachedRecommendations(recombeeRequest, recombeeRequestSettings.scenario, context);
recombeeResponsesWithScenario = await helpers.getCachedRecommendations({
recRequest: recombeeRequest,
scenario: recombeeRequestSettings.scenario,
batch: true,
skipCache: reqIsLoadMore,
context
});
} catch (err) {
recombeeResponsesWithScenario = [];

Expand Down

0 comments on commit f8ad121

Please sign in to comment.