iceshrimp/packages/backend/src/server/api/endpoints/notes/timeline.ts
Laura Hausmann 8ecf361870
[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.
2023-11-22 00:14:54 +01:00

174 lines
5.1 KiB
TypeScript

import { Brackets } from "typeorm";
import { Notes, Followings } from "@/models/index.js";
import { activeUsersChart } from "@/services/chart/index.js";
import define from "../../define.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
import { generateVisibilityQuery } from "../../common/generate-visibility-query.js";
import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js";
import { generateRepliesQuery } from "../../common/generate-replies-query.js";
import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.js";
import { generateChannelQuery } from "../../common/generate-channel-query.js";
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"],
requireCredential: true,
res: {
type: "array",
optional: false,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
ref: "Note",
},
},
errors: {
queryError: {
message: "Please follow more users.",
code: "QUERY_ERROR",
id: "620763f4-f621-4533-ab33-0577a1a3c343",
},
},
} as const;
export const paramDef = {
type: "object",
properties: {
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
sinceId: { type: "string", format: "misskey:id" },
untilId: { type: "string", format: "misskey:id" },
sinceDate: { type: "integer" },
untilDate: { type: "integer" },
includeMyRenotes: { type: "boolean", default: true },
includeRenotedMyNotes: { type: "boolean", default: true },
includeLocalRenotes: { type: "boolean", default: true },
withFiles: {
type: "boolean",
default: false,
description: "Only show notes that have attached files.",
},
withReplies: {
type: "boolean",
default: false,
description: "Show replies in the timeline",
},
},
required: [],
} as const;
export default define(meta, paramDef, async (ps, user) => {
//#region Construct query
const query = makePaginationQuery(
Notes.createQueryBuilder("note"),
ps.sinceId,
ps.untilId,
ps.sinceDate,
ps.untilDate,
)
.innerJoinAndSelect("note.user", "user")
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply")
.leftJoinAndSelect("note.renote", "renote")
.leftJoinAndSelect("reply.user", "replyUser")
.leftJoinAndSelect("replyUser.avatar", "replyUserAvatar")
.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
await generateFollowingQuery(query, user);
generateListQuery(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, ps.withReplies, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
if (ps.includeMyRenotes === false) {
query.andWhere(
new Brackets((qb) => {
qb.orWhere("note.userId != :meId", { meId: user.id });
qb.orWhere("note.renoteId IS NULL");
qb.orWhere("note.text IS NOT NULL");
qb.orWhere("note.fileIds != '{}'");
qb.orWhere(
'0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)',
);
}),
);
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(
new Brackets((qb) => {
qb.orWhere("note.renoteUserId != :meId", { meId: user.id });
qb.orWhere("note.renoteId IS NULL");
qb.orWhere("note.text IS NOT NULL");
qb.orWhere("note.fileIds != '{}'");
qb.orWhere(
'0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)',
);
}),
);
}
if (ps.includeLocalRenotes === false) {
query.andWhere(
new Brackets((qb) => {
qb.orWhere("note.renoteUserHost IS NOT NULL");
qb.orWhere("note.renoteId IS NULL");
qb.orWhere("note.text IS NOT NULL");
qb.orWhere("note.fileIds != '{}'");
qb.orWhere(
'0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)',
);
}),
);
}
if (ps.withFiles) {
query.andWhere("note.fileIds != '{}'");
}
query.andWhere("note.visibility != 'hidden'");
//#endregion
process.nextTick(() => {
activeUsersChart.read(user);
});
// We fetch more than requested because some may be filtered out, and if there's less than
// requested, the pagination stops.
const found = [];
const take = Math.floor(ps.limit * 1.5);
let skip = 0;
try {
while (found.length < ps.limit) {
const notes = await query.take(take).skip(skip).getMany();
found.push(...(await Notes.packMany(notes, user)));
skip += take;
if (notes.length < take) break;
}
} catch (error) {
throw new ApiError(meta.errors.queryError);
}
if (found.length > ps.limit) {
found.length = ps.limit;
}
return found;
});