Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ミュートユーザーのリアクション考慮を入れるとGTLがタイムアウトする場合がある問題を修正 #241

Merged
merged 4 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/backend/src/core/FanoutTimelineEndpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { removeMutedUsersReactions } from '@/misc/reactions-mute.js';

type TimelineOptions = {
untilId: string | null,
Expand Down Expand Up @@ -50,7 +51,14 @@ export class FanoutTimelineEndpointService {

@bindThis
async timeline(ps: TimelineOptions): Promise<Packed<'Note'>[]> {
return await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me);
const packedNotes = await this.noteEntityService.packMany(await this.getMiNotes(ps), ps.me, ps.me ? { withReactionAndUserPairCache: true } : undefined);
if (ps.me) {
const userIdsWhoMeMuting = await this.cacheService.userMutingsCache.fetch(ps.me.id);
await Promise.all(
packedNotes.map(note => removeMutedUsersReactions(note, userIdsWhoMeMuting)),
);
}
return packedNotes;
}

@bindThis
Expand Down
59 changes: 41 additions & 18 deletions packages/backend/src/core/SearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import { type Config, FulltextSearchProvider } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiNote } from '@/models/Note.js';
import { Packed } from '@/misc/json-schema.js';
import type { NotesRepository } from '@/models/_.js';
import { MiUser } from '@/models/_.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
Expand All @@ -17,6 +18,9 @@ import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { isMustRemove } from '@/misc/is-hidden-or-visibility-modified.js';
import { removeMutedUsersReactions } from '@/misc/reactions-mute.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import type { Index, MeiliSearch } from 'meilisearch';

type K = string;
Expand Down Expand Up @@ -90,6 +94,7 @@ export class SearchService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,

private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private queryService: QueryService,
private idService: IdService,
Expand Down Expand Up @@ -178,16 +183,24 @@ export class SearchService {
me: MiUser | null,
opts: SearchOpts,
pagination: SearchPagination,
): Promise<MiNote[]> {
): Promise<Packed<'Note'>[]> {
const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];

switch (this.provider) {
case 'sqlLike':
case 'sqlPgroonga': {
// ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
// 今後の拡張で差が出る用であれば関数を分ける.
return this.searchNoteByLike(q, me, opts, pagination);
return this.searchNoteByLike(q, me, opts, pagination, userIdsWhoMeMuting, userIdsWhoBlockingMe);
}
case 'meilisearch': {
return this.searchNoteByMeiliSearch(q, me, opts, pagination);
return this.searchNoteByMeiliSearch(q, me, opts, pagination, userIdsWhoMeMuting, userIdsWhoBlockingMe);
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -203,7 +216,9 @@ export class SearchService {
me: MiUser | null,
opts: SearchOpts,
pagination: SearchPagination,
): Promise<MiNote[]> {
userIdsWhoMeMuting: Set<string>,
userIdsWhoBlockingMe: Set<string>,
): Promise<Packed<'Note'>[]> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);

if (opts.userId) {
Expand Down Expand Up @@ -234,10 +249,18 @@ export class SearchService {
}

this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);

return query.limit(pagination.limit).getMany();
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;

return true;
});
const packedNotes = (await this.noteEntityService.packMany(notes, me, { withReactionAndUserPairCache: true })).filter(note => !isMustRemove(note, 'home'));
await Promise.all(
packedNotes.map(note => removeMutedUsersReactions(note, userIdsWhoMeMuting)),
);
return packedNotes;
}

