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);