From 8ecf36187019d2ebdbe7e01aee6ee01acd020d64 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Tue, 21 Nov 2023 23:47:02 +0100 Subject: [PATCH] [backend] Implement heuristics for home timeline queries After lots of performance analysis, we've ended up with a cutoff value of 250 posts in the last 7d, after which we should switch which query plan to nudge postgres towards. This should greatly improve performance of users who were previously performance edge cases. --- .../api/common/generate-following-query.ts | 48 +++++++++++++++++++ .../server/api/endpoints/notes/timeline.ts | 24 ++-------- .../server/api/mastodon/helpers/timeline.ts | 11 +---- 3 files changed, 53 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/server/api/common/generate-following-query.ts diff --git a/packages/backend/src/server/api/common/generate-following-query.ts b/packages/backend/src/server/api/common/generate-following-query.ts new file mode 100644 index 000000000..2b1063b54 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-following-query.ts @@ -0,0 +1,48 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { Followings, Notes } from "@/models/index.js"; +import { Cache } from "@/misc/cache.js"; +import { apiLogger } from "@/server/api/logger.js"; + +const cache = new Cache("homeTlQueryData", 60 * 60 * 24); +const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500 +const logger = apiLogger.createSubLogger("heuristics"); + +export async function generateFollowingQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): Promise { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :meId"); + + const heuristic = await cache.fetch(me.id, async () => { + let curr = new Date(); + let prev = new Date(); + prev.setDate(prev.getDate() - 7); + return Notes.createQueryBuilder('note') + .where(`note.createdAt > :prev`, { prev }) + .andWhere(`note.createdAt < :curr`, { curr }) + .andWhere(`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`, { meId: me.id }) + .getCount() + .then(res => { + logger.info(`Calculating heuristics for user ${me.id} took ${new Date().getTime() - curr.getTime()}ms`); + return res; + }); + }); + + const shouldUseUnion = heuristic < cutoff ; + + q.andWhere( + new Brackets((qb) => { + if (shouldUseUnion) { + qb.where(`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`); + } else { + qb.where(`note.userId = :meId`); + qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`); + } + }), + ) + + q.setParameters({ meId: me.id }); +} diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d2e097a77..731768ead 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -12,6 +12,7 @@ import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; import { ApiError } from "../../error.js"; import { generateListQuery } from "@/server/api/common/generate-list-query.js"; +import { generateFollowingQuery } from "@/server/api/common/generate-following-query.js"; export const meta = { tags: ["notes"], @@ -65,19 +66,7 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, user) => { - const hasFollowing = - (await Followings.count({ - where: { - followerId: user.id, - }, - take: 1, - })) !== 0; - //#region Construct query - const followingQuery = Followings.createQueryBuilder("following") - .select("following.followeeId") - .where("following.followerId = :followerId", { followerId: user.id }); - const query = makePaginationQuery( Notes.createQueryBuilder("note"), ps.sinceId, @@ -85,13 +74,6 @@ export default define(meta, paramDef, async (ps, user) => { ps.sinceDate, ps.untilDate, ) - .andWhere( - new Brackets((qb) => { - qb.where("note.userId = :meId", { meId: user.id }); - if (hasFollowing) - qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`); - }), - ) .innerJoinAndSelect("note.user", "user") .leftJoinAndSelect("user.avatar", "avatar") .leftJoinAndSelect("user.banner", "banner") @@ -102,9 +84,9 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect("replyUser.banner", "replyUserBanner") .leftJoinAndSelect("renote.user", "renoteUser") .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") - .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") - .setParameters(followingQuery.getParameters()); + .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner"); + await generateFollowingQuery(query, user); generateListQuery(query, user); generateChannelQuery(query, user); generateRepliesQuery(query, ps.withReplies, user); diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index e4d1112d4..a97c12c44 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -21,29 +21,22 @@ import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js" import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; import { generateListQuery } from "@/server/api/common/generate-list-query.js"; +import { generateFollowingQuery } from "@/server/api/common/generate-following-query.js"; export class TimelineHelpers { public static async getHomeTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { if (limit > 40) limit = 40; const user = ctx.user as ILocalUser; - const followingQuery = Followings.createQueryBuilder("following") - .select("following.followeeId") - .where("following.followerId = :followerId", { followerId: user.id }); - const query = PaginationHelpers.makePaginationQuery( Notes.createQueryBuilder("note"), sinceId, maxId, minId ) - .andWhere( - new Brackets((qb) => { - qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, { meId: user.id }); - }), - ) .leftJoinAndSelect("note.renote", "renote"); + await generateFollowingQuery(query, user); generateListQuery(query, user); generateChannelQuery(query, user); generateRepliesQuery(query, true, user);