@bindThis
Expand All @@ -246,7 +269,10 @@ export class SearchService {
me: MiUser | null,
opts: SearchOpts,
pagination: SearchPagination,
): Promise<MiNote[]> {
userIdsWhoMeMuting: Set<string>,
userIdsWhoBlockingMe: Set<string>,

): Promise<Packed<'Note'>[]> {
if (!this.meilisearch || !this.meilisearchNoteIndex) {
throw new Error('MeiliSearch is not available');
}
Expand Down Expand Up @@ -286,15 +312,6 @@ export class SearchService {
return [];
}

const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me
? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
])
: [new Set<string>(), new Set<string>()];
const notes = (await this.notesRepository.findBy({
id: In(res.hits.map(x => x.id)),
})).filter(note => {
Expand All @@ -303,6 +320,12 @@ export class SearchService {
return true;
});

return notes.sort((a, b) => a.id > b.id ? -1 : 1);
notes.sort((a, b) => a.id > b.id ? -1 : 1);
const packedNotes = (await this.noteEntityService.packMany(notes, me, { withReactionAndUserPairCache: true })).filter(note => !isMustRemove(note, 'home'));
await Promise.all(
packedNotes.map(note => removeMutedUsersReactions(note, userIdsWhoMeMuting)),
);

return packedNotes;
}
}
3 changes: 2 additions & 1 deletion packages/backend/src/core/entities/ChannelEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class ChannelEntityService {
src: MiChannel['id'] | MiChannel,
me?: { id: MiUser['id'] } | null | undefined,
detailed?: boolean,
pinnedNotesWithReactionAndUserPairCache?: boolean | false,
): Promise<Packed<'Channel'>> {
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
const meId = me ? me.id : null;
Expand Down Expand Up @@ -94,7 +95,7 @@ export class ChannelEntityService {
} : {}),

...(detailed ? {
pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)),
pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me, { withReactionAndUserPairCache: pinnedNotesWithReactionAndUserPairCache })).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)),
} : {}),
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/entities/NoteEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export class NoteEntityService implements OnModuleInit {
options?: {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
},
) {
if (notes.length === 0) return [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,24 @@ export class NoteFavoriteEntityService {
public async pack(
src: MiNoteFavorite['id'] | MiNoteFavorite,
me?: { id: MiUser['id'] } | null | undefined,
withReactionAndUserPairCache?: boolean,
) {
const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src });

return {
id: favorite.id,
createdAt: this.idService.parse(favorite.id).date.toISOString(),
noteId: favorite.noteId,
note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me, { withReactionAndUserPairCache: withReactionAndUserPairCache }),
};
}

@bindThis
public packMany(
favorites: any[],
me: { id: MiUser['id'] },
withReactionAndUserPairCache?: boolean,
) {
return Promise.all(favorites.map(x => this.pack(x, me)));
return Promise.all(favorites.map(x => this.pack(x, me, withReactionAndUserPairCache)));
}
}
2 changes: 2 additions & 0 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export class UserEntityService implements OnModuleInit {
userRelations?: Map<MiUser['id'], UserRelation>,
userMemos?: Map<MiUser['id'], string | null>,
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
pinNotesWithReactionAndUserPairCache?: boolean,
},
): Promise<Packed<S>> {
const opts = Object.assign({
Expand Down Expand Up @@ -543,6 +544,7 @@ export class UserEntityService implements OnModuleInit {
pinnedNoteIds: pins.map(pin => pin.noteId),
pinnedNotes: this.noteEntityService.packMany(pins.map(pin => pin.note!), me, {
detail: true,
withReactionAndUserPairCache: opts.pinNotesWithReactionAndUserPairCache,
}),
pinnedPageId: profile!.pinnedPageId,
pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null,
Expand Down
18 changes: 14 additions & 4 deletions packages/backend/src/core/hanamisearch/HanamiSearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { MiNote } from '@/models/Note.js';
import { MiUser } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js';
import { isMustRemove } from '@/misc/is-hidden-or-visibility-modified.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { removeMutedUsersReactions } from '@/misc/reactions-mute.js';
import type { Index, MeiliSearch } from 'meilisearch';

type K = string;
Expand Down Expand Up @@ -75,6 +78,7 @@ export class HanamiSearchService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,

private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private idService: IdService,
) {
Expand Down Expand Up @@ -177,7 +181,7 @@ export class HanamiSearchService {
sinceId?: MiNote['id'];
limit?: number;
},
): Promise<MiNote[]> {
): Promise<Packed<'Note'>[]> {
const preferredMethod = opts.preferredMethod ?? 'hanamisearchv1';

if ((preferredMethod === 'hanamisearchv1' && this.hanamisearch)) {
Expand Down Expand Up @@ -205,7 +209,7 @@ export class HanamiSearchService {
limit?: number;
},
shouldTimeSeriesSort: boolean,
): Promise<MiNote[]> {
): Promise<Packed<'Note'>[]> {
const filter: Q = { op: 'and', qs: [] };

if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() });
Expand Down Expand Up @@ -242,6 +246,12 @@ export class HanamiSearchService {
return true;
});

return shouldTimeSeriesSort ? notes.sort((a, b) => (a.id > b.id ? -1 : 1)) : notes;
notes.sort((a, b) => a.id > b.id ? -1 : 1);
const packedNotes = (await this.noteEntityService.packMany(notes, me, { withReactionAndUserPairCache: true })).filter(note => !isMustRemove(note, 'home'));
await Promise.all(
packedNotes.map(note => removeMutedUsersReactions(note, userIdsWhoMeMuting)),
);

return packedNotes;
}
}
44 changes: 44 additions & 0 deletions packages/backend/src/misc/reactions-mute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Packed } from '@/misc/json-schema.js';

