merge: Avoid more N+1 queries in NoteEntityService and UserEntityService (!1099)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1099

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-06-12 15:42:39 +00:00
commit 55551a5a8a
67 changed files with 2684 additions and 683 deletions

View file

@ -47,7 +47,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
alwaysMarkNsfw: true,
});
await this.cacheService.userProfileCache.refresh(ps.userId);
await this.cacheService.userProfileCache.delete(ps.userId);
});
}
}

View file

@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['following', 'users'],
@ -69,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private getterService: GetterService,
private userFollowingService: UserFollowingService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const follower = me;
@ -85,12 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
// Check not following
const exist = await this.followingsRepository.exists({
where: {
followerId: follower.id,
followeeId: followee.id,
},
});
const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id));
if (!exist) {
throw new ApiError(meta.errors.notFollowing);

View file

@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -69,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private getterService: GetterService,
private userFollowingService: UserFollowingService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const followee = me;
@ -85,12 +87,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
// Check not following
const exist = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
});
const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id));
if (exist == null) {
if (!isFollowing) {
throw new ApiError(meta.errors.notFollowing);
}

View file

@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['following', 'users'],
@ -39,6 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
await this.followingsRepository.update({
@ -48,6 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
});
await this.cacheService.refreshFollowRelationsFor(me.id);
return;
});
}

View file

@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -71,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private getterService: GetterService,
private userFollowingService: UserFollowingService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const follower = me;
@ -87,10 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
// Check not following
const exist = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
});
const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.get(followee.id));
if (exist == null) {
throw new ApiError(meta.errors.notFollowing);
@ -103,6 +102,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
});
await this.cacheService.refreshFollowRelationsFor(follower.id);
return await this.userEntityService.pack(follower.id, me);
});
}

View file

@ -617,7 +617,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
this.cacheService.userProfileCache.set(user.id, updatedProfile);
await this.cacheService.userProfileCache.set(user.id, updatedProfile);
// Publish meUpdated event
this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj);

View file

@ -348,7 +348,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -402,7 +402,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -164,7 +164,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false;
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
return true;

View file

@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludePureRenotes: !ps.withRenotes,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false;
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
if (!ps.withBots && note.user?.isBot) return false;

View file

@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null))) {
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null, { me }))) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}

View file

@ -104,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
sendReadMessage: ps.sendReadMessage,
});
this.pushNotificationService.refreshCache(me.id);
await this.pushNotificationService.refreshCache(me.id);
return {
state: 'subscribed' as const,

View file

@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
if (me) {
this.pushNotificationService.refreshCache(me.id);
await this.pushNotificationService.refreshCache(me.id);
}
});
}

View file

@ -86,7 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
sendReadMessage: swSubscription.sendReadMessage,
});
this.pushNotificationService.refreshCache(me.id);
await this.pushNotificationService.refreshCache(me.id);
return {
userId: swSubscription.userId,

View file

@ -12,6 +12,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -89,6 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private followingEntityService: FollowingEntityService,
private queryService: QueryService,
private roleService: RoleService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
@ -110,12 +112,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id));
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}

View file

@ -13,6 +13,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -98,6 +99,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private followingEntityService: FollowingEntityService,
private queryService: QueryService,
private roleService: RoleService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
@ -119,12 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id));
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}

View file

@ -134,7 +134,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`);
const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
const isFollowing = me && (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId);
const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId,

View file

@ -71,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockQueryForUsers(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
// TODO optimization: replace with exists()
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });

View file

@ -57,19 +57,19 @@ export class MastodonDataService {
if (relations.reply) {
query.leftJoinAndSelect('note.reply', 'reply');
if (typeof(relations.reply) === 'object') {
if (relations.reply.reply) query.leftJoinAndSelect('note.reply.reply', 'replyReply');
if (relations.reply.renote) query.leftJoinAndSelect('note.reply.renote', 'replyRenote');
if (relations.reply.user) query.innerJoinAndSelect('note.reply.user', 'replyUser');
if (relations.reply.channel) query.leftJoinAndSelect('note.reply.channel', 'replyChannel');
if (relations.reply.reply) query.leftJoinAndSelect('reply.reply', 'replyReply');
if (relations.reply.renote) query.leftJoinAndSelect('reply.renote', 'replyRenote');
if (relations.reply.user) query.innerJoinAndSelect('reply.user', 'replyUser');
if (relations.reply.channel) query.leftJoinAndSelect('reply.channel', 'replyChannel');
}
}
if (relations.renote) {
query.leftJoinAndSelect('note.renote', 'renote');
if (typeof(relations.renote) === 'object') {
if (relations.renote.reply) query.leftJoinAndSelect('note.renote.reply', 'renoteReply');
if (relations.renote.renote) query.leftJoinAndSelect('note.renote.renote', 'renoteRenote');
if (relations.renote.user) query.innerJoinAndSelect('note.renote.user', 'renoteUser');
if (relations.renote.channel) query.leftJoinAndSelect('note.renote.channel', 'renoteChannel');
if (relations.renote.reply) query.leftJoinAndSelect('renote.reply', 'renoteReply');
if (relations.renote.renote) query.leftJoinAndSelect('renote.renote', 'renoteRenote');
if (relations.renote.user) query.innerJoinAndSelect('renote.user', 'renoteUser');
if (relations.renote.channel) query.leftJoinAndSelect('renote.channel', 'renoteChannel');
}
}
if (relations.user) {

View file

@ -36,7 +36,7 @@ export default class Connection {
private channels = new Map<string, Channel>();
private subscribingNotes = new Map<string, number>();
public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public following: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> = new Map();
public followingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set();

View file

@ -70,7 +70,7 @@ export default abstract class Channel {
if (!this.user) return false;
if (this.user.id === note.userId) return true;
if (note.visibility === 'followers') {
return this.following[note.userId] != null;
return this.following.has(note.userId);
}
if (!note.visibleUserIds) return false;
return note.visibleUserIds.includes(this.user.id);
@ -84,7 +84,7 @@ export default abstract class Channel {
if (note.user.requireSigninToViewContents && !this.user) return true;
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, this.userMutedInstances) && !this.following[note.userId]) return true;
if (isInstanceMuted(note, this.userMutedInstances) && !this.following.has(note.userId)) return true;
// 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
@ -101,7 +101,7 @@ export default abstract class Channel {
if (note.user.isSilenced || note.user.instance?.isSilenced) {
if (this.user == null) return true;
if (this.user.id === note.userId) return false;
if (this.following[note.userId] == null) return true;
if (!this.following.has(note.userId)) return true;
}
// TODO muted threads

View file

@ -62,7 +62,7 @@ class BubbleTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -63,7 +63,7 @@ class GlobalTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -47,7 +47,7 @@ class HomeTimelineChannel extends Channel {
if (!this.followingChannels.has(note.channelId)) return;
} else {
// その投稿のユーザーをフォローしていなかったら弾く
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
if (!isMe && !this.following.has(note.userId)) return;
}
if (this.isNoteMutedOrBlocked(note)) return;
@ -57,7 +57,7 @@ class HomeTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -62,7 +62,7 @@ class HybridTimelineChannel extends Channel {
// フォローしているチャンネルの投稿 の場合だけ
if (!(
(note.channelId == null && isMe) ||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
(note.channelId == null && this.following.has(note.userId)) ||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
@ -74,7 +74,7 @@ class HybridTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies && !this.withReplies) {
if (!this.following.get(note.userId)?.withReplies && !this.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -67,7 +67,7 @@ class LocalTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -55,7 +55,7 @@ class RoleTimelineChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}

View file

@ -98,7 +98,7 @@ class UserListChannel extends Channel {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}