perf(database): Improve performance when fetching timelines by fetching all data in a single SQL query

This commit is contained in:
Jesse Wierzbinski 2024-05-08 13:19:53 -10:00
parent 26dfd14aaf
commit e48f57a3d8
No known key found for this signature in database
24 changed files with 158 additions and 89 deletions

View file

@ -21,6 +21,7 @@ export type NotificationWithRelations = Notification & {
export const findManyNotifications = async ( export const findManyNotifications = async (
query: Parameters<typeof db.query.Notifications.findMany>[0], query: Parameters<typeof db.query.Notifications.findMany>[0],
userId?: string,
): Promise<NotificationWithRelations[]> => { ): Promise<NotificationWithRelations[]> => {
const output = await db.query.Notifications.findMany({ const output = await db.query.Notifications.findMany({
...query, ...query,
@ -42,7 +43,8 @@ export const findManyNotifications = async (
output.map(async (notif) => ({ output.map(async (notif) => ({
...notif, ...notif,
account: transformOutputToUserWithRelations(notif.account), account: transformOutputToUserWithRelations(notif.account),
status: (await Note.fromId(notif.noteId))?.getStatus() ?? null, status:
(await Note.fromId(notif.noteId, userId))?.getStatus() ?? null,
})), })),
); );
}; };

View file

@ -61,6 +61,10 @@ export type StatusWithRelations = Status & {
reblogCount: number; reblogCount: number;
likeCount: number; likeCount: number;
replyCount: number; replyCount: number;
pinned: boolean;
reblogged: boolean;
muted: boolean;
liked: boolean;
}; };
export type StatusWithoutRecursiveRelations = Omit< export type StatusWithoutRecursiveRelations = Omit<
@ -75,6 +79,7 @@ export type StatusWithoutRecursiveRelations = Omit<
*/ */
export const findManyNotes = async ( export const findManyNotes = async (
query: Parameters<typeof db.query.Notes.findMany>[0], query: Parameters<typeof db.query.Notes.findMany>[0],
userId?: string,
): Promise<StatusWithRelations[]> => { ): Promise<StatusWithRelations[]> => {
const output = await db.query.Notes.findMany({ const output = await db.query.Notes.findMany({
...query, ...query,
@ -149,6 +154,26 @@ export const findManyNotes = async (
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes_reblog".id)`.as( sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes_reblog".id)`.as(
"reply_count", "reply_count",
), ),
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
}, },
}, },
reply: true, reply: true,
@ -167,6 +192,26 @@ export const findManyNotes = async (
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as( sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as(
"reply_count", "reply_count",
), ),
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
...query?.extras, ...query?.extras,
}, },
}); });
@ -190,10 +235,18 @@ export const findManyNotes = async (
reblogCount: Number(post.reblog.reblogCount), reblogCount: Number(post.reblog.reblogCount),
likeCount: Number(post.reblog.likeCount), likeCount: Number(post.reblog.likeCount),
replyCount: Number(post.reblog.replyCount), replyCount: Number(post.reblog.replyCount),
pinned: Boolean(post.reblog.pinned),
reblogged: Boolean(post.reblog.reblogged),
muted: Boolean(post.reblog.muted),
liked: Boolean(post.reblog.liked),
}, },
reblogCount: Number(post.reblogCount), reblogCount: Number(post.reblogCount),
likeCount: Number(post.likeCount), likeCount: Number(post.likeCount),
replyCount: Number(post.replyCount), replyCount: Number(post.replyCount),
pinned: Boolean(post.pinned),
reblogged: Boolean(post.reblogged),
muted: Boolean(post.muted),
liked: Boolean(post.liked),
})); }));
}; };

View file

@ -54,25 +54,38 @@ import { User } from "./user";
export class Note { export class Note {
private constructor(private status: StatusWithRelations) {} private constructor(private status: StatusWithRelations) {}
static async fromId(id: string | null): Promise<Note | null> { static async fromId(
id: string | null,
userId?: string,
): Promise<Note | null> {
if (!id) return null; if (!id) return null;
return await Note.fromSql(eq(Notes.id, id)); return await Note.fromSql(eq(Notes.id, id), undefined, userId);
} }
static async fromIds(ids: string[]): Promise<Note[]> { static async fromIds(ids: string[], userId?: string): Promise<Note[]> {
return await Note.manyFromSql(inArray(Notes.id, ids)); return await Note.manyFromSql(
inArray(Notes.id, ids),
undefined,
undefined,
undefined,
userId,
);
} }
static async fromSql( static async fromSql(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notes.id), orderBy: SQL<unknown> | undefined = desc(Notes.id),
userId?: string,
) { ) {
const found = await findManyNotes({ const found = await findManyNotes(
{
where: sql, where: sql,
orderBy, orderBy,
limit: 1, limit: 1,
}); },
userId,
);
if (!found[0]) return null; if (!found[0]) return null;
return new Note(found[0]); return new Note(found[0]);
@ -83,13 +96,17 @@ export class Note {
orderBy: SQL<unknown> | undefined = desc(Notes.id), orderBy: SQL<unknown> | undefined = desc(Notes.id),
limit?: number, limit?: number,
offset?: number, offset?: number,
userId?: string,
) { ) {
const found = await findManyNotes({ const found = await findManyNotes(
{
where: sql, where: sql,
orderBy, orderBy,
limit, limit,
offset, offset,
}); },
userId,
);
return found.map((s) => new Note(s)); return found.map((s) => new Note(s));
} }
@ -176,8 +193,14 @@ export class Note {
)[0].count; )[0].count;
} }
async getReplyChildren() { async getReplyChildren(userId?: string) {
return await Note.manyFromSql(eq(Notes.replyId, this.status.id)); return await Note.manyFromSql(
eq(Notes.replyId, this.status.id),
undefined,
undefined,
undefined,
userId,
);
} }
static async insert(values: InferInsertModel<typeof Notes>) { static async insert(values: InferInsertModel<typeof Notes>) {
@ -275,7 +298,7 @@ export class Note {
} }
} }
return await Note.fromId(newNote.id); return await Note.fromId(newNote.id, newNote.authorId);
} }
async updateFromData( async updateFromData(
@ -358,7 +381,7 @@ export class Note {
.where(inArray(Attachments.id, media_attachments)); .where(inArray(Attachments.id, media_attachments));
} }
return await Note.fromId(newNote.id); return await Note.fromId(newNote.id, newNote.authorId);
} }
async delete() { async delete() {
@ -414,47 +437,6 @@ export class Note {
async toAPI(userFetching?: User | null): Promise<APIStatus> { async toAPI(userFetching?: User | null): Promise<APIStatus> {
const data = this.getStatus(); const data = this.getStatus();
const [pinnedByUser, rebloggedByUser, mutedByUser, likedByUser] = (
await Promise.all([
userFetching
? db.query.UserToPinnedNotes.findFirst({
where: (relation, { and, eq }) =>
and(
eq(relation.noteId, data.id),
eq(relation.userId, userFetching?.id),
),
})
: false,
userFetching
? Note.fromSql(
and(
eq(Notes.authorId, userFetching?.id),
eq(Notes.reblogId, data.id),
),
)
: false,
userFetching
? db.query.Relationships.findFirst({
where: (relationship, { and, eq }) =>
and(
eq(relationship.ownerId, userFetching.id),
eq(relationship.subjectId, data.authorId),
eq(relationship.muting, true),
),
})
: false,
userFetching
? db.query.Likes.findFirst({
where: (like, { and, eq }) =>
and(
eq(like.likedId, data.id),
eq(like.likerId, userFetching.id),
),
})
: false,
])
).map((r) => !!r);
// Convert mentions of local users from @username@host to @username // Convert mentions of local users from @username@host to @username
const mentionedLocalUsers = data.mentions.filter( const mentionedLocalUsers = data.mentions.filter(
(mention) => mention.instanceId === null, (mention) => mention.instanceId === null,
@ -488,7 +470,7 @@ export class Note {
card: null, card: null,
content: replacedContent, content: replacedContent,
emojis: data.emojis.map((emoji) => emojiToAPI(emoji)), emojis: data.emojis.map((emoji) => emojiToAPI(emoji)),
favourited: likedByUser, favourited: data.liked,
favourites_count: data.likeCount, favourites_count: data.likeCount,
media_attachments: (data.attachments ?? []).map( media_attachments: (data.attachments ?? []).map(
(a) => attachmentToAPI(a) as APIAttachment, (a) => attachmentToAPI(a) as APIAttachment,
@ -504,8 +486,8 @@ export class Note {
username: mention.username, username: mention.username,
})), })),
language: null, language: null,
muted: mutedByUser, muted: data.muted,
pinned: pinnedByUser, pinned: data.pinned,
// TODO: Add polls // TODO: Add polls
poll: null, poll: null,
reblog: data.reblog reblog: data.reblog
@ -513,7 +495,7 @@ export class Note {
data.reblog as StatusWithRelations, data.reblog as StatusWithRelations,
).toAPI(userFetching) ).toAPI(userFetching)
: null, : null,
reblogged: rebloggedByUser, reblogged: data.reblogged,
reblogs_count: data.reblogCount, reblogs_count: data.reblogCount,
replies_count: data.replyCount, replies_count: data.replyCount,
sensitive: data.sensitive, sensitive: data.sensitive,
@ -525,8 +507,8 @@ export class Note {
bookmarked: false, bookmarked: false,
// @ts-expect-error Glitch-SOC extension // @ts-expect-error Glitch-SOC extension
quote: data.quotingId quote: data.quotingId
? (await Note.fromId(data.quotingId).then((n) => ? (await Note.fromId(data.quotingId, userFetching?.id).then(
n?.toAPI(userFetching), (n) => n?.toAPI(userFetching),
)) ?? null )) ?? null
: null, : null,
quote_id: data.quotingId || undefined, quote_id: data.quotingId || undefined,
@ -589,7 +571,10 @@ export class Note {
let currentStatus: Note = this; let currentStatus: Note = this;
while (currentStatus.getStatus().replyId) { while (currentStatus.getStatus().replyId) {
const parent = await Note.fromId(currentStatus.getStatus().replyId); const parent = await Note.fromId(
currentStatus.getStatus().replyId,
fetcher?.id,
);
if (!parent) { if (!parent) {
break; break;
@ -612,7 +597,7 @@ export class Note {
*/ */
async getDescendants(fetcher: User | null, depth = 0) { async getDescendants(fetcher: User | null, depth = 0) {
const descendants: Note[] = []; const descendants: Note[] = [];
for (const child of await this.getReplyChildren()) { for (const child of await this.getReplyChildren(fetcher?.id)) {
descendants.push(child); descendants.push(child);
if (depth < 20) { if (depth < 20) {

View file

@ -16,11 +16,13 @@ export class Timeline {
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: string,
userId?: string,
) { ) {
return new Timeline(TimelineType.NOTE).fetchTimeline<Note>( return new Timeline(TimelineType.NOTE).fetchTimeline<Note>(
sql, sql,
limit, limit,
url, url,
userId,
); );
} }
@ -40,13 +42,22 @@ export class Timeline {
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: string,
userId?: string,
) { ) {
const notes: Note[] = []; const notes: Note[] = [];
const users: User[] = []; const users: User[] = [];
switch (this.type) { switch (this.type) {
case TimelineType.NOTE: case TimelineType.NOTE:
notes.push(...(await Note.manyFromSql(sql, undefined, limit))); notes.push(
...(await Note.manyFromSql(
sql,
undefined,
limit,
undefined,
userId,
)),
);
break; break;
case TimelineType.USER: case TimelineType.USER:
users.push(...(await User.manyFromSql(sql, undefined, limit))); users.push(...(await User.manyFromSql(sql, undefined, limit)));

View file

@ -62,6 +62,7 @@ export default (app: Hono) =>
auth(meta.auth), auth(meta.auth),
async (context) => { async (context) => {
const { id } = context.req.valid("param"); const { id } = context.req.valid("param");
const { user } = context.req.valid("header");
const otherUser = await User.fromId(id); const otherUser = await User.fromId(id);
@ -95,6 +96,7 @@ export default (app: Hono) =>
), ),
limit, limit,
context.req.url, context.req.url,
user?.id,
); );
return jsonResponse( return jsonResponse(

View file

@ -52,6 +52,7 @@ export default (app: Hono) =>
), ),
limit, limit,
context.req.url, context.req.url,
user?.id,
); );
return jsonResponse( return jsonResponse(

View file

@ -37,10 +37,14 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const notification = ( const notification = (
await findManyNotifications({ await findManyNotifications(
where: (notification, { eq }) => eq(notification.id, id), {
where: (notification, { eq }) =>
eq(notification.id, id),
limit: 1, limit: 1,
}) },
user.id,
)
)[0]; )[0];
if (!notification) if (!notification)

View file

@ -174,6 +174,7 @@ export default (app: Hono) =>
desc(notification.id), desc(notification.id),
}, },
context.req.raw, context.req.raw,
user.id,
); );
return jsonResponse( return jsonResponse(

View file

@ -34,7 +34,7 @@ export default (app: Hono) =>
const { user } = context.req.valid("header"); const { user } = context.req.valid("header");
const foundStatus = await Note.fromId(id); const foundStatus = await Note.fromId(id, user?.id);
if (!foundStatus) return errorResponse("Record not found", 404); if (!foundStatus) return errorResponse("Record not found", 404);

View file

@ -39,7 +39,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const note = await Note.fromId(id); const note = await Note.fromId(id, user?.id);
if (!note?.isViewableByUser(user)) if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -48,7 +48,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const status = await Note.fromId(id); const status = await Note.fromId(id, user?.id);
if (!status?.isViewableByUser(user)) if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -72,7 +72,7 @@ export default (app: Hono) =>
const { id } = context.req.valid("param"); const { id } = context.req.valid("param");
const { user } = context.req.valid("header"); const { user } = context.req.valid("header");
const foundStatus = await Note.fromId(id); const foundStatus = await Note.fromId(id, user?.id);
if (!foundStatus?.isViewableByUser(user)) if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -36,7 +36,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await Note.fromId(id); const foundStatus = await Note.fromId(id, user?.id);
if (!foundStatus) return errorResponse("Record not found", 404); if (!foundStatus) return errorResponse("Record not found", 404);

View file

@ -43,7 +43,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const foundStatus = await Note.fromId(id); const foundStatus = await Note.fromId(id, user.id);
if (!foundStatus?.isViewableByUser(user)) if (!foundStatus?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
@ -72,7 +72,7 @@ export default (app: Hono) =>
return errorResponse("Failed to reblog", 500); return errorResponse("Failed to reblog", 500);
} }
const finalNewReblog = await Note.fromId(newReblog.id); const finalNewReblog = await Note.fromId(newReblog.id, user?.id);
if (!finalNewReblog) { if (!finalNewReblog) {
return errorResponse("Failed to reblog", 500); return errorResponse("Failed to reblog", 500);

View file

@ -47,7 +47,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const status = await Note.fromId(id); const status = await Note.fromId(id, user.id);
if (!status?.isViewableByUser(user)) if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -36,7 +36,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const status = await Note.fromId(id); const status = await Note.fromId(id, user.id);
if (!status?.isViewableByUser(user)) if (!status?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -37,7 +37,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const note = await Note.fromId(id); const note = await Note.fromId(id, user.id);
if (!note?.isViewableByUser(user)) if (!note?.isViewableByUser(user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);

View file

@ -35,7 +35,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const status = await Note.fromId(id); const status = await Note.fromId(id, user.id);
if (!status) return errorResponse("Record not found", 404); if (!status) return errorResponse("Record not found", 404);

View file

@ -38,9 +38,7 @@ export default (app: Hono) =>
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
if (!user) return errorResponse("Unauthorized", 401); const foundStatus = await Note.fromId(id, user.id);
const foundStatus = await Note.fromId(id);
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!foundStatus?.isViewableByUser(user)) if (!foundStatus?.isViewableByUser(user))
@ -51,6 +49,8 @@ export default (app: Hono) =>
eq(Notes.authorId, user.id), eq(Notes.authorId, user.id),
eq(Notes.reblogId, foundStatus.getStatus().id), eq(Notes.reblogId, foundStatus.getStatus().id),
), ),
undefined,
user?.id,
); );
if (!existingReblog) { if (!existingReblog) {

View file

@ -58,6 +58,7 @@ export default (app: Hono) =>
), ),
limit, limit,
context.req.url, context.req.url,
user.id,
); );
return jsonResponse( return jsonResponse(

View file

@ -79,6 +79,7 @@ export default (app: Hono) =>
), ),
limit, limit,
context.req.url, context.req.url,
user?.id,
); );
return jsonResponse( return jsonResponse(

View file

@ -186,6 +186,10 @@ export default (app: Hono) =>
})` })`
: undefined, : undefined,
), ),
undefined,
undefined,
undefined,
self?.id,
); );
return jsonResponse({ return jsonResponse({

View file

@ -106,6 +106,9 @@ export const getTestStatuses = async (
statuses.map((s) => s.id), statuses.map((s) => s.id),
), ),
asc(Notes.id), asc(Notes.id),
undefined,
undefined,
user.id,
) )
).map((n) => n.getStatus()); ).map((n) => n.getStatus());
}; };

View file

@ -17,11 +17,12 @@ export async function fetchTimeline<T extends UserType | Status | Notification>(
| Parameters<typeof findManyUsers>[0] | Parameters<typeof findManyUsers>[0]
| Parameters<typeof db.query.Notifications.findMany>[0], | Parameters<typeof db.query.Notifications.findMany>[0],
req: Request, req: Request,
userId?: string,
) { ) {
// BEFORE: Before in a top-to-bottom order, so the most recent posts // BEFORE: Before in a top-to-bottom order, so the most recent posts
// AFTER: After in a top-to-bottom order, so the oldest posts // AFTER: After in a top-to-bottom order, so the oldest posts
// @ts-expect-error This is a hack to get around the fact that Prisma doesn't have a common base type for all models // @ts-expect-error This is a hack to get around the fact that Prisma doesn't have a common base type for all models
const objects = (await model(args)) as T[]; const objects = (await model(args, userId)) as T[];
// Constuct HTTP Link header (next and prev) only if there are more statuses // Constuct HTTP Link header (next and prev) only if there are more statuses
const linkHeader = []; const linkHeader = [];