export async function removeMutedUsersReactions(note: Packed<'Note'>, userIdsWhoMeMuting: Set<string>): Promise<Packed<'Note'>> {
if (!note.reactions || !note.reactionAndUserPairCache) {
delete note.reactionAndUserPairCache;
return note;
}

const mutedReactionsCount = new Map<string, number>();

for (const entry of note.reactionAndUserPairCache) {
const [userId, reaction] = entry.split('/');
if (!reaction || !userId) {
continue;
}

// ミュートされたユーザーの場合のみ処理
if (userIdsWhoMeMuting.has(userId)) {
mutedReactionsCount.set(
reaction,
(mutedReactionsCount.get(reaction) || 0) + 1,
);
}
}

for (const reactionKey of Object.keys(note.reactions)) {
const isLocalEmoji = reactionKey.endsWith('@.:');
const normalizedKey = isLocalEmoji ? reactionKey.replace('@.', '') : reactionKey;
const mutedCount = mutedReactionsCount.get(normalizedKey) || 0;

// ミュートされたリアクションが存在する場合のみ処理
if (mutedCount > 0) {
note.reactions[reactionKey] -= mutedCount;
note.reactionCount -= mutedCount;

// リアクション数がゼロ以下になった場合は削除
if (note.reactions[reactionKey] <= 0) {
delete note.reactions[reactionKey];
}
}
}
delete note.reactionAndUserPairCache;
return note;
}
28 changes: 24 additions & 4 deletions packages/backend/src/server/api/endpoints/antennas/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isMustRemove } from '@/misc/is-hidden-or-visibility-modified.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { removeMutedUsersReactions } from '@/misc/reactions-mute.js';
import { ApiError } from '../../error.js';

export const meta = {
Expand Down Expand Up @@ -69,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,

private cacheService: CacheService,
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
Expand Down Expand Up @@ -106,6 +110,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}

const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];

const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
Expand All @@ -115,10 +127,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');

this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);

const notes = await query.getMany();
const notes = (await query.getMany()).filter(note => {
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (isUserRelated(note, userIdsWhoMeMuting)) return false;

return true;
});

if (sinceId != null && untilId == null) {
notes.sort((a, b) => a.id < b.id ? -1 : 1);
} else {
Expand All @@ -127,7 +143,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-

this.noteReadService.read(me.id, notes);

return (await this.noteEntityService.packMany(notes, me)).filter(note => !isMustRemove(note, 'home'));
const packedNotes = (await this.noteEntityService.packMany(notes, me, { withReactionAndUserPairCache: true })).filter(note => !isMustRemove(note, 'home'));
await Promise.all(
packedNotes.map(note => removeMutedUsersReactions(note, userIdsWhoMeMuting)),
);
return packedNotes;
});
}
}
Loading
Loading