[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.
This commit is contained in:
Laura Hausmann 2023-11-21 23:47:02 +01:00
parent a5b30a6adc
commit 8ecf361870
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
3 changed files with 53 additions and 30 deletions

View file

@ -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<number>("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<any>,
me: { id: User["id"] },
): Promise<void> {
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 });
}

View file

@ -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,32 +66,13 @@ 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,
ps.untilId,
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")
@ -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);

View file

@ -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<Note[]> {
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);