From 70641501444820b2e30c01ae152bf396e8cabf7e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 24 May 2025 23:14:57 -0400 Subject: use instance block columns instead of checking meta columns --- packages/backend/src/server/api/endpoints/users/reactions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 56f59bd285..553787ad58 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -105,10 +105,15 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); + .innerJoinAndSelect('reaction.note', 'note'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } const reactions = (await query .limit(ps.limit) -- cgit v1.2.3-freya From 05d7aa0b91525e9029b1e8a638561bf125ca32cb Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 5 Jun 2025 02:34:57 -0400 Subject: additional fixes and cleanup to all note endpoints --- packages/backend/src/core/LatestNoteService.ts | 25 ++--- packages/backend/src/core/QueryService.ts | 125 +++++++++++++++++++-- .../src/server/api/endpoints/antennas/notes.ts | 4 +- .../src/server/api/endpoints/channels/timeline.ts | 12 +- .../api/endpoints/drive/files/attached-notes.ts | 20 +++- packages/backend/src/server/api/endpoints/notes.ts | 23 +++- .../server/api/endpoints/notes/bubble-timeline.ts | 7 +- .../src/server/api/endpoints/notes/children.ts | 28 ++--- .../src/server/api/endpoints/notes/following.ts | 10 +- .../server/api/endpoints/notes/global-timeline.ts | 4 +- .../server/api/endpoints/notes/hybrid-timeline.ts | 61 ++++------ .../server/api/endpoints/notes/local-timeline.ts | 27 ++--- .../src/server/api/endpoints/notes/renotes.ts | 14 ++- .../src/server/api/endpoints/notes/replies.ts | 11 +- .../server/api/endpoints/notes/search-by-tag.ts | 30 ++--- .../src/server/api/endpoints/notes/timeline.ts | 11 +- .../api/endpoints/notes/user-list-timeline.ts | 43 +++---- .../src/server/api/endpoints/roles/notes.ts | 5 +- .../src/server/api/endpoints/users/notes.ts | 26 +---- 19 files changed, 283 insertions(+), 203 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/core/LatestNoteService.ts b/packages/backend/src/core/LatestNoteService.ts index c379805506..63f973c6c6 100644 --- a/packages/backend/src/core/LatestNoteService.ts +++ b/packages/backend/src/core/LatestNoteService.ts @@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js'; import type { LatestNotesRepository, NotesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; +import { QueryService } from './QueryService.js'; @Injectable() export class LatestNoteService { @@ -14,11 +15,12 @@ export class LatestNoteService { constructor( @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + private readonly notesRepository: NotesRepository, @Inject(DI.latestNotesRepository) - private latestNotesRepository: LatestNotesRepository, + private readonly latestNotesRepository: LatestNotesRepository, + private readonly queryService: QueryService, loggerService: LoggerService, ) { this.logger = loggerService.getLogger('LatestNoteService'); @@ -91,7 +93,7 @@ export class LatestNoteService { // Find the newest remaining note for the user. // We exclude DMs and pure renotes. - const nextLatest = await this.notesRepository + const query = this.notesRepository .createQueryBuilder('note') .select() .where({ @@ -106,18 +108,11 @@ export class LatestNoteService { ? Not(null) : null, }) - .andWhere(` - ( - note."renoteId" IS NULL - OR note.text IS NOT NULL - OR note.cw IS NOT NULL - OR note."replyId" IS NOT NULL - OR note."hasPoll" - OR note."fileIds" != '{}' - ) - `) - .orderBy({ id: 'DESC' }) - .getOne(); + .orderBy({ id: 'DESC' }); + + this.queryService.andIsNotRenote(query, 'note'); + + const nextLatest = await query.getOne(); if (!nextLatest) return; // Record it as the latest diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index d488505afd..4089fc080c 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -188,15 +188,8 @@ export class QueryService { } @bindThis - public generateExcludedRenotesQueryForNotes(q: SelectQueryBuilder): SelectQueryBuilder { - return q - .andWhere(new Brackets(qb => qb - .orWhere('note.renoteId IS NULL') - .orWhere('note.text IS NOT NULL') - .orWhere('note.cw IS NOT NULL') - .orWhere('note.replyId IS NOT NULL') - .orWhere('note.hasPoll = true') - .orWhere('note.fileIds != \'{}\''))); + public generateExcludedRenotesQueryForNotes(q: Q): Q { + return this.andIsNotRenote(q, 'note'); } @bindThis @@ -256,6 +249,120 @@ export class QueryService { return q; } + /** + * Adds OR condition that noteProp (note ID) refers to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsQuote(q: Q, noteProp: string): Q { + return this.addIsQuote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) refers to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsQuote(q: Q, noteProp: string): Q { + return this.addIsQuote(q, noteProp, 'andWhere'); + } + + private addIsQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .andWhere(`${noteProp}.renoteId IS NOT NULL`) + .andWhere(new Brackets(qbb => qbb + .orWhere(`${noteProp}.text IS NOT NULL`) + .orWhere(`${noteProp}.cw IS NOT NULL`) + .orWhere(`${noteProp}.replyId IS NOT NULL`) + .orWhere(`${noteProp}.hasPoll = true`) + .orWhere(`${noteProp}.fileIds != '{}'`))))); + } + + /** + * Adds OR condition that noteProp (note ID) does not refer to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsNotQuote(q: Q, noteProp: string): Q { + return this.addIsNotQuote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) does not refer to a quote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsNotQuote(q: Q, noteProp: string): Q { + return this.addIsNotQuote(q, noteProp, 'andWhere'); + } + + private addIsNotQuote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .orWhere(`${noteProp}.renoteId IS NULL`) + .orWhere(new Brackets(qb => qb + .andWhere(`${noteProp}.text IS NULL`) + .andWhere(`${noteProp}.cw IS NULL`) + .andWhere(`${noteProp}.replyId IS NULL`) + .andWhere(`${noteProp}.hasPoll = false`) + .andWhere(`${noteProp}.fileIds = '{}'`))))); + } + + /** + * Adds OR condition that noteProp (note ID) refers to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsRenote(q: Q, noteProp: string): Q { + return this.addIsRenote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) refers to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsRenote(q: Q, noteProp: string): Q { + return this.addIsRenote(q, noteProp, 'andWhere'); + } + + private addIsRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .andWhere(`${noteProp}.renoteId IS NOT NULL`) + .andWhere(`${noteProp}.text IS NULL`) + .andWhere(`${noteProp}.cw IS NULL`) + .andWhere(`${noteProp}.replyId IS NULL`) + .andWhere(`${noteProp}.hasPoll = false`) + .andWhere(`${noteProp}.fileIds = '{}'`))); + } + + /** + * Adds OR condition that noteProp (note ID) does not refer to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public orIsNotRenote(q: Q, noteProp: string): Q { + return this.addIsNotRenote(q, noteProp, 'orWhere'); + } + + /** + * Adds AND condition that noteProp (note ID) does not refer to a renote. + * The prop should be an expression, not a raw value. + */ + @bindThis + public andIsNotRenote(q: Q, noteProp: string): Q { + return this.addIsNotRenote(q, noteProp, 'andWhere'); + } + + private addIsNotRenote(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q { + return q[join](new Brackets(qb => qb + .orWhere(`${noteProp}.renoteId IS NULL`) + .orWhere(`${noteProp}.text IS NOT NULL`) + .orWhere(`${noteProp}.cw IS NOT NULL`) + .orWhere(`${noteProp}.replyId IS NOT NULL`) + .orWhere(`${noteProp}.hasPoll = true`) + .orWhere(`${noteProp}.fileIds != '{}'`))); + } + /** * Adds OR condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 1abeee53d2..e975b9ad0f 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -114,8 +114,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index b7152130d5..fa5b948eca 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -137,12 +137,14 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel') + .limit(ps.limit); - this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); @@ -159,6 +161,6 @@ export default class extends Endpoint { // eslint- } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 32c2620915..9d70044db8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -81,10 +81,22 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchFile); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); - query.andWhere(':file <@ note.fileIds', { file: [file.id] }); - - const notes = await query.limit(ps.limit).getMany(); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(':file <@ note.fileIds', { file: [file.id] }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes, me, { detail: true, diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index f6c37023e1..00a88521fd 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -64,7 +64,16 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateBlockedHostQueryForNote(query); + if (me) { + this.queryService.generateSilencedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } if (ps.local) { query.andWhere('note.userHost IS NULL'); @@ -75,7 +84,15 @@ export default class extends Endpoint { // eslint- } if (ps.renote !== undefined) { - query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + if (ps.renote) { + this.queryService.andIsRenote(query, 'note'); + + if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + } else { + this.queryService.andIsNotRenote(query, 'note'); + } } if (ps.withFiles !== undefined) { @@ -91,7 +108,7 @@ export default class extends Endpoint { // eslint- // query.isBot = bot; //} - const notes = await query.limit(ps.limit).getMany(); + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes); }); diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts index 5f16351b20..84d6aa0dc7 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -82,8 +82,9 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); // This subquery mess teaches postgres how to use the right indexes. // Using WHERE or ON conditions causes a fallback to full sequence scan, which times out. @@ -114,7 +115,7 @@ export default class extends Endpoint { // eslint- } //#endregion - const timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.getMany(); if (me) { process.nextTick(() => { diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 8f19d534d4..cf8b11ccb5 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -57,26 +57,22 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { - qb - .where('note.replyId = :noteId', { noteId: ps.noteId }); - if (ps.showQuotes) { - qb.orWhere(new Brackets(qb => { - qb - .where('note.renoteId = :noteId', { noteId: ps.noteId }) - .andWhere(new Brackets(qb => { - qb - .where('note.text IS NOT NULL') - .orWhere('note.fileIds != \'{}\'') - .orWhere('note.hasPoll = TRUE'); - })); - })); - } + qb.orWhere('note.replyId = :noteId'); + + if (ps.showQuotes) { + qb.orWhere(new Brackets(qbb => this.queryService + .andIsQuote(qbb, 'note') + .andWhere('note.renoteId = :noteId'), + )); + } })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters({ noteId: ps.noteId }) + .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); @@ -85,7 +81,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedUserQueryForNotes(query, me); } - const notes = await query.limit(ps.limit).getMany(); + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index ac26dbbbc8..0f8c61ab3e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -87,7 +87,7 @@ export default class extends Endpoint { // eslint- const query = this.notesRepository .createQueryBuilder('note') - .setParameter('me', me.id) + .setParameters({ meId: me.id }) // Limit to latest notes .innerJoin( @@ -130,8 +130,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') // Exclude channel notes .andWhere({ channelId: IsNull() }) @@ -177,14 +177,14 @@ export default class extends Endpoint { // eslint- * Limit to followers (they follow us) */ function addFollower>(query: T): T { - return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :me'); + return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :meId'); } /** * Limit to followees (we follow them) */ function addFollowee>(query: T): T { - return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :me AND followee."followeeId" = latest.user_id'); + return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :meId AND followee."followeeId" = latest.user_id'); } /** diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 6ebb3c1676..506ea6fcda 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -82,8 +82,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSilencedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 083da9090f..a7b104e198 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -197,51 +197,32 @@ export default class extends Endpoint { // eslint- withBots: boolean, withRenotes: boolean, }, me: MiLocalUser) { - const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } - })) + // 1. by a user I follow, 2. a public local post, 3. my own post + .andWhere(new Brackets(qb => this.queryService + .orFollowingUser(qb, ':meId', 'note.userId') + .orWhere(new Brackets(qbb => qbb + .andWhere('note.visibility = \'public\'') + .andWhere('note.userHost IS NULL'))) + .orWhere(':meId = note.userId'))) + // 1. in a channel I follow, 2. not in a channel + .andWhere(new Brackets(qb => this.queryService + .orFollowingChannel(qb, ':meId', 'note.channelId') + .orWhere('note.channelId IS NULL'))) + .setParameters({ meId: me.id }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); - - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); - - query.andWhere(new Brackets(qb => { - qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); - qb.orWhere('note.channelId IS NULL'); - })); - } else { - query.andWhere('note.channelId IS NULL'); - } + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); + query + // 1. Not a reply, 2. a self-reply + .andWhere(new Brackets(qb => qb + .orWhere('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = note.userId'))); } this.queryService.generateVisibilityQuery(query, me); @@ -263,6 +244,6 @@ export default class extends Endpoint { // eslint- } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 360528eaed..41b1ee1086 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -168,8 +168,17 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); + + if (!ps.withReplies) { + query + // 1. Not a reply, 2. a self-reply + .andWhere(new Brackets(qb => qb + .orWhere('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = note.userId'))); + } this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSilencedUserQueryForNotes(query, me); @@ -190,18 +199,6 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 0f08cc9cf2..be7cb0320f 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -47,7 +47,7 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, - userId: { type: "string", format: "misskey:id" }, + userId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -81,19 +81,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.userId) { - query.andWhere("user.id = :userId", { userId: ps.userId }); + query.andWhere('user.id = :userId', { userId: ps.userId }); } if (ps.quote) { - query.andWhere("note.text IS NOT NULL"); + this.queryService.andIsQuote(query, 'note'); } else { - query.andWhere("note.text IS NULL"); + this.queryService.andIsRenote(query, 'note'); } this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } const renotes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 0882e19182..f79bfaa7df 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -59,14 +59,17 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } - const timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.getMany(); return await this.noteEntityService.packMany(timeline, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 01bedd9b1d..5064144d9c 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -12,8 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { CacheService } from '@/core/CacheService.js'; -import { UtilityService } from '@/core/UtilityService.js'; export const meta = { tags: ['notes', 'hashtags'], @@ -82,19 +80,18 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, - private cacheService: CacheService, - private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` + .andWhere(new Brackets(qb => qb + .orWhere('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''))) // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSilencedUserQueryForNotes(query, me); @@ -102,7 +99,7 @@ export default class extends Endpoint { // eslint- if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; + if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); try { if (ps.tag) { @@ -135,9 +132,9 @@ export default class extends Endpoint { // eslint- if (ps.renote != null) { if (ps.renote) { - query.andWhere('note.renoteId IS NOT NULL'); + this.queryService.andIsRenote(query, 'note'); } else { - query.andWhere('note.renoteId IS NULL'); + this.queryService.andIsNotRenote(query, 'note'); } } @@ -154,16 +151,7 @@ export default class extends Endpoint { // eslint- } // Search notes - let notes = await query.limit(ps.limit).getMany(); - - notes = notes.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; - if (note.user?.isSuspended) return false; - if (note.userHost) { - if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false; - } - return true; - }); + const notes = await query.getMany(); return await this.noteEntityService.packMany(notes, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d898748ac7..8cf7bb5795 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -142,11 +142,6 @@ export default class extends Endpoint { // eslint- private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') // 1. in a channel I follow, 2. my own post, 3. by a user I follow .andWhere(new Brackets(qb => this.queryService .orFollowingChannel(qb, ':meId', 'note.channelId') @@ -160,10 +155,16 @@ export default class extends Endpoint { // eslint- .orWhere('note.replyId IS NULL') // 返信ではない .orWhere('note.replyUserId = note.userId'))) .setParameters({ meId: me.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 8872672b67..0f038e5541 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -154,32 +154,25 @@ export default class extends Endpoint { // eslint- //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId') + .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) + .andWhere('note.channelId IS NULL') // チャンネルノートではない + .andWhere(new Brackets(qb => qb + // 返信ではない + .orWhere('note.replyId IS NULL') + // 返信だけど投稿者自身への返信 + .orWhere('note.replyUserId = note.userId') + // 返信だけど自分宛ての返信 + .orWhere('note.replyUserId = :meId') + // 返信だけどwithRepliesがtrueの場合 + .orWhere('userListMemberships.withReplies = true'), + )) + .setParameters({ meId: me.id }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') - .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) - .andWhere('note.channelId IS NULL') // チャンネルノートではない - .andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })) - .orWhere(new Brackets(qb => { - qb // 返信だけど自分宛ての返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = :meId', { meId: me.id }); - })) - .orWhere(new Brackets(qb => { - qb // 返信だけどwithRepliesがtrueの場合 - .where('note.replyId IS NOT NULL') - .andWhere('userListMemberships.withReplies = true'); - })); - })); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); @@ -192,12 +185,12 @@ export default class extends Endpoint { // eslint- if (!ps.withRenotes) { this.queryService.generateExcludedRenotesQueryForNotes(query); - } else if (me) { + } else { this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } //#endregion - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 4752561ad5..741bd819ba 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -107,10 +107,11 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 965baa859a..66b50e0633 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -205,7 +205,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.channel', 'channel') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('renote.user', 'renoteUser') + .limit(ps.limit); if (ps.withChannelNotes) { if (!isSelf) query.andWhere(new Brackets(qb => { @@ -230,26 +231,9 @@ export default class extends Endpoint { // eslint- if (!ps.withRenotes && !ps.withQuotes) { query.andWhere('note.renoteId IS NULL'); } else if (!ps.withRenotes) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :userId', { userId: ps.userId }); - 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)'); - })); + this.queryService.andIsNotRenote(query, 'note'); } else if (!ps.withQuotes) { - query.andWhere(` - ( - note."renoteId" IS NULL - OR ( - note.text IS NULL - AND note.cw IS NULL - AND note."replyId" IS NULL - AND note."hasPoll" IS FALSE - AND note."fileIds" = '{}' - ) - ) - `); + this.queryService.andIsNotQuote(query, 'note'); } if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) { @@ -268,6 +252,6 @@ export default class extends Endpoint { // eslint- query.andWhere('"user"."isBot" = false'); } - return await query.limit(ps.limit).getMany(); + return await query.getMany(); } } -- cgit v1.2.3-freya From ce08bd1b425f14a1ca80287ca4cf43deeb32ea76 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 16 Mar 2025 12:37:46 -0400 Subject: reduce, clarify, and normalize more error messages --- packages/backend/src/boot/entry.ts | 16 +++++++++---- packages/backend/src/boot/master.ts | 2 +- packages/backend/src/core/DriveService.ts | 9 ++++---- .../src/core/FetchInstanceMetadataService.ts | 3 ++- packages/backend/src/core/NoteCreateService.ts | 2 -- packages/backend/src/core/WebAuthnService.ts | 10 ++++++-- packages/backend/src/core/WebfingerService.ts | 3 ++- .../backend/src/core/activitypub/ApInboxService.ts | 15 ++++++------ .../src/core/activitypub/models/ApNoteService.ts | 22 ++++++++---------- .../ExportAccountDataProcessorService.ts | 27 +++++++++++----------- .../processors/ExportAntennasProcessorService.ts | 2 +- .../processors/ExportBlockingProcessorService.ts | 2 +- .../ExportCustomEmojisProcessorService.ts | 4 ++-- .../processors/ExportFavoritesProcessorService.ts | 2 +- .../processors/ExportFollowingProcessorService.ts | 2 +- .../processors/ExportMutingProcessorService.ts | 2 +- .../processors/ExportUserListsProcessorService.ts | 2 +- .../processors/ImportAntennasProcessorService.ts | 4 ++-- .../processors/ImportBlockingProcessorService.ts | 2 +- .../ImportCustomEmojisProcessorService.ts | 9 ++++---- .../processors/ImportFollowingProcessorService.ts | 2 +- .../processors/ImportMutingProcessorService.ts | 3 ++- .../processors/ImportNotesProcessorService.ts | 26 ++++++++------------- .../processors/ImportUserListsProcessorService.ts | 3 ++- .../processors/ScheduleNotePostProcessorService.ts | 6 +++-- .../SystemWebhookDeliverProcessorService.ts | 3 ++- packages/backend/src/server/FileServerService.ts | 3 ++- packages/backend/src/server/ServerService.ts | 3 ++- packages/backend/src/server/api/ApiCallService.ts | 5 ++-- .../src/server/api/SigninWithPasskeyApiService.ts | 2 +- .../src/server/api/endpoints/drive/files/create.ts | 5 +++- .../backend/src/server/api/endpoints/i/move.ts | 4 ++-- .../backend/src/server/api/endpoints/i/update.ts | 3 ++- .../backend/src/server/api/endpoints/users/show.ts | 3 ++- 34 files changed, 114 insertions(+), 97 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 735a0f4666..e52d77ab9b 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -9,6 +9,7 @@ import cluster from 'node:cluster'; import { EventEmitter } from 'node:events'; +import { inspect } from 'node:util'; import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; @@ -53,15 +54,22 @@ async function main() { // Display detail of unhandled promise rejection if (!envOption.quiet) { - process.on('unhandledRejection', console.dir); + process.on('unhandledRejection', e => { + try { + logger.error('Unhandled rejection:', inspect(e)); + } catch { + console.error('Unhandled rejection:', inspect(e)); + } + }); } // Display detail of uncaught exception process.on('uncaughtException', err => { try { - logger.error(err); - console.trace(err); - } catch { } + logger.error('Uncaught exception:', err); + } catch { + console.error('Uncaught exception:', err); + } }); // Dying away... diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 538c529106..301db03e0e 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -172,7 +172,7 @@ function loadConfigBoot(): Config { config = loadConfig(); } catch (exception) { if (typeof exception === 'string') { - configLogger.error(exception); + configLogger.error('Exception loading config:', exception); process.exit(1); } else if ((exception as any).code === 'ENOENT') { configLogger.error('Configuration file not found', null, true); diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 73125f36d7..e1e74d0868 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -46,6 +46,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { BunnyService } from '@/core/BunnyService.js'; import { LoggerService } from './LoggerService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; type AddFileArgs = { /** User who wish to add file */ @@ -311,7 +312,7 @@ export class DriveService { thumbnail, }; } catch (err) { - this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`); + this.registerLogger.warn(`GenerateVideoThumbnail failed: ${renderInlineError(err)}`); return { webpublic: null, thumbnail: null, @@ -344,7 +345,7 @@ export class DriveService { metadata.height && metadata.height <= 2048 ); } catch (err) { - this.registerLogger.warn(`sharp failed: ${err}`); + this.registerLogger.warn(`sharp failed: ${renderInlineError(err)}`); return { webpublic: null, thumbnail: null, @@ -651,7 +652,7 @@ export class DriveService { userId: user ? user.id : IsNull(), }) as MiDriveFile; } else { - this.registerLogger.error(err as Error); + this.registerLogger.error('Error in drive register', err as Error); throw err; } } @@ -895,7 +896,7 @@ export class DriveService { this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; } catch (err) { - this.downloaderLogger.error(`Failed to create drive file: ${err}`, { + this.downloaderLogger.error(`Failed to create drive file: ${renderInlineError(err)}`, { url: url, e: err, }); diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 980f1fcacf..9f745f9459 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -15,6 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import type { CheerioAPI } from 'cheerio'; type NodeInfo = { @@ -130,7 +131,7 @@ export class FetchInstanceMetadataService { this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { - this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + this.logger.error(`Failed to update metadata of ${instance.host}: ${renderInlineError(e)}`); } finally { await this.unlock(host); } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 097d657ba3..3703926781 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -549,8 +549,6 @@ export class NoteCreateService implements OnApplicationShutdown { throw err; } - console.error(e); - throw e; } } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 372e1e2ab7..1b75811fff 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -17,6 +17,8 @@ import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { MiUser } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, @@ -28,6 +30,8 @@ import type { @Injectable() export class WebAuthnService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -40,7 +44,9 @@ export class WebAuthnService { @Inject(DI.userSecurityKeysRepository) private userSecurityKeysRepository: UserSecurityKeysRepository, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('web-authn'); } @bindThis @@ -114,7 +120,7 @@ export class WebAuthnService { requireUserVerification: true, }); } catch (error) { - console.error(error); + this.logger.error(error as Error, 'Error authenticating webauthn'); throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed'); } @@ -301,7 +307,7 @@ export class WebAuthnService { requireUserVerification: true, }); } catch (error) { - console.error(error); + this.logger.error(error as Error, 'Error authenticating webauthn'); throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed'); } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index f57e7a2c1f..664963f3a3 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -9,6 +9,7 @@ import { XMLParser } from 'fast-xml-parser'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; export type ILink = { @@ -109,7 +110,7 @@ export class WebfingerService { const template = (hostMeta['XRD']['Link'] as Array).filter(p => p['@_rel'] === 'lrdd')[0]['@_template']; return template.indexOf('{uri}') < 0 ? null : template; } catch (err) { - this.logger.error(`error while request host-meta for ${url}: ${err}`); + this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`); return null; } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 0ffe0eea92..f4208b6e93 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -32,6 +32,7 @@ import { AbuseReportService } from '@/core/AbuseReportService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { fromTuple } from '@/misc/from-tuple.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import FederationChart from '@/core/chart/charts/federation.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; @@ -127,7 +128,7 @@ export class ApInboxService { results.push([id, result]); } catch (err) { if (err instanceof Error || typeof err === 'string') { - this.logger.error(err); + this.logger.error(`Unhandled error in activity ${getNullableApId(item || 'undefined')}:`, err); } else { throw err; } @@ -253,7 +254,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { - this.logger.error(`Resolution failed: ${err}`); + this.logger.error(`Resolution failed: ${renderInlineError(err)}`); throw err; }); @@ -326,7 +327,7 @@ export class ApInboxService { if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; const target = await resolver.secureResolve(activityObject, uri).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -499,7 +500,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activityObject).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -668,7 +669,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -740,7 +741,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); @@ -872,7 +873,7 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); + this.logger.error(`Resolution failed: ${renderInlineError(e)}`); throw e; }); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 5b66031bee..5b3def4bc3 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -26,6 +26,7 @@ import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; @@ -161,7 +162,7 @@ export class ApNoteService { const entryUri = getApId(value); const err = this.validateNote(object, entryUri, actor); if (err) { - this.logger.error(err.message, { + this.logger.error(`Error creating note: ${renderInlineError(err)}`, { resolver: { history: resolver.getHistory() }, value, object, @@ -269,14 +270,14 @@ export class ApNoteService { ? await this.resolveNote(note.inReplyTo, { resolver }) .then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); + this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`); } return x; }) .catch(async err => { - this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); + this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); throw err; }) : null; @@ -379,7 +380,7 @@ export class ApNoteService { const entryUri = getApId(value); const err = this.validateNote(object, entryUri, actor, user); if (err) { - this.logger.error(err.message, { + this.logger.error(`Error updating note: ${renderInlineError(err)}`, { resolver: { history: resolver.getHistory() }, value, object, @@ -473,14 +474,14 @@ export class ApNoteService { ? await this.resolveNote(note.inReplyTo, { resolver }) .then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but not found'); + this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`); throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`); } return x; }) .catch(async err => { - this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); + this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); throw err; }) : null; @@ -685,18 +686,13 @@ export class ApNoteService { const quote = await this.resolveNote(uri, { resolver }); if (quote == null) { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`); + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": fetch failed`); return false; } return quote; } catch (e) { - if (e instanceof Error) { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e); - } else { - this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`); - } - + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${renderInlineError(e)}`); return isRetryableError(e); } }; diff --git a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts index 33a2362c4a..9caf5409dc 100644 --- a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts @@ -22,6 +22,7 @@ import { Packed } from '@/misc/json-schema.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { EmailService } from '@/core/EmailService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -113,7 +114,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { userStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing user:', err); rej(err); } else { res(); @@ -145,7 +146,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { profileStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing profile:', err); rej(err); } else { res(); @@ -179,7 +180,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { ipStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing IPs:', err); rej(err); } else { res(); @@ -214,7 +215,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { notesStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing notes:', err); rej(err); } else { res(); @@ -275,7 +276,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { followingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing following:', err); rej(err); } else { res(); @@ -345,7 +346,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { followerStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing followers:', err); rej(err); } else { res(); @@ -406,7 +407,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { filesStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing drive:', err); rej(err); } else { res(); @@ -432,7 +433,7 @@ export class ExportAccountDataProcessorService { await this.downloadService.downloadUrl(file.url, filePath); downloaded = true; } catch (e) { - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error(`Error writing drive file ${file.id} (${file.name}): ${renderInlineError(e)}`); } if (!downloaded) { @@ -464,7 +465,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { mutingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing mutings:', err); rej(err); } else { res(); @@ -527,7 +528,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { blockingStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing blockings:', err); rej(err); } else { res(); @@ -589,7 +590,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { favoriteStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing favorites:', err); rej(err); } else { res(); @@ -650,7 +651,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { antennaStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing antennas:', err); rej(err); } else { res(); @@ -708,7 +709,7 @@ export class ExportAccountDataProcessorService { return new Promise((res, rej) => { listStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error writing lists:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index b3111865ad..3d3a32a597 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -53,7 +53,7 @@ export class ExportAntennasProcessorService { return new Promise((resolve, reject) => { stream.write(input, err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting antennas:', err); reject(); } else { resolve(); diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index ecc439db69..764bd062a7 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -87,7 +87,7 @@ export class ExportBlockingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting blocking:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index 14d32e78b3..09ed0bfa56 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -66,7 +66,7 @@ export class ExportCustomEmojisProcessorService { return new Promise((res, rej) => { metaStream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting custom emojis:', err); rej(err); } else { res(); @@ -101,7 +101,7 @@ export class ExportCustomEmojisProcessorService { await this.downloadService.downloadUrl(emoji.originalUrl, emojiPath); downloaded = true; } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error exporting custom emojis:', e as Error); } if (!downloaded) { diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index b81feece01..32fcb5d4bc 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -64,7 +64,7 @@ export class ExportFavoritesProcessorService { return new Promise((res, rej) => { stream.write(text, err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting favorites:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 903f962515..0b001dce75 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -98,7 +98,7 @@ export class ExportFollowingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting following:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index f9867ade29..9b12269401 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -88,7 +88,7 @@ export class ExportMutingProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting mutings:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index c483d79854..5483f1d47e 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -74,7 +74,7 @@ export class ExportUserListsProcessorService { await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { - this.logger.error(err); + this.logger.error('Error exporting lists:', err); rej(err); } else { res(); diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 9c033b73e2..901bf5f70a 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -73,7 +73,7 @@ export class ImportAntennasProcessorService { for (const antenna of job.data.antenna) { if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue; if (!validate(antenna)) { - this.logger.warn('Validation Failed'); + this.logger.warn('Antenna validation failed'); continue; } const result = await this.antennasRepository.insertOne({ @@ -96,7 +96,7 @@ export class ImportAntennasProcessorService { this.globalEventService.publishInternalEvent('antennaCreated', result); } } catch (err: any) { - this.logger.error(err); + this.logger.error('Error importing antennas:', err); } } } diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index b78229c648..707bf093fc 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -97,7 +97,7 @@ export class ImportBlockingProcessorService { this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); } catch (e) { - this.logger.warn(`Error: ${e}`); + this.logger.error('Error importing blockings:', e as Error); } } } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index d08cadd378..4f54873924 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -16,6 +16,7 @@ import { DriveService } from '@/core/DriveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -65,7 +66,7 @@ export class ImportCustomEmojisProcessorService { await this.downloadService.downloadUrl(file.url, destPath, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); + this.logger.error('Error importing custom emojis:', e as Error); } throw e; } @@ -117,7 +118,7 @@ export class ImportCustomEmojisProcessorService { }); } catch (e) { if (e instanceof Error || typeof e === 'string') { - this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${e}`); + this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${renderInlineError(e)}`); } continue; } @@ -127,9 +128,7 @@ export class ImportCustomEmojisProcessorService { this.logger.succ('Imported'); } catch (e) { - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing custom emojis:', e as Error); cleanup(); throw e; } diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index 70c9f3a096..647faa142b 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -97,7 +97,7 @@ export class ImportFollowingProcessorService { this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: job.data.withReplies }]); } catch (e) { - this.logger.warn(`Error: ${e}`); + this.logger.error('Error importing followings:', e as Error); } } } diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index ec9d2b6c4c..8b90f7663b 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -14,6 +14,7 @@ import { DownloadService } from '@/core/DownloadService.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -92,7 +93,7 @@ export class ImportMutingProcessorService { await this.userMutingService.mute(user, target); } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); + this.logger.warn(`Error in line:${linenum} ${renderInlineError(e)}`); } } diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index 5e660e8081..c2b8884f94 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -192,9 +192,7 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } @@ -222,9 +220,7 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } @@ -255,9 +251,7 @@ export class ImportNotesProcessorService { await fsp.writeFile(destPath, '', 'binary'); await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } @@ -313,9 +307,7 @@ export class ImportNotesProcessorService { await fsp.writeFile(path, '', 'utf-8'); await this.downloadUrl(file.url, path); } catch (e) { // TODO: 何度か再試行 - if (e instanceof Error || typeof e === 'string') { - this.logger.error(e); - } + this.logger.error('Error importing notes:', e as Error); throw e; } @@ -365,7 +357,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -504,7 +496,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -628,7 +620,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(videos[0].url, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ user: user, @@ -653,7 +645,7 @@ export class ImportNotesProcessorService { try { await this.downloadUrl(file.media_url_https, filePath); } catch (e) { // TODO: 何度か再試行 - this.logger.error(e instanceof Error ? e : new Error(e as string)); + this.logger.error('Error importing notes:', e as Error); } const driveFile = await this.driveService.addFile({ @@ -673,7 +665,7 @@ export class ImportNotesProcessorService { const createdNote = await this.noteCreateService.import(user, { createdAt: date, reply: parentNote, text: text, files: files }); if (tweet.childNotes) this.queueService.createImportTweetsToDbJob(user, tweet.childNotes, createdNote.id); } catch (e) { - this.logger.warn(`Error: ${e}`); + this.logger.error('Error importing notes:', e as Error); } } diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index db9255b35d..d30ebfa187 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -15,6 +15,7 @@ import { UserListService } from '@/core/UserListService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -102,7 +103,7 @@ export class ImportUserListsProcessorService { this.userListService.addMember(target, list!, user); } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); + this.logger.warn(`Error in line:${linenum} ${renderInlineError(e)}`); } } diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts index d823d98ef1..d96199c201 100644 --- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { NotificationService } from '@/core/NotificationService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiScheduleNoteType } from '@/models/NoteSchedule.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { ScheduleNotePostJobData } from '../types.js'; @@ -129,10 +130,11 @@ export class ScheduleNotePostProcessorService { channel, }).catch(async (err: IdentifiableError) => { this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { - reason: err.message, + reason: renderInlineError(err), }); await this.noteScheduleRepository.remove(data); - throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`); + this.logger.error(`Scheduled note failed:`, err); + throw err; }); await this.noteScheduleRepository.remove(data); this.notificationService.createNotification(me.id, 'scheduledNotePosted', { diff --git a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts index f6bef52684..61042ff718 100644 --- a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts @@ -12,6 +12,7 @@ import type Logger from '@/logger.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import { SystemWebhookDeliverJobData } from '../types.js'; @@ -63,7 +64,7 @@ export class SystemWebhookDeliverProcessorService { return 'Success'; } catch (res) { - this.logger.error(res as Error); + this.logger.error(`Failed to send webhook: ${renderInlineError(res)}`); this.systemWebhooksRepository.update({ id: job.data.webhookId }, { latestSentAt: new Date(), diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 4ef5539cff..2e3e8df7ea 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -32,6 +32,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -120,7 +121,7 @@ export class FileServerService { @bindThis private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) { - this.logger.error(`${err}`); + this.logger.error(`Unhandled error in file server: ${renderInlineError(err)}`); reply.header('Cache-Control', 'max-age=300'); diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 2d20aa1222..77b4519570 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -277,7 +278,7 @@ export class ServerService implements OnApplicationShutdown { this.logger.error(`Port ${this.config.port} is already in use by another process.`); break; default: - this.logger.error(err); + this.logger.error(`Unhandled error in server: ${renderInlineError(err)}`); break; } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 5c9e5717bb..ce0078cd93 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -20,6 +20,7 @@ import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from './error.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; @@ -100,7 +101,7 @@ export class ApiCallService implements OnApplicationShutdown { throw err; } else { const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { + this.logger.error(`Internal error occurred in ${ep.name}: ${renderInlineError(err)}`, { ep: ep.name, ps: data, e: { @@ -112,7 +113,7 @@ export class ApiCallService implements OnApplicationShutdown { }); if (this.config.sentryForBackend) { - Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${renderInlineError(err)}`, { level: 'error', user: { id: userId, diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index f84f50523b..38886f8876 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -128,7 +128,7 @@ export class SigninWithPasskeyApiService { try { authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential); } catch (err) { - this.logger.warn(`Passkey challenge Verify error! : ${err}`); + this.logger.warn('Passkey challenge verify error:', err as Error); const errorId = (err as IdentifiableError).id; return error(403, { id: errorId, diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 5373cc4dd8..939eadad9b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -10,6 +10,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveService } from '@/core/DriveService.js'; import type { Config } from '@/config.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from '../../../error.js'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -95,6 +97,7 @@ export default class extends Endpoint { // eslint- private driveFileEntityService: DriveFileEntityService, private driveService: DriveService, + private readonly apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { // Get 'name' parameter @@ -130,7 +133,7 @@ export default class extends Endpoint { // eslint- return await this.driveFileEntityService.pack(driveFile, { self: true }); } catch (err) { if (err instanceof Error || typeof err === 'string') { - console.error('Error saving drive file:', err); + this.apiLoggerService.logger.error(`Error saving drive file: ${renderInlineError(err)}`); } if (err instanceof IdentifiableError) { if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index 7852b5a2e1..e2a14b61af 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -17,7 +17,7 @@ import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; - +import { renderInlineError } from '@/misc/render-inline-error.js'; import * as Acct from '@/misc/acct.js'; import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/_.js'; @@ -105,7 +105,7 @@ export default class extends Endpoint { // eslint- const { username, host } = Acct.parse(ps.moveToAccount); // retrieve the destination account let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(e)}`); throw new ApiError(meta.errors.noSuchUser); }); const destination = await this.getterService.getUser(moveTo.id) as MiLocalUser | MiRemoteUser; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5a160d632c..193f2e8313 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -34,6 +34,7 @@ import { verifyFieldLinks } from '@/misc/verify-field-link.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; import { userUnsignedFetchOptions } from '@/const.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -516,7 +517,7 @@ export default class extends Endpoint { // eslint- // Retrieve the old account const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + this.apiLoggerService.logger.warn(`failed to resolve destination user: ${renderInlineError(e)}`); throw new ApiError(meta.errors.noSuchUser); }); if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 7b1c8adfb8..84eb661742 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -13,6 +13,7 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DI } from '@/di-symbols.js'; import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; import { RoleService } from '@/core/RoleService.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; import { ApiError } from '../../error.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import type { FindOptionsWhere } from 'typeorm'; @@ -131,7 +132,7 @@ export default class extends Endpoint { // eslint- // Lookup user if (typeof ps.host === 'string' && typeof ps.username === 'string') { user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(err)}`); throw new ApiError(meta.errors.failedToResolveRemoteUser); }); } else { -- cgit v1.2.3-freya From 853b548a4369051b8fdaabbda80d7d6ed52adb77 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 7 Jun 2025 21:27:25 -0400 Subject: re-type userFollowingsCache to match the others --- packages/backend/src/core/AntennaService.ts | 3 +- packages/backend/src/core/CacheService.ts | 38 +++++++++------------- packages/backend/src/core/NotificationService.ts | 12 +++---- .../backend/src/core/entities/NoteEntityService.ts | 14 +++----- .../server/api/endpoints/notes/hybrid-timeline.ts | 2 +- .../src/server/api/endpoints/notes/timeline.ts | 2 +- .../src/server/api/endpoints/users/notes.ts | 2 +- .../backend/src/server/api/stream/Connection.ts | 2 +- packages/backend/src/server/api/stream/channel.ts | 6 ++-- .../server/api/stream/channels/bubble-timeline.ts | 2 +- .../server/api/stream/channels/global-timeline.ts | 2 +- .../server/api/stream/channels/home-timeline.ts | 4 +-- .../server/api/stream/channels/hybrid-timeline.ts | 4 +-- .../server/api/stream/channels/local-timeline.ts | 2 +- .../server/api/stream/channels/role-timeline.ts | 2 +- .../src/server/api/stream/channels/user-list.ts | 2 +- packages/backend/test/misc/noOpCaches.ts | 4 +-- 17 files changed, 47 insertions(+), 56 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index cf696e3599..667df57943 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -130,7 +130,8 @@ export class AntennaService implements OnApplicationShutdown { } if (note.visibility === 'followers') { - const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId); + const followings = await this.cacheService.userFollowingsCache.fetch(antenna.userId); + const isFollowing = followings.has(note.userId); if (!isFollowing && antenna.userId !== note.userId) return false; } diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index e59857b4ce..38a93e57f4 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -6,14 +6,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In, IsNull } from 'typeorm'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js'; +import type { InternalEventTypes } from '@/core/GlobalEventService.js'; import { InternalEventService } from '@/core/InternalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @@ -46,7 +46,7 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: QuantumKVCache>; public userBlockedCache: QuantumKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: QuantumKVCache>; - public userFollowingsCache: QuantumKVCache | undefined>>; + public userFollowingsCache: QuantumKVCache>; protected userFollowStatsCache = new MemoryKVCache(1000 * 60 * 10); // 10 minutes protected translationsCache: RedisKVCache; @@ -110,15 +110,9 @@ export class CacheService implements OnApplicationShutdown { fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), }); - this.userFollowingsCache = new QuantumKVCache | undefined>>(this.internalEventService, 'userFollowings', { + this.userFollowingsCache = new QuantumKVCache>(this.internalEventService, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { - const obj: Record | undefined> = {}; - for (const x of xs) { - obj[x.followeeId] = { withReplies: x.withReplies }; - } - return obj; - }), + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => new Map(xs.map(f => [f.followeeId, { withReplies: f.withReplies }]))), }); this.translationsCache = new RedisKVCache(this.redisClient, 'translations', { @@ -305,14 +299,14 @@ export class CacheService implements OnApplicationShutdown { } @bindThis - public async getUserFollowings(userIds: Iterable): Promise>> { - const followings = new Map>(); + public async getUserFollowings(userIds: Iterable): Promise>> { + const followings = new Map>(); const toFetch: string[] = []; for (const userId of userIds) { const fromCache = this.userFollowingsCache.get(userId); if (fromCache) { - followings.set(userId, new Set(Object.keys(fromCache))); + followings.set(userId, fromCache); } else { toFetch.push(userId); } @@ -331,25 +325,25 @@ export class CacheService implements OnApplicationShutdown { }) .getMany(); - const toCache = new Map | undefined>>(); + const toCache = new Map>(); // Pivot to a map for (const { followerId, followeeId, withReplies } of fetchedFollowings) { // Queue for cache - let cacheSet = toCache.get(followerId); - if (!cacheSet) { - cacheSet = {}; - toCache.set(followerId, cacheSet); + let cacheMap = toCache.get(followerId); + if (!cacheMap) { + cacheMap = new Map(); + toCache.set(followerId, cacheMap); } - cacheSet[followeeId] = { withReplies }; + cacheMap.set(followeeId, { withReplies }); // Queue for return let returnSet = followings.get(followerId); if (!returnSet) { - returnSet = new Set(); + returnSet = new Map(); followings.set(followerId, returnSet); } - returnSet.add(followeeId); + returnSet.set(followeeId, { withReplies }); } // Update cache to speed up future calls diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 0f05f5425d..2ce7bdb5a9 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -113,27 +113,27 @@ export class NotificationService implements OnApplicationShutdown { } if (recieveConfig?.type === 'following') { - const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); if (!isFollowing) { return null; } } else if (recieveConfig?.type === 'follower') { - const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)); if (!isFollower) { return null; } } else if (recieveConfig?.type === 'mutualFollow') { const [isFollowing, isFollower] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), - this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), ]); if (!(isFollowing && isFollower)) { return null; } } else if (recieveConfig?.type === 'followingOrFollower') { const [isFollowing, isFollower] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), - this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), ]); if (!isFollowing && !isFollower) { return null; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 1b3920e13f..3af66b220d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -133,7 +133,7 @@ export class NoteEntityService implements OnModuleInit { @bindThis public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: { - myFollowing?: ReadonlySet, + myFollowing?: ReadonlyMap, myBlockers?: ReadonlySet, }): Promise { if (meId === packedNote.userId) return; @@ -193,7 +193,7 @@ export class NoteEntityService implements OnModuleInit { } else { const isFollowing = hint?.myFollowing ? hint.myFollowing.has(packedNote.userId) - : (await this.cacheService.userFollowingsCache.fetch(meId))[packedNote.userId] != null; + : (await this.cacheService.userFollowingsCache.fetch(meId)).has(packedNote.userId); hide = !isFollowing; } @@ -358,14 +358,10 @@ export class NoteEntityService implements OnModuleInit { : this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)), hint?.myFollowing ? hint.myFollowing.has(note.userId) - : this.followingsRepository.existsBy({ - followeeId: note.userId, - followerId: meId, - }), + : this.cacheService.userFollowingsCache.fetch(meId).then(ids => ids.has(note.userId)), hint?.me !== undefined ? (hint.me?.host ?? null) - : this.cacheService.userByIdCache.fetch(meId, () => this.usersRepository.findOneByOrFail({ id: meId })) - .then(me => me.host), + : this.cacheService.findUserById(meId).then(me => me.host), ]); if (blocked) return false; @@ -420,7 +416,7 @@ export class NoteEntityService implements OnModuleInit { packedFiles: Map | null>; packedUsers: Map>; mentionHandles: Record; - userFollowings: Map>; + userFollowings: Map>; userBlockers: Map>; polls: Map; pollVotes: Map>; diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index a7b104e198..a5623d1f03 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -164,7 +164,7 @@ export default class extends Endpoint { // 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; diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8cf7bb5795..44c539eaad 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -115,7 +115,7 @@ export default class extends Endpoint { // 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; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 66b50e0633..4602709067 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -134,7 +134,7 @@ export default class extends Endpoint { // 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, diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index e0535a2f14..21437850d3 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -36,7 +36,7 @@ export default class Connection { private channels = new Map(); private subscribingNotes = new Map(); public userProfile: MiUserProfile | null = null; - public following: Record | undefined> = {}; + public following: Map = new Map(); public followingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3a82865577..40ad454adb 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -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 diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 393fe3883c..72f719b411 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index bac0277538..5c73f637c7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index d1dcbd07e5..c7062c0394 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index d923167e04..7cb64c9f89 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 2eb3460efa..4869d871d6 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index f5984b5ae9..a3886618f1 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -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; } diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 3f1a5a8f8f..4dae24a696 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -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; } diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts index c05632239b..7e8c27503c 100644 --- a/packages/backend/test/misc/noOpCaches.ts +++ b/packages/backend/test/misc/noOpCaches.ts @@ -6,7 +6,7 @@ import * as Redis from 'ioredis'; import { Inject } from '@nestjs/common'; import { FakeInternalEventService } from './FakeInternalEventService.js'; -import type { BlockingsRepository, FollowingsRepository, MiFollowing, MiUser, MiUserProfile, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MiUser, MiUserProfile, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; @@ -106,7 +106,7 @@ export class NoOpCacheService extends CacheService { onSet: this.renoteMutingsCache.onSet, onDelete: this.renoteMutingsCache.onDelete, }); - this.userFollowingsCache = new NoOpQuantumKVCache | undefined>>({ + this.userFollowingsCache = new NoOpQuantumKVCache>({ internalEventService: fakeInternalEventService, fetcher: this.userFollowingsCache.fetcher, onSet: this.userFollowingsCache.onSet, -- cgit v1.2.3-freya From fa68751a19877474bf78a80ef7204102296f0f17 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 8 Jun 2025 19:52:59 -0400 Subject: normalize userFollowingsCache / userFollowersCache and add hibernatedUserCache to reduce the number of cache-clears and allow use of caching in many more places --- packages/backend/src/core/AccountMoveService.ts | 24 +- packages/backend/src/core/CacheService.ts | 316 ++++++++++--------- packages/backend/src/core/GlobalEventService.ts | 2 +- packages/backend/src/core/NoteCreateService.ts | 43 ++- packages/backend/src/core/NoteEditService.ts | 33 +- packages/backend/src/core/UserFollowingService.ts | 121 +++----- packages/backend/src/core/UserService.ts | 21 +- packages/backend/src/core/UserSuspendService.ts | 10 +- .../core/activitypub/ApDeliverManagerService.ts | 54 ++-- .../backend/src/core/activitypub/ApInboxService.ts | 16 +- .../src/core/activitypub/models/ApPersonService.ts | 15 +- .../backend/src/core/chart/charts/federation.ts | 1 + .../src/core/chart/charts/per-user-following.ts | 19 +- .../backend/src/core/entities/NoteEntityService.ts | 10 +- .../backend/src/core/entities/UserEntityService.ts | 44 +-- packages/backend/src/misc/QuantumKVCache.ts | 145 ++++++--- packages/backend/src/misc/cache.ts | 18 +- .../processors/DeleteAccountProcessorService.ts | 18 ++ .../src/server/api/endpoints/following/delete.ts | 9 +- .../server/api/endpoints/following/invalidate.ts | 9 +- .../server/api/endpoints/following/update-all.ts | 4 + .../src/server/api/endpoints/following/update.ts | 9 +- .../src/server/api/endpoints/users/followers.ts | 9 +- .../src/server/api/endpoints/users/following.ts | 9 +- .../server/api/endpoints/users/recommendation.ts | 1 + .../backend/src/server/api/stream/Connection.ts | 2 +- packages/backend/test/misc/noOpCaches.ts | 104 +++---- packages/backend/test/unit/misc/QuantumKVCache.ts | 333 +++++++++++++++++---- 28 files changed, 817 insertions(+), 582 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 738026f753..e107f02796 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -26,6 +26,7 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { RoleService } from '@/core/RoleService.js'; import { AntennaService } from '@/core/AntennaService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class AccountMoveService { @@ -68,6 +69,7 @@ export class AccountMoveService { private systemAccountService: SystemAccountService, private roleService: RoleService, private antennaService: AntennaService, + private readonly cacheService: CacheService, ) { } @@ -107,12 +109,10 @@ export class AccountMoveService { this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); // Unfollow after 24 hours - const followings = await this.followingsRepository.findBy({ - followerId: src.id, - }); - this.queueService.createDelayedUnfollowJob(followings.map(following => ({ + const followings = await this.cacheService.userFollowingsCache.fetch(src.id); + this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({ from: { id: src.id }, - to: { id: following.followeeId }, + to: { id: followeeId }, })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); await this.postMoveProcess(src, dst); @@ -138,11 +138,9 @@ export class AccountMoveService { // follow the new account const proxy = await this.systemAccountService.fetch('proxy'); - const followings = await this.followingsRepository.findBy({ - followeeId: src.id, - followerHost: IsNull(), // follower is local - followerId: Not(proxy.id), - }); + const followings = await this.cacheService.userFollowersCache.fetch(src.id) + .then(fs => Array.from(fs.values()) + .filter(f => f.followerHost == null && f.followerId !== proxy.id)); const followJobs = followings.map(following => ({ from: { id: following.followerId }, to: { id: dst.id }, @@ -318,9 +316,9 @@ export class AccountMoveService { await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); // Decrease follower counts of local followees by 1. - const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id }); - if (oldFollowings.length > 0) { - await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1); + const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id); + if (oldFollowings.size > 0) { + await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1); } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index e8b26f8b9b..9c68597441 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In, IsNull } from 'typeorm'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -46,8 +46,9 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: QuantumKVCache>; public userBlockedCache: QuantumKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: QuantumKVCache>; - public userFollowingsCache: QuantumKVCache>; - public userFollowersCache: QuantumKVCache>; + public userFollowingsCache: QuantumKVCache>>; + public userFollowersCache: QuantumKVCache>>; + public hibernatedUserCache: QuantumKVCache; protected userFollowStatsCache = new MemoryKVCache(1000 * 60 * 10); // 10 minutes protected translationsCache: RedisKVCache; @@ -89,36 +90,145 @@ export class CacheService implements OnApplicationShutdown { this.userProfileCache = new QuantumKVCache(this.internalEventService, 'userProfile', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), + bulkFetcher: userIds => this.userProfilesRepository.findBy({ userId: In(userIds) }).then(ps => ps.map(p => [p.userId, p])), }); this.userMutingsCache = new QuantumKVCache>(this.internalEventService, 'userMutings', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + bulkFetcher: muterIds => this.mutingsRepository + .createQueryBuilder('muting') + .select('"muting"."muterId"', 'muterId') + .addSelect('array_agg("muting"."muteeId")', 'muteeIds') + .where({ muterId: In(muterIds) }) + .groupBy('muting.muterId') + .getRawMany<{ muterId: string, muteeIds: string[] }>() + .then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])), }); this.userBlockingCache = new QuantumKVCache>(this.internalEventService, 'userBlocking', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), + bulkFetcher: blockerIds => this.blockingsRepository + .createQueryBuilder('blocking') + .select('"blocking"."blockerId"', 'blockerId') + .addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds') + .where({ blockerId: In(blockerIds) }) + .groupBy('blocking.blockerId') + .getRawMany<{ blockerId: string, blockeeIds: string[] }>() + .then(ms => ms.map(m => [m.blockerId, new Set(m.blockeeIds)])), }); this.userBlockedCache = new QuantumKVCache>(this.internalEventService, 'userBlocked', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), + bulkFetcher: blockeeIds => this.blockingsRepository + .createQueryBuilder('blocking') + .select('"blocking"."blockeeId"', 'blockeeId') + .addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds') + .where({ blockeeId: In(blockeeIds) }) + .groupBy('blocking.blockeeId') + .getRawMany<{ blockeeId: string, blockerIds: string[] }>() + .then(ms => ms.map(m => [m.blockeeId, new Set(m.blockerIds)])), }); this.renoteMutingsCache = new QuantumKVCache>(this.internalEventService, 'renoteMutings', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + bulkFetcher: muterIds => this.renoteMutingsRepository + .createQueryBuilder('muting') + .select('"muting"."muterId"', 'muterId') + .addSelect('array_agg("muting"."muteeId")', 'muteeIds') + .where({ muterId: In(muterIds) }) + .groupBy('muting.muterId') + .getRawMany<{ muterId: string, muteeIds: string[] }>() + .then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])), }); - this.userFollowingsCache = new QuantumKVCache>(this.internalEventService, 'userFollowings', { + this.userFollowingsCache = new QuantumKVCache>>(this.internalEventService, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => new Map(xs.map(f => [f.followeeId, { withReplies: f.withReplies }]))), + fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))), + bulkFetcher: followerIds => this.followingsRepository + .findBy({ followerId: In(followerIds) }) + .then(fs => fs + .reduce((groups, f) => { + let group = groups.get(f.followerId); + if (!group) { + group = new Map(); + groups.set(f.followerId, group); + } + group.set(f.followeeId, f); + return groups; + }, {} as Map>>)), + }); + + this.userFollowersCache = new QuantumKVCache>>(this.internalEventService, 'userFollowers', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: followeeId => this.followingsRepository.findBy({ followeeId: followeeId }).then(xs => new Map(xs.map(x => [x.followerId, x]))), + bulkFetcher: followeeIds => this.followingsRepository + .findBy({ followeeId: In(followeeIds) }) + .then(fs => fs + .reduce((groups, f) => { + let group = groups.get(f.followeeId); + if (!group) { + group = new Map(); + groups.set(f.followeeId, group); + } + group.set(f.followerId, f); + return groups; + }, {} as Map>>)), }); - this.userFollowersCache = new QuantumKVCache>(this.internalEventService, 'userFollowers', { + this.hibernatedUserCache = new QuantumKVCache(this.internalEventService, 'hibernatedUsers', { lifetime: 1000 * 60 * 30, // 30m - fetcher: (key) => this.followingsRepository.find({ where: { followeeId: key }, select: ['followerId'] }).then(xs => new Set(xs.map(x => x.followerId))), + fetcher: async userId => { + const { isHibernated } = await this.usersRepository.findOneOrFail({ + where: { id: userId }, + select: { isHibernated: true }, + }); + return isHibernated; + }, + bulkFetcher: async userIds => { + const results = await this.usersRepository.find({ + where: { id: In(userIds) }, + select: { id: true, isHibernated: true }, + }); + return results.map(({ id, isHibernated }) => [id, isHibernated]); + }, + onChanged: async userIds => { + // We only update local copies since each process will get this event, but we can have user objects in multiple different caches. + // Before doing anything else we must "find" all the objects to update. + const userObjects = new Map(); + const toUpdate: string[] = []; + for (const uid of userIds) { + const toAdd: MiUser[] = []; + + const localUserById = this.localUserByIdCache.get(uid); + if (localUserById) toAdd.push(localUserById); + + const userById = this.userByIdCache.get(uid); + if (userById) toAdd.push(userById); + + if (toAdd.length > 0) { + toUpdate.push(uid); + userObjects.set(uid, toAdd); + } + } + + // In many cases, we won't have to do anything. + // Skipping the DB fetch ensures that this remains a single-step synchronous process. + if (toUpdate.length > 0) { + const hibernations = await this.usersRepository.find({ where: { id: In(toUpdate) }, select: { id: true, isHibernated: true } }); + for (const { id, isHibernated } of hibernations) { + const users = userObjects.get(id); + if (users) { + for (const u of users) { + u.isHibernated = isHibernated; + } + } + } + } + }, }); this.translationsCache = new RedisKVCache(this.redisClient, 'translations', { @@ -161,6 +271,7 @@ export class CacheService implements OnApplicationShutdown { this.renoteMutingsCache.delete(body.id), this.userFollowingsCache.delete(body.id), this.userFollowersCache.delete(body.id), + this.hibernatedUserCache.delete(body.id), ]); } } else { @@ -312,142 +423,6 @@ export class CacheService implements OnApplicationShutdown { } @bindThis - public async getUserFollowings(userIds: Iterable): Promise>> { - const followings = new Map>(); - - const toFetch: string[] = []; - for (const userId of userIds) { - const fromCache = this.userFollowingsCache.get(userId); - if (fromCache) { - followings.set(userId, fromCache); - } else { - toFetch.push(userId); - } - } - - if (toFetch.length > 0) { - const fetchedFollowings = await this.followingsRepository - .createQueryBuilder('following') - .select([ - 'following.followerId', - 'following.followeeId', - 'following.withReplies', - ]) - .where({ - followerId: In(toFetch), - }) - .getMany(); - - const toCache = new Map>(); - - // Pivot to a map - for (const { followerId, followeeId, withReplies } of fetchedFollowings) { - // Queue for cache - let cacheMap = toCache.get(followerId); - if (!cacheMap) { - cacheMap = new Map(); - toCache.set(followerId, cacheMap); - } - cacheMap.set(followeeId, { withReplies }); - - // Queue for return - let returnSet = followings.get(followerId); - if (!returnSet) { - returnSet = new Map(); - followings.set(followerId, returnSet); - } - returnSet.set(followeeId, { withReplies }); - } - - // Update cache to speed up future calls - this.userFollowingsCache.addMany(toCache); - } - - return followings; - } - - @bindThis - public async getUserBlockers(userIds: Iterable): Promise>> { - const blockers = new Map>(); - - const toFetch: string[] = []; - for (const userId of userIds) { - const fromCache = this.userBlockedCache.get(userId); - if (fromCache) { - blockers.set(userId, fromCache); - } else { - toFetch.push(userId); - } - } - - if (toFetch.length > 0) { - const fetchedBlockers = await this.blockingsRepository.createQueryBuilder('blocking') - .select([ - 'blocking.blockerId', - 'blocking.blockeeId', - ]) - .where({ - blockeeId: In(toFetch), - }) - .getMany(); - - const toCache = new Map>(); - - // Pivot to a map - for (const { blockerId, blockeeId } of fetchedBlockers) { - // Queue for cache - let cacheSet = toCache.get(blockeeId); - if (!cacheSet) { - cacheSet = new Set(); - toCache.set(blockeeId, cacheSet); - } - cacheSet.add(blockerId); - - // Queue for return - let returnSet = blockers.get(blockeeId); - if (!returnSet) { - returnSet = new Set(); - blockers.set(blockeeId, returnSet); - } - returnSet.add(blockerId); - } - - // Update cache to speed up future calls - this.userBlockedCache.addMany(toCache); - } - - return blockers; - } - - public async getUserProfiles(userIds: Iterable): Promise> { - const profiles = new Map; - - const toFetch: string[] = []; - for (const userId of userIds) { - const fromCache = this.userProfileCache.get(userId); - if (fromCache) { - profiles.set(userId, fromCache); - } else { - toFetch.push(userId); - } - } - - if (toFetch.length > 0) { - const fetched = await this.userProfilesRepository.findBy({ - userId: In(toFetch), - }); - - for (const profile of fetched) { - profiles.set(profile.userId, profile); - } - - const toCache = new Map(fetched.map(p => [p.userId, p])); - this.userProfileCache.addMany(toCache); - } - - return profiles; - } - public async getUsers(userIds: Iterable): Promise> { const users = new Map; @@ -475,6 +450,61 @@ export class CacheService implements OnApplicationShutdown { return users; } + @bindThis + public async isFollowing(follower: string | { id: string }, followee: string | { id: string }): Promise { + const followerId = typeof(follower) === 'string' ? follower : follower.id; + const followeeId = typeof(followee) === 'string' ? followee : followee.id; + + // This lets us use whichever one is in memory, falling back to DB fetch via userFollowingsCache. + return this.userFollowersCache.get(followeeId)?.has(followerId) + ?? (await this.userFollowingsCache.fetch(followerId)).has(followeeId); + } + + /** + * Returns all hibernated followers. + */ + @bindThis + public async getHibernatedFollowers(followeeId: string): Promise { + const followers = await this.getFollowersWithHibernation(followeeId); + return followers.filter(f => f.isFollowerHibernated); + } + + /** + * Returns all non-hibernated followers. + */ + @bindThis + public async getNonHibernatedFollowers(followeeId: string): Promise { + const followers = await this.getFollowersWithHibernation(followeeId); + return followers.filter(f => !f.isFollowerHibernated); + } + + /** + * Returns follower relations with populated isFollowerHibernated. + * If you don't need this field, then please use userFollowersCache directly for reduced overhead. + */ + @bindThis + public async getFollowersWithHibernation(followeeId: string): Promise { + const followers = await this.userFollowersCache.fetch(followeeId); + const hibernations = await this.hibernatedUserCache.fetchMany(followers.keys()).then(fs => fs.reduce((map, f) => { + map.set(f[0], f[1]); + return map; + }, new Map)); + return Array.from(followers.values()).map(following => ({ + ...following, + isFollowerHibernated: hibernations.get(following.followerId) ?? false, + })); + } + + /** + * Refreshes follower and following relations for the given user. + */ + @bindThis + public async refreshFollowRelationsFor(userId: string): Promise { + const followings = await this.userFollowingsCache.refresh(userId); + const followees = Array.from(followings.values()).map(f => f.followeeId); + await this.userFollowersCache.deleteMany(followees); + } + @bindThis public clear(): void { this.userByIdCache.clear(); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index de35e9db19..c146811331 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -265,7 +265,7 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; - quantumCacheUpdated: { name: string, keys: string[], op: 's' | 'd' }; + quantumCacheUpdated: { name: string, keys: string[] }; } type EventTypesToEventPayload = EventUnionFromDictionary>>; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4dceb6e953..a9f4083446 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -606,11 +606,11 @@ export class NoteCreateService implements OnApplicationShutdown { } if (data.reply == null) { - // TODO: キャッシュ - this.followingsRepository.findBy({ - followeeId: user.id, - notify: 'normal', - }).then(async followings => { + this.cacheService.userFollowersCache.fetch(user.id).then(async followingsMap => { + const followings = Array + .from(followingsMap.values()) + .filter(f => f.notify === 'normal'); + if (note.visibility !== 'specified') { const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false; for (const following of followings) { @@ -948,14 +948,7 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: キャッシュ? // eslint-disable-next-line prefer-const let [followings, userListMemberships] = await Promise.all([ - this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }), + this.cacheService.getNonHibernatedFollowers(user.id), this.userListMembershipsRepository.find({ where: { userId: user.id, @@ -1072,17 +1065,19 @@ export class NoteCreateService implements OnApplicationShutdown { }); if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); + await Promise.all([ + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }), + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }), + this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])), + ]); } } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 34af1c76dd..a359381573 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -833,14 +833,7 @@ export class NoteEditService implements OnApplicationShutdown { // TODO: キャッシュ? // eslint-disable-next-line prefer-const let [followings, userListMemberships] = await Promise.all([ - this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }), + this.cacheService.getNonHibernatedFollowers(user.id), this.userListMembershipsRepository.find({ where: { userId: user.id, @@ -957,17 +950,19 @@ export class NoteEditService implements OnApplicationShutdown { }); if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); + await Promise.all([ + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }), + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }), + this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])), + ]); } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 6a6c9a3000..8470872eac 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -147,12 +147,7 @@ export class UserFollowingService implements OnModuleInit { if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); } - if (await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - })) { + if (await this.cacheService.isFollowing(follower, followee)) { // すでにフォロー関係が存在している場合 if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { // リモート → ローカル: acceptを送り返しておしまい @@ -180,24 +175,14 @@ export class UserFollowingService implements OnModuleInit { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); + const isFollowing = await this.cacheService.isFollowing(follower, followee); if (isFollowing) { autoAccept = true; } // フォローしているユーザーは自動承認オプション if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { - const isFollowed = await this.followingsRepository.exists({ - where: { - followerId: followee.id, - followeeId: follower.id, - }, - }); + const isFollowed = await this.cacheService.isFollowing(followee, follower); // intentionally reversed parameters if (isFollowed) autoAccept = true; } @@ -206,12 +191,7 @@ export class UserFollowingService implements OnModuleInit { if (followee.isLocked && !autoAccept) { autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( follower, - (oldSrc, newSrc) => this.followingsRepository.exists({ - where: { - followeeId: followee.id, - followerId: newSrc.id, - }, - }), + (oldSrc, newSrc) => this.cacheService.isFollowing(newSrc, followee), true, )); } @@ -366,32 +346,29 @@ export class UserFollowingService implements OnModuleInit { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOne({ - relations: { - follower: true, - followee: true, - }, - where: { - followerId: follower.id, - followeeId: followee.id, - }, - }); + const [ + followerUser, + followeeUser, + following, + ] = await Promise.all([ + this.cacheService.findUserById(follower.id), + this.cacheService.findUserById(followee.id), + this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)), + ]); - if (following === null || !following.follower || !following.followee) { + if (following == null) { this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } await this.followingsRepository.delete(following.id); + await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id }); - // Handled by CacheService - // this.cacheService.userFollowingsCache.refresh(follower.id); - - this.decrementFollowing(following.follower, following.followee); + this.decrementFollowing(followerUser, followeeUser); if (!silent && this.userEntityService.isLocalUser(follower)) { // Publish unfollow event - this.userEntityService.pack(followee.id, follower, { + this.userEntityService.pack(followeeUser, follower, { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); @@ -416,8 +393,6 @@ export class UserFollowingService implements OnModuleInit { follower: MiUser, followee: MiUser, ): Promise { - await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id }); - // Neither followee nor follower has moved. if (!follower.movedToUri && !followee.movedToUri) { //#region Decrement following / followers counts @@ -691,22 +666,22 @@ export class UserFollowingService implements OnModuleInit { */ @bindThis private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOne({ - relations: { - followee: true, - follower: true, - }, - where: { - followeeId: followee.id, - followerId: follower.id, - }, - }); + const [ + followerUser, + followeeUser, + following, + ] = await Promise.all([ + this.cacheService.findUserById(follower.id), + this.cacheService.findUserById(followee.id), + this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)), + ]); - if (!following || !following.followee || !following.follower) return; + if (!following) return; await this.followingsRepository.delete(following.id); + await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id }); - this.decrementFollowing(following.follower, following.followee); + this.decrementFollowing(followerUser, followeeUser); } /** @@ -737,36 +712,26 @@ export class UserFollowingService implements OnModuleInit { } @bindThis - public getFollowees(userId: MiUser['id']) { - return this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: userId }) - .getMany(); + public async getFollowees(userId: MiUser['id']) { + const followings = await this.cacheService.userFollowingsCache.fetch(userId); + return Array.from(followings.values()); } @bindThis - public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { - return this.followingsRepository.exists({ - where: { - followerId, - followeeId, - }, - }); + public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { + return this.cacheService.isFollowing(followerId, followeeId); } @bindThis public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { - const count = await this.followingsRepository.createQueryBuilder('following') - .where(new Brackets(qb => { - qb.where('following.followerId = :aUserId', { aUserId }) - .andWhere('following.followeeId = :bUserId', { bUserId }); - })) - .orWhere(new Brackets(qb => { - qb.where('following.followerId = :bUserId', { bUserId }) - .andWhere('following.followeeId = :aUserId', { aUserId }); - })) - .getCount(); - - return count === 2; + const [ + isFollowing, + isFollowed, + ] = await Promise.all([ + this.isFollowing(aUserId, bUserId), + this.isFollowing(bUserId, aUserId), + ]); + + return isFollowing && isFollowed; } } diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts index 1f471513f3..4a04910105 100644 --- a/packages/backend/src/core/UserService.ts +++ b/packages/backend/src/core/UserService.ts @@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserService { @@ -20,6 +21,7 @@ export class UserService { private followingsRepository: FollowingsRepository, private systemWebhookService: SystemWebhookService, private userEntityService: UserEntityService, + private readonly cacheService: CacheService, ) { } @@ -38,14 +40,17 @@ export class UserService { }); const wokeUp = result.isHibernated; if (wokeUp) { - this.usersRepository.update(user.id, { - isHibernated: false, - }); - this.followingsRepository.update({ - followerId: user.id, - }, { - isFollowerHibernated: false, - }); + await Promise.all([ + this.usersRepository.update(user.id, { + isHibernated: false, + }), + this.followingsRepository.update({ + followerId: user.id, + }, { + isFollowerHibernated: false, + }), + this.cacheService.hibernatedUserCache.set(user.id, false), + ]); } } else { this.usersRepository.update(user.id, { diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 30dcaa6f7d..f375dff862 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js'; import { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserSuspendService { @@ -34,6 +35,7 @@ export class UserSuspendService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, ) { } @@ -143,12 +145,8 @@ export class UserSuspendService { @bindThis private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { - followerId: follower.id, - followeeId: Not(IsNull()), - }, - }); + const followings = await this.cacheService.userFollowingsCache.fetch(follower.id) + .then(fs => Array.from(fs.values()).filter(f => f.followeeHost != null)); const jobs: RelationshipJobData[] = []; for (const following of followings) { diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 746af41f55..86747f2508 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; -import { UnrecoverableError } from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; @@ -14,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import type { IActivity } from '@/core/activitypub/type.js'; import { ThinUser } from '@/queue/types.js'; +import { CacheService } from '@/core/CacheService.js'; interface IRecipe { type: string; @@ -41,16 +41,14 @@ class DeliverManager { /** * Constructor - * @param userEntityService - * @param followingsRepository * @param queueService + * @param cacheService * @param actor Actor * @param activity Activity to deliver */ constructor( - private userEntityService: UserEntityService, - private followingsRepository: FollowingsRepository, private queueService: QueueService, + private readonly cacheService: CacheService, actor: { id: MiUser['id']; host: null; }, activity: IActivity | null, @@ -114,24 +112,23 @@ class DeliverManager { // Process follower recipes first to avoid duplication when processing direct recipes later. if (this.recipes.some(r => isFollowers(r))) { // followers deliver - // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? - const followers = await this.followingsRepository.find({ - where: { - followeeId: this.actor.id, - followerHost: Not(IsNull()), - }, - select: { - followerSharedInbox: true, - followerInbox: true, - followerId: true, - }, - }); + const followers = await this.cacheService.userFollowingsCache + .fetch(this.actor.id) + .then(f => Array + .from(f.values()) + .filter(f => f.followerHost != null) + .map(f => ({ + followerInbox: f.followerInbox, + followerSharedInbox: f.followerSharedInbox, + }))); for (const following of followers) { - const inbox = following.followerSharedInbox ?? following.followerInbox; - if (inbox === null) throw new UnrecoverableError(`deliver failed for ${this.actor.id}: follower ${following.followerId} inbox is null`); - inboxes.set(inbox, following.followerSharedInbox != null); + if (following.followerSharedInbox) { + inboxes.set(following.followerSharedInbox, true); + } else if (following.followerInbox) { + inboxes.set(following.followerInbox, false); + } } } @@ -153,11 +150,8 @@ class DeliverManager { @Injectable() export class ApDeliverManagerService { constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, private queueService: QueueService, + private readonly cacheService: CacheService, ) { } @@ -169,9 +163,8 @@ export class ApDeliverManagerService { @bindThis public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, this.queueService, + this.cacheService, actor, activity, ); @@ -188,9 +181,8 @@ export class ApDeliverManagerService { @bindThis public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise { const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, this.queueService, + this.cacheService, actor, activity, ); @@ -207,9 +199,8 @@ export class ApDeliverManagerService { @bindThis public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise { const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, this.queueService, + this.cacheService, actor, activity, ); @@ -220,9 +211,8 @@ export class ApDeliverManagerService { @bindThis public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( - this.userEntityService, - this.followingsRepository, this.queueService, + this.cacheService, actor, activity, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 7c26deb00f..009d4cbd39 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -37,6 +37,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import FederationChart from '@/core/chart/charts/federation.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; +import { CacheService } from '@/core/CacheService.js'; import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -98,6 +99,7 @@ export class ApInboxService { private readonly instanceChart: InstanceChart, private readonly federationChart: FederationChart, private readonly updateInstanceQueue: UpdateInstanceQueue, + private readonly cacheService: CacheService, ) { this.logger = this.apLoggerService.logger; } @@ -766,12 +768,7 @@ export class ApInboxService { return 'skip: follower not found'; } - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: follower.id, - followeeId: actor.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(actor.id)); if (isFollowing) { await this.userFollowingService.unfollow(follower, actor); @@ -830,12 +827,7 @@ export class ApInboxService { }, }); - const isFollowing = await this.followingsRepository.exists({ - where: { - followerId: actor.id, - followeeId: followee.id, - }, - }); + const isFollowing = await this.cacheService.userFollowingsCache.fetch(actor.id).then(f => f.has(followee.id)); if (requestExist) { await this.userFollowingService.cancelFollowRequest(followee, actor); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index b7aa036068..29f7459219 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -741,10 +741,17 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown { this.hashtagService.updateUsertags(exist, tags); // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update( - { followerId: exist.id }, - { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null }, - ); + if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) { + await this.followingsRepository.update( + { followerId: exist.id }, + { + followerInbox: person.inbox, + followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, + }, + ); + + await this.cacheService.refreshFollowRelationsFor(exist.id); + } await this.updateFeatured(exist.id, resolver).catch(err => { // Permanent error implies hidden or inaccessible, which is a normal thing. diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index b6db6f5454..4bbb5437cc 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -44,6 +44,7 @@ export default class FederationChart extends Chart { // eslint-di } protected async tickMinor(): Promise>> { + // TODO optimization: replace these with exists() const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') .where('f.followerHost IS NOT NULL'); diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 588ac638de..8d75a30e9a 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -15,6 +15,7 @@ import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-following.js'; import type { KVs } from '../core.js'; +import { CacheService } from '@/core/CacheService.js'; /** * ユーザーごとのフォローに関するチャート @@ -31,23 +32,25 @@ export default class PerUserFollowingChart extends Chart { // esl private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, + private readonly cacheService: CacheService, ) { super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { const [ - localFollowingsCount, - localFollowersCount, - remoteFollowingsCount, - remoteFollowersCount, + followees, + followers, ] = await Promise.all([ - this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }), - this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }), - this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }), - this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }), + this.cacheService.userFollowingsCache.fetch(group).then(fs => Array.from(fs.values())), + this.cacheService.userFollowersCache.fetch(group).then(fs => Array.from(fs.values())), ]); + const localFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 1 : 0), 0); + const localFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 1 : 0), 0); + const remoteFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 0 : 1), 0); + const remoteFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 0 : 1), 0); + return { 'local.followings.total': localFollowingsCount, 'local.followers.total': localFollowersCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 3af66b220d..9b447a4064 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,7 +11,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; @@ -133,7 +133,7 @@ export class NoteEntityService implements OnModuleInit { @bindThis public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: { - myFollowing?: ReadonlyMap, + myFollowing?: ReadonlyMap, myBlockers?: ReadonlySet, }): Promise { if (meId === packedNote.userId) return; @@ -416,7 +416,7 @@ export class NoteEntityService implements OnModuleInit { packedFiles: Map | null>; packedUsers: Map>; mentionHandles: Record; - userFollowings: Map>; + userFollowings: Map>>; userBlockers: Map>; polls: Map; pollVotes: Map>; @@ -659,9 +659,9 @@ export class NoteEntityService implements OnModuleInit { // mentionHandles this.getUserHandles(Array.from(mentionedUsers)), // userFollowings - this.cacheService.getUserFollowings(userIds), + this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)), // userBlockers - this.cacheService.getUserBlockers(userIds), + this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)), // polls this.pollsRepository.findBy({ noteId: In(noteIds) }) .then(polls => new Map(polls.map(p => [p.noteId, p]))), diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8ed482af6f..aecbaa7fd5 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -79,7 +79,7 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { export type UserRelation = { id: MiUser['id'] - following: MiFollowing | null, + following: Omit | null, isFollowing: boolean isFollowed: boolean hasPendingFollowRequestFromYou: boolean @@ -197,16 +197,8 @@ export class UserEntityService implements OnModuleInit { memo, mutedInstances, ] = await Promise.all([ - this.followingsRepository.findOneBy({ - followerId: me, - followeeId: target, - }), - this.followingsRepository.exists({ - where: { - followerId: target, - followeeId: me, - }, - }), + this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null), + this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)), this.followRequestsRepository.exists({ where: { followerId: me, @@ -227,8 +219,7 @@ export class UserEntityService implements OnModuleInit { .then(mutings => mutings.has(target)), this.cacheService.renoteMutingsCache.fetch(me) .then(mutings => mutings.has(target)), - this.cacheService.userByIdCache.fetch(target, () => this.usersRepository.findOneByOrFail({ id: target })) - .then(user => user.host), + this.cacheService.findUserById(target).then(u => u.host), this.userMemosRepository.createQueryBuilder('m') .select('m.memo') .where({ userId: me, targetUserId: target }) @@ -271,13 +262,8 @@ export class UserEntityService implements OnModuleInit { memos, mutedInstances, ] = await Promise.all([ - this.followingsRepository.findBy({ followerId: me }) - .then(f => new Map(f.map(it => [it.followeeId, it]))), - this.followingsRepository.createQueryBuilder('f') - .select('f.followerId') - .where('f.followeeId = :me', { me }) - .getRawMany<{ f_followerId: string }>() - .then(it => it.map(it => it.f_followerId)), + this.cacheService.userFollowingsCache.fetch(me), + this.cacheService.userFollowersCache.fetch(me), this.followRequestsRepository.createQueryBuilder('f') .select('f.followeeId') .where('f.followerId = :me', { me }) @@ -322,7 +308,7 @@ export class UserEntityService implements OnModuleInit { id: target, following: following, isFollowing: following != null, - isFollowed: followees.includes(target), + isFollowed: followees.has(target), hasPendingFollowRequestFromYou: followersRequests.includes(target), hasPendingFollowRequestToYou: followeesRequests.includes(target), isBlocking: blockees.has(target), @@ -354,7 +340,7 @@ export class UserEntityService implements OnModuleInit { return false; // TODO } - // TODO make redis calls in MULTI? + // TODO optimization: make redis calls in MULTI @bindThis public async getNotificationsInfo(userId: MiUser['id']): Promise<{ hasUnread: boolean; @@ -789,11 +775,11 @@ export class UserEntityService implements OnModuleInit { .map(user => user.host) .filter((host): host is string => host != null)); - const _profilesFromUsers: MiUserProfile[] = []; + const _profilesFromUsers: [string, MiUserProfile][] = []; const _profilesToFetch: string[] = []; for (const user of _users) { if (user.userProfile) { - _profilesFromUsers.push(user.userProfile); + _profilesFromUsers.push([user.id, user.userProfile]); } else { _profilesToFetch.push(user.id); } @@ -803,13 +789,7 @@ export class UserEntityService implements OnModuleInit { const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, pendingReceivedFollows, pendingSentFollows] = await Promise.all([ // profilesMap - this.cacheService.getUserProfiles(_profilesToFetch) - .then(profiles => { - for (const profile of _profilesFromUsers) { - profiles.set(profile.userId, profile); - } - return profiles; - }), + this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))), // userMemos isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId }) .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(), @@ -857,7 +837,7 @@ export class UserEntityService implements OnModuleInit { .groupBy('key.userId') .getRawMany<{ userId: string, userCount: number }>() .then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : new Map(), - // TODO check query performance + // TODO optimization: cache follow requests // pendingReceivedFollows isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req') .select('req.followeeId', 'followeeId') diff --git a/packages/backend/src/misc/QuantumKVCache.ts b/packages/backend/src/misc/QuantumKVCache.ts index 6b36789f5e..b96937d6f2 100644 --- a/packages/backend/src/misc/QuantumKVCache.ts +++ b/packages/backend/src/misc/QuantumKVCache.ts @@ -21,18 +21,18 @@ export interface QuantumKVOpts { fetcher: (key: string, cache: QuantumKVCache) => T | Promise; /** - * Optional callback when a value is created or changed in the cache, either locally or elsewhere in the cluster. - * This is called *after* the cache state is updated. + * Optional callback to fetch the value for multiple keys that weren't found in the cache. * May be synchronous or async. + * If not provided, then the implementation will fall back on repeated calls to fetcher(). */ - onSet?: (key: string, cache: QuantumKVCache) => void | Promise; + bulkFetcher?: (keys: string[], cache: QuantumKVCache) => Iterable<[key: string, value: T]> | Promise>; /** - * Optional callback when a value is deleted from the cache, either locally or elsewhere in the cluster. + * Optional callback when one or more values are changed (created, updated, or deleted) in the cache, either locally or elsewhere in the cluster. * This is called *after* the cache state is updated. - * May be synchronous or async. + * Implementations may be synchronous or async. */ - onDelete?: (key: string, cache: QuantumKVCache) => void | Promise; + onChanged?: (keys: string[], cache: QuantumKVCache) => void | Promise; } /** @@ -44,8 +44,8 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { private readonly memoryCache: MemoryKVCache; public readonly fetcher: QuantumKVOpts['fetcher']; - public readonly onSet: QuantumKVOpts['onSet']; - public readonly onDelete: QuantumKVOpts['onDelete']; + public readonly bulkFetcher: QuantumKVOpts['bulkFetcher']; + public readonly onChanged: QuantumKVOpts['onChanged']; /** * @param internalEventService Service bus to synchronize events. @@ -59,8 +59,8 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { ) { this.memoryCache = new MemoryKVCache(opts.lifetime); this.fetcher = opts.fetcher; - this.onSet = opts.onSet; - this.onDelete = opts.onDelete; + this.bulkFetcher = opts.bulkFetcher; + this.onChanged = opts.onChanged; this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, { // Ignore our own events, otherwise we'll immediately erase any set value. @@ -122,10 +122,10 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { this.memoryCache.set(key, value); - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: [key] }); + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] }); - if (this.onSet) { - await this.onSet(key, this); + if (this.onChanged) { + await this.onChanged([key], this); } } @@ -146,12 +146,10 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { } if (changedKeys.length > 0) { - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: changedKeys }); + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: changedKeys }); - if (this.onSet) { - for (const key of changedKeys) { - await this.onSet(key, this); - } + if (this.onChanged) { + await this.onChanged(changedKeys, this); } } } @@ -180,12 +178,26 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { /** * Gets a value from the local memory cache, or returns undefined if not found. + * Returns cached data only - does not make any fetches. */ @bindThis public get(key: string): T | undefined { return this.memoryCache.get(key); } + /** + * Gets multiple values from the local memory cache; returning undefined for any missing keys. + * Returns cached data only - does not make any fetches. + */ + @bindThis + public getMany(keys: Iterable): [key: string, value: T | undefined][] { + const results: [key: string, value: T | undefined][] = []; + for (const key of keys) { + results.push([key, this.get(key)]); + } + return results; + } + /** * Gets or fetches a value from the cache. * Fires an onSet event, but does not emit an update event to other processes. @@ -197,13 +209,49 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { value = await this.fetcher(key, this); this.memoryCache.set(key, value); - if (this.onSet) { - await this.onSet(key, this); + if (this.onChanged) { + await this.onChanged([key], this); } } return value; } + /** + * Gets or fetches multiple values from the cache. + * Fires onSet events, but does not emit any update events to other processes. + */ + @bindThis + public async fetchMany(keys: Iterable): Promise<[key: string, value: T][]> { + const results: [key: string, value: T][] = []; + const toFetch: string[] = []; + + // Spliterate into cached results / uncached keys. + for (const key of keys) { + const fromCache = this.get(key); + if (fromCache) { + results.push([key, fromCache]); + } else { + toFetch.push(key); + } + } + + // Fetch any uncached keys + if (toFetch.length > 0) { + const fetched = await this.bulkFetch(toFetch); + + // Add to cache and return set + this.addMany(fetched); + results.push(...fetched); + + // Emit event + if (this.onChanged) { + await this.onChanged(toFetch, this); + } + } + + return results; + } + /** * Returns true is a key exists in memory. * This applies to the local subset view, not the cross-cluster cache state. @@ -221,10 +269,10 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { public async delete(key: string): Promise { this.memoryCache.delete(key); - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys: [key] }); + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] }); - if (this.onDelete) { - await this.onDelete(key, this); + if (this.onChanged) { + await this.onChanged([key], this); } } /** @@ -233,21 +281,22 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { * Skips if the input is empty. */ @bindThis - public async deleteMany(keys: string[]): Promise { - if (keys.length === 0) { - return; - } + public async deleteMany(keys: Iterable): Promise { + const deleted: string[] = []; for (const key of keys) { this.memoryCache.delete(key); + deleted.push(key); } - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys }); + if (deleted.length === 0) { + return; + } - if (this.onDelete) { - for (const key of keys) { - await this.onDelete(key, this); - } + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: deleted }); + + if (this.onChanged) { + await this.onChanged(deleted, this); } } @@ -262,6 +311,13 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { return value; } + @bindThis + public async refreshMany(keys: Iterable): Promise<[key: string, value: T][]> { + const values = await this.bulkFetch(keys); + await this.setMany(values); + return values; + } + /** * Erases all entries from the local memory cache. * Does not send any events or update other processes. @@ -291,19 +347,30 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { this.memoryCache.dispose(); } + @bindThis + private async bulkFetch(keys: Iterable): Promise<[key: string, value: T][]> { + if (this.bulkFetcher) { + const results = await this.bulkFetcher(Array.from(keys), this); + return Array.from(results); + } + + const results: [key: string, value: T][] = []; + for (const key of keys) { + const value = await this.fetcher(key, this); + results.push([key, value]); + } + return results; + } + @bindThis private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise { if (data.name === this.name) { for (const key of data.keys) { this.memoryCache.delete(key); + } - if (data.op === 's' && this.onSet) { - await this.onSet(key, this); - } - - if (data.op === 'd' && this.onDelete) { - await this.onDelete(key, this); - } + if (this.onChanged) { + await this.onChanged(data.keys, this); } } } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 932c0b409a..666e684c1c 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -5,8 +5,6 @@ import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; -import { InternalEventService } from '@/core/InternalEventService.js'; -import { InternalEventTypes } from '@/core/GlobalEventService.js'; export class RedisKVCache { private readonly lifetime: number; @@ -120,9 +118,9 @@ export class RedisKVCache { export class RedisSingleCache { private readonly lifetime: number; private readonly memoryCache: MemorySingleCache; - private readonly fetcher: () => Promise; - private readonly toRedisConverter: (value: T) => string; - private readonly fromRedisConverter: (value: string) => T | undefined; + public readonly fetcher: () => Promise; + public readonly toRedisConverter: (value: T) => string; + public readonly fromRedisConverter: (value: string) => T | undefined; constructor( private redisClient: Redis.Redis, @@ -245,6 +243,16 @@ export class MemoryKVCache { return cached.value; } + public has(key: string): boolean { + const cached = this.cache.get(key); + if (cached == null) return false; + if ((Date.now() - cached.date) > this.lifetime) { + this.cache.delete(key); + return false; + } + return true; + } + @bindThis public delete(key: string): void { this.cache.delete(key); diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 6a1a8bcc66..5bf64e4f04 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -18,6 +18,7 @@ import { SearchService } from '@/core/SearchService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { QueueService } from '@/core/QueueService.js'; +import { CacheService } from '@/core/CacheService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; @@ -94,6 +95,7 @@ export class DeleteAccountProcessorService { private searchService: SearchService, private reactionService: ReactionService, private readonly apLogService: ApLogService, + private readonly cacheService: CacheService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -140,6 +142,22 @@ export class DeleteAccountProcessorService { } { // Delete user relations + await this.cacheService.refreshFollowRelationsFor(user.id); + await this.cacheService.userFollowingsCache.delete(user.id); + await this.cacheService.userFollowingsCache.delete(user.id); + await this.cacheService.userBlockingCache.delete(user.id); + await this.cacheService.userBlockedCache.delete(user.id); + await this.cacheService.userMutingsCache.delete(user.id); + await this.cacheService.userMutingsCache.delete(user.id); + await this.cacheService.hibernatedUserCache.delete(user.id); + await this.cacheService.renoteMutingsCache.delete(user.id); + await this.cacheService.userProfileCache.delete(user.id); + this.cacheService.userByIdCache.delete(user.id); + this.cacheService.localUserByIdCache.delete(user.id); + if (user.token) { + this.cacheService.localUserByNativeTokenCache.delete(user.token); + } + await this.followingsRepository.delete({ followerId: user.id, }); diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index ba146b6703..442352a4d2 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -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 { // 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 { // 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); diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index b45d21410b..3809bf29b0 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -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 { // 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 { // 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); } diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts index c953feb393..a02b51cc79 100644 --- a/packages/backend/src/server/api/endpoints/following/update-all.ts +++ b/packages/backend/src/server/api/endpoints/following/update-all.ts @@ -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 { // 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 { // eslint- withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); + await this.cacheService.refreshFollowRelationsFor(me.id); + return; }); } diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts index d62cf210ed..f4ca21856f 100644 --- a/packages/backend/src/server/api/endpoints/following/update.ts +++ b/packages/backend/src/server/api/endpoints/following/update.ts @@ -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 { // 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 { // 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 { // eslint- withReplies: ps.withReplies != null ? ps.withReplies : undefined, }); + await this.cacheService.refreshFollowRelationsFor(follower.id); + return await this.userEntityService.pack(follower.id, me); }); } diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index c1617e14e5..82ce282bfc 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -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 { // 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 { // 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); } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index c292c6d6a3..80f0b0c484 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -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 { // 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 { // 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); } diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 642d788459..52dd2197b2 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -71,6 +71,7 @@ export default class extends Endpoint { // 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 }); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 21437850d3..0ee7078eb2 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -36,7 +36,7 @@ export default class Connection { private channels = new Map(); private subscribingNotes = new Map(); public userProfile: MiUserProfile | null = null; - public following: Map = new Map(); + public following: Map> = new Map(); public followingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts index 40c5d2dc65..f3cc1e2ba2 100644 --- a/packages/backend/test/misc/noOpCaches.ts +++ b/packages/backend/test/misc/noOpCaches.ts @@ -6,13 +6,14 @@ import * as Redis from 'ioredis'; import { Inject } from '@nestjs/common'; import { FakeInternalEventService } from './FakeInternalEventService.js'; -import type { BlockingsRepository, FollowingsRepository, MiUser, MiUserProfile, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; -import { CacheService, CachedTranslationEntity, FollowStats } from '@/core/CacheService.js'; +import { CacheService, FollowStats } from '@/core/CacheService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { InternalEventService } from '@/core/InternalEventService.js'; export function noOpRedis() { return { @@ -76,55 +77,16 @@ export class NoOpCacheService extends CacheService { this.localUserByNativeTokenCache = new NoOpMemoryKVCache(); this.localUserByIdCache = new NoOpMemoryKVCache(); this.uriPersonCache = new NoOpMemoryKVCache(); - this.userProfileCache = new NoOpQuantumKVCache({ - internalEventService: fakeInternalEventService, - fetcher: this.userProfileCache.fetcher, - onSet: this.userProfileCache.onSet, - onDelete: this.userProfileCache.onDelete, - }); - this.userMutingsCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.userMutingsCache.fetcher, - onSet: this.userMutingsCache.onSet, - onDelete: this.userMutingsCache.onDelete, - }); - this.userBlockingCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.userBlockingCache.fetcher, - onSet: this.userBlockingCache.onSet, - onDelete: this.userBlockingCache.onDelete, - }); - this.userBlockedCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.userBlockedCache.fetcher, - onSet: this.userBlockedCache.onSet, - onDelete: this.userBlockedCache.onDelete, - }); - this.renoteMutingsCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.renoteMutingsCache.fetcher, - onSet: this.renoteMutingsCache.onSet, - onDelete: this.renoteMutingsCache.onDelete, - }); - this.userFollowingsCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.userFollowingsCache.fetcher, - onSet: this.userFollowingsCache.onSet, - onDelete: this.userFollowingsCache.onDelete, - }); - this.userFollowersCache = new NoOpQuantumKVCache>({ - internalEventService: fakeInternalEventService, - fetcher: this.userFollowersCache.fetcher, - onSet: this.userFollowersCache.onSet, - onDelete: this.userFollowersCache.onDelete, - }); + this.userProfileCache = NoOpQuantumKVCache.copy(this.userProfileCache, fakeInternalEventService); + this.userMutingsCache = NoOpQuantumKVCache.copy(this.userMutingsCache, fakeInternalEventService); + this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService); + this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService); + this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService); + this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService); + this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService); + this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService); this.userFollowStatsCache = new NoOpMemoryKVCache(); - this.translationsCache = new NoOpRedisKVCache({ - redis: fakeRedis, - fetcher: this.translationsCache.fetcher, - toRedisConverter: this.translationsCache.toRedisConverter, - fromRedisConverter: this.translationsCache.fromRedisConverter, - }); + this.translationsCache = NoOpRedisKVCache.copy(this.translationsCache, fakeRedis); } } @@ -159,17 +121,26 @@ export class NoOpRedisKVCache extends RedisKVCache { }, ); } + + public static copy(cache: RedisKVCache, redis?: Redis.Redis): NoOpRedisKVCache { + return new NoOpRedisKVCache({ + redis, + fetcher: cache.fetcher, + toRedisConverter: cache.toRedisConverter, + fromRedisConverter: cache.fromRedisConverter, + }); + } } export class NoOpRedisSingleCache extends RedisSingleCache { constructor(opts?: { - fakeRedis?: Redis.Redis; + redis?: Redis.Redis; fetcher?: RedisSingleCache['fetcher']; toRedisConverter?: RedisSingleCache['toRedisConverter']; fromRedisConverter?: RedisSingleCache['fromRedisConverter']; }) { super( - opts?.fakeRedis ?? noOpRedis(), + opts?.redis ?? noOpRedis(), 'no-op', { lifetime: -1, @@ -180,24 +151,37 @@ export class NoOpRedisSingleCache extends RedisSingleCache { }, ); } + + public static copy(cache: RedisSingleCache, redis?: Redis.Redis): NoOpRedisSingleCache { + return new NoOpRedisSingleCache({ + redis, + fetcher: cache.fetcher, + toRedisConverter: cache.toRedisConverter, + fromRedisConverter: cache.fromRedisConverter, + }); + } } export class NoOpQuantumKVCache extends QuantumKVCache { - constructor(opts: { - internalEventService?: FakeInternalEventService, - fetcher: QuantumKVOpts['fetcher'], - onSet?: QuantumKVOpts['onSet'], - onDelete?: QuantumKVOpts['onDelete'], + constructor(opts: Omit, 'lifetime'> & { + internalEventService?: InternalEventService, }) { super( opts.internalEventService ?? new FakeInternalEventService(), 'no-op', { + ...opts, lifetime: -1, - fetcher: opts.fetcher, - onSet: opts.onSet, - onDelete: opts.onDelete, }, ); } + + public static copy(cache: QuantumKVCache, internalEventService?: InternalEventService): NoOpQuantumKVCache { + return new NoOpQuantumKVCache({ + internalEventService, + fetcher: cache.fetcher, + bulkFetcher: cache.bulkFetcher, + onChanged: cache.onChanged, + }); + } } diff --git a/packages/backend/test/unit/misc/QuantumKVCache.ts b/packages/backend/test/unit/misc/QuantumKVCache.ts index 72997494ce..92792171be 100644 --- a/packages/backend/test/unit/misc/QuantumKVCache.ts +++ b/packages/backend/test/unit/misc/QuantumKVCache.ts @@ -73,19 +73,19 @@ describe(QuantumKVCache, () => { await cache.set('foo', 'bar'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); }); - it('should call onSet when storing', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should call onChanged when storing', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.set('foo', 'bar'); - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); it('should not emit event when storing unchanged value', async () => { @@ -97,17 +97,17 @@ describe(QuantumKVCache, () => { expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); }); - it('should not call onSet when storing unchanged value', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should not call onChanged when storing unchanged value', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.set('foo', 'bar'); await cache.set('foo', 'bar'); - expect(fakeOnSet).toHaveBeenCalledTimes(1); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); }); it('should fetch an unknown value', async () => { @@ -133,17 +133,17 @@ describe(QuantumKVCache, () => { expect(result).toBe(true); }); - it('should call onSet when fetching', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should call onChanged when fetching', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', fetcher: key => `value#${key}`, - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.fetch('foo'); - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); it('should not emit event when fetching', async () => { @@ -154,7 +154,7 @@ describe(QuantumKVCache, () => { await cache.fetch('foo'); - expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); }); it('should delete from memory cache', async () => { @@ -167,17 +167,17 @@ describe(QuantumKVCache, () => { expect(result).toBe(false); }); - it('should call onDelete when deleting', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); + it('should call onChanged when deleting', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onDelete: fakeOnDelete, + onChanged: fakeOnChanged, }); await cache.set('foo', 'bar'); await cache.delete('foo'); - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); it('should emit event when deleting', async () => { @@ -186,52 +186,52 @@ describe(QuantumKVCache, () => { await cache.set('foo', 'bar'); await cache.delete('foo'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); }); it('should delete when receiving set event', async () => { const cache = makeCache({ name: 'fake' }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); const result = cache.has('foo'); expect(result).toBe(false); }); - it('should call onSet when receiving set event', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should call onChanged when receiving set event', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); it('should delete when receiving delete event', async () => { const cache = makeCache({ name: 'fake' }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); const result = cache.has('foo'); expect(result).toBe(false); }); - it('should call onDelete when receiving delete event', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); + it('should call onChanged when receiving delete event', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onDelete: fakeOnDelete, + onChanged: fakeOnChanged, }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] }); - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); describe('get', () => { @@ -269,40 +269,243 @@ describe(QuantumKVCache, () => { await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); }); - it('should call onSet for each item', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should call onChanged once with all items', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); }); it('should emit events only for changed items', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.set('foo', 'bar'); - fakeOnSet.mockClear(); + fakeOnChanged.mockClear(); fakeInternalEventService._reset(); await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['alpha'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + }); + + describe('getMany', () => { + it('should return empty for empty input', () => { + const cache = makeCache(); + const result = cache.getMany([]); + expect(result).toEqual([]); + }); + + it('should return the value for all keys', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + const result = cache.getMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]); + }); + + it('should return undefined for missing keys', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + + const result = cache.getMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', undefined]]); + }); + }); + + describe('fetchMany', () => { + it('should do nothing for empty input', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + await cache.fetchMany([]); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should return existing items', async () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + const result = await cache.fetchMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]); + }); + + it('should return existing items without events', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + cache.add('foo', 'bar'); + cache.add('alpha', 'omega'); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should call bulkFetcher for missing items', async () => { + const cache = makeCache({ + bulkFetcher: keys => keys.map(k => [k, `${k}#many`]), + fetcher: key => `${key}#single`, + }); + + const results = await cache.fetchMany(['foo', 'alpha']); + + expect(results).toEqual([['foo', 'foo#many'], ['alpha', 'alpha#many']]); + }); + + it('should call bulkFetcher only once', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + + await cache.fetchMany(['foo', 'bar']); + + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should call fetcher when fetchMany is undefined', async () => { + const cache = makeCache({ + fetcher: key => `${key}#single`, + }); + + const results = await cache.fetchMany(['foo', 'alpha']); + + expect(results).toEqual([['foo', 'foo#single'], ['alpha', 'alpha#single']]); + }); + + it('should call onChanged', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + fetcher: k => k, + }); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should call onChanged only for changed', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + fetcher: k => k, + }); + cache.add('foo', 'bar'); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should not emit event', async () => { + const cache = makeCache({ + fetcher: k => k, + }); + + await cache.fetchMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + }); + + describe('refreshMany', () => { + it('should do nothing for empty input', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onChanged: fakeOnChanged, + }); + + const result = await cache.refreshMany([]); + + expect(result).toEqual([]); + expect(fakeOnChanged).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should call bulkFetcher for all keys', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + + const result = await cache.refreshMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]); + expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should replace any existing keys', async () => { + const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string])); + const cache = makeCache({ + bulkFetcher: mockBulkFetcher, + }); + cache.add('foo', 'bar'); + + const result = await cache.refreshMany(['foo', 'alpha']); + + expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]); + expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(mockBulkFetcher).toHaveBeenCalledTimes(1); + }); + + it('should call onChanged for all keys', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + bulkFetcher: keys => keys.map(k => [k, `${k}#value`]), + onChanged: fakeOnChanged, + }); + cache.add('foo', 'bar'); + + await cache.refreshMany(['foo', 'alpha']); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); + }); + + it('should emit event for all keys', async () => { + const cache = makeCache({ + name: 'fake', + bulkFetcher: keys => keys.map(k => [k, `${k}#value`]), + }); + cache.add('foo', 'bar'); + + await cache.refreshMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); - expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); - expect(fakeOnSet).toHaveBeenCalledTimes(1); }); }); @@ -325,33 +528,33 @@ describe(QuantumKVCache, () => { await cache.deleteMany(['foo', 'alpha']); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]); expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); }); - it('should call onDelete for each key', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); + it('should call onChanged once with all items', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onDelete: fakeOnDelete, + onChanged: fakeOnChanged, }); await cache.deleteMany(['foo', 'alpha']); - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); - expect(fakeOnDelete).toHaveBeenCalledWith('alpha', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache); + expect(fakeOnChanged).toHaveBeenCalledTimes(1); }); it('should do nothing if no keys are provided', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - onDelete: fakeOnDelete, + onChanged: fakeOnChanged, }); await cache.deleteMany([]); - expect(fakeOnDelete).not.toHaveBeenCalled(); + expect(fakeOnChanged).not.toHaveBeenCalled(); expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); }); }); @@ -392,17 +595,17 @@ describe(QuantumKVCache, () => { expect(result).toBe('value#foo'); }); - it('should call onSet', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should call onChanged', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', fetcher: key => `value#${key}`, - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); await cache.refresh('foo'); - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache); }); it('should emit event', async () => { @@ -413,7 +616,7 @@ describe(QuantumKVCache, () => { await cache.refresh('foo'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); }); }); @@ -434,15 +637,15 @@ describe(QuantumKVCache, () => { expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); }); - it('should not call onSet', () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should not call onChanged', () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); cache.add('foo', 'bar'); - expect(fakeOnSet).not.toHaveBeenCalled(); + expect(fakeOnChanged).not.toHaveBeenCalled(); }); }); @@ -466,15 +669,15 @@ describe(QuantumKVCache, () => { expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); }); - it('should not call onSet', () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); + it('should not call onChanged', () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); const cache = makeCache({ - onSet: fakeOnSet, + onChanged: fakeOnChanged, }); cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); - expect(fakeOnSet).not.toHaveBeenCalled(); + expect(fakeOnChanged).not.toHaveBeenCalled(); }); }); -- cgit v1.2.3-freya