From f585f70dcbb7d57b59eff62ccbf7d27db97e87c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 20 Sep 2024 14:36:36 +0900 Subject: [PATCH 01/46] =?UTF-8?q?Update=20CHANGELOG.md=20(=E5=9F=8B?= =?UTF-8?q?=E3=82=81=E8=BE=BC=E3=81=BF=E6=A9=9F=E8=83=BD=E3=81=AE=E3=83=89?= =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b7f4f355..65ed505c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Client - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - - 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です + - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように - Enhance: アイコンデコレーション管理画面にプレビューを追加 - Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく From 0b062f1407688906483e2427d87b708ce1a2dc47 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 20 Sep 2024 21:03:53 +0900 Subject: [PATCH 02/46] =?UTF-8?q?Misskey=C2=AE=20Reactions=20Buffering=20T?= =?UTF-8?q?echnology=E2=84=A2=20(#14579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * Update ReactionsBufferingService.ts * Update ReactionsBufferingService.ts * wip * wip * wip * Update ReactionsBufferingService.ts * wip * wip * wip * Update NoteEntityService.ts * wip * wip * wip * wip * Update CHANGELOG.md --- .config/cypress-devcontainer.yml | 8 + .config/docker_example.yml | 8 + .config/example.yml | 10 ++ .devcontainer/devcontainer.yml | 8 + CHANGELOG.md | 1 + chart/files/default.yml | 8 + locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../1726804538569-reactions-buffering.js | 16 ++ packages/backend/src/GlobalModule.ts | 14 +- packages/backend/src/config.ts | 3 + packages/backend/src/const.ts | 2 + packages/backend/src/core/CoreModule.ts | 6 + packages/backend/src/core/QueueService.ts | 6 + packages/backend/src/core/ReactionService.ts | 60 ++++--- .../src/core/ReactionsBufferingService.ts | 162 ++++++++++++++++++ .../src/core/entities/NoteEntityService.ts | 80 +++++++-- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/Meta.ts | 5 + .../backend/src/queue/QueueProcessorModule.ts | 2 + .../src/queue/QueueProcessorService.ts | 3 + .../BakeBufferedReactionsProcessorService.ts | 40 +++++ .../backend/src/server/HealthServerService.ts | 4 + .../src/server/api/endpoints/admin/meta.ts | 5 + .../server/api/endpoints/admin/update-meta.ts | 5 + .../test/unit/entities/UserEntityService.ts | 4 +- .../src/pages/admin/other-settings.vue | 72 ++++++++ .../frontend/src/pages/admin/settings.vue | 50 ------ packages/misskey-js/src/autogen/types.ts | 2 + 29 files changed, 498 insertions(+), 92 deletions(-) create mode 100644 packages/backend/migration/1726804538569-reactions-buffering.js create mode 100644 packages/backend/src/core/ReactionsBufferingService.ts create mode 100644 packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index e8da5f5e27..91dce35155 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -103,6 +103,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.config/docker_example.yml b/.config/docker_example.yml index d347882d1a..3f8e5734ce 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -106,6 +106,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index b11cbd1373..7080159117 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -172,6 +172,16 @@ redis: # # You can specify more ioredis options... # #username: example-username +#redisForReactions: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 +# # You can specify more ioredis options... +# #username: example-username + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index beefcfd0a2..3eb4fc2879 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -103,6 +103,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ed505c0e..a2d2e62a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正 ### Server +- Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に - Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように - この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます - Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 diff --git a/chart/files/default.yml b/chart/files/default.yml index f98b8ebfee..4d17131c25 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -124,6 +124,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/locales/index.d.ts b/locales/index.d.ts index 339e625684..798cb89f83 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5583,6 +5583,10 @@ export interface Locale extends ILocale { * 有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。 */ "fanoutTimelineDbFallbackDescription": string; + /** + * 有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。 + */ + "reactionsBufferingDescription": string; /** * 問い合わせ先URL */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2a5b530c9f..726e4f4ef4 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1411,6 +1411,7 @@ _serverSettings: fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" fanoutTimelineDbFallback: "データベースへのフォールバック" fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" + reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" diff --git a/packages/backend/migration/1726804538569-reactions-buffering.js b/packages/backend/migration/1726804538569-reactions-buffering.js new file mode 100644 index 0000000000..bc19e9cc8a --- /dev/null +++ b/packages/backend/migration/1726804538569-reactions-buffering.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ReactionsBuffering1726804538569 { + name = 'ReactionsBuffering1726804538569' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableReactionsBuffering" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableReactionsBuffering"`); + } +} diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 09971e8ca0..2ecc1f4742 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -78,11 +78,19 @@ const $redisForTimelines: Provider = { inject: [DI.config], }; +const $redisForReactions: Provider = { + provide: DI.redisForReactions, + useFactory: (config: Config) => { + return new Redis.Redis(config.redisForReactions); + }, + inject: [DI.config], +}; + @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], + providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], + exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -91,6 +99,7 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, + @Inject(DI.redisForReactions) private redisForReactions: Redis.Redis, ) { } public async dispose(): Promise { @@ -103,6 +112,7 @@ export class GlobalModule implements OnApplicationShutdown { this.redisForPub.disconnect(), this.redisForSub.disconnect(), this.redisForTimelines.disconnect(), + this.redisForReactions.disconnect(), ]); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index cbd6d1c086..97ba79c574 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -49,6 +49,7 @@ type Source = { redisForPubsub?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource; redisForTimelines?: RedisOptionsSource; + redisForReactions?: RedisOptionsSource; meilisearch?: { host: string; port: string; @@ -171,6 +172,7 @@ export type Config = { redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; + redisForReactions: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; sentryForFrontend: { options: Partial } | undefined; perChannelMaxNoteCacheCount: number; @@ -251,6 +253,7 @@ export function loadConfig(): Config { redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, + redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, sentryForBackend: config.sentryForBackend, sentryForFrontend: config.sentryForFrontend, id: config.id, diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index a238f4973a..e3a61861f4 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -8,6 +8,8 @@ export const MAX_NOTE_TEXT_LENGTH = 3000; export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days +export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; + //#region hard limits // If you change DB_* values, you must also change the DB schema. diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 674241ac12..3b3c35f976 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -50,6 +50,7 @@ import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; import { QueryService } from './QueryService.js'; import { ReactionService } from './ReactionService.js'; +import { ReactionsBufferingService } from './ReactionsBufferingService.js'; import { RelayService } from './RelayService.js'; import { RoleService } from './RoleService.js'; import { S3Service } from './S3Service.js'; @@ -193,6 +194,7 @@ const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExis const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; +const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingService', useExisting: ReactionsBufferingService }; const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; @@ -342,6 +344,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -487,6 +490,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, @@ -633,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -777,6 +782,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index ddb90a051f..f35e456556 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -87,6 +87,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('bakeBufferedReactions', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); } @bindThis diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 371207c33a..5993c42a1f 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -30,9 +29,10 @@ import { RoleService } from '@/core/RoleService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; const FALLBACK = '\u2764'; -const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; const legacies: Record = { 'like': '👍', @@ -71,9 +71,6 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; @Injectable() export class ReactionService { constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -93,6 +90,7 @@ export class ReactionService { private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, + private reactionsBufferingService: ReactionsBufferingService, private idService: IdService, private featuredService: FeaturedService, private globalEventService: GlobalEventService, @@ -174,7 +172,6 @@ export class ReactionService { reaction, }; - // Create reaction try { await this.noteReactionsRepository.insert(record); } catch (e) { @@ -198,16 +195,25 @@ export class ReactionService { } // Increment reactions count - const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; - await this.notesRepository.createQueryBuilder().update() - .set({ - reactions: () => sql, - ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { - reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, - } : {}), - }) - .where('id = :id', { id: note.id }) - .execute(); + if (meta.enableReactionsBuffering) { + await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache); + + // for debugging + if (reaction === ':angry_ai:') { + this.reactionsBufferingService.bake(); + } + } else { + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { + reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, + } : {}), + }) + .where('id = :id', { id: note.id }) + .execute(); + } // 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新 if ( @@ -304,15 +310,21 @@ export class ReactionService { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } + const meta = await this.metaService.fetch(); + // Decrement reactions count - const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; - await this.notesRepository.createQueryBuilder().update() - .set({ - reactions: () => sql, - reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, - }) - .where('id = :id', { id: note.id }) - .execute(); + if (meta.enableReactionsBuffering) { + await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction); + } else { + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, + }) + .where('id = :id', { id: note.id }) + .execute(); + } this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, diff --git a/packages/backend/src/core/ReactionsBufferingService.ts b/packages/backend/src/core/ReactionsBufferingService.ts new file mode 100644 index 0000000000..b1a197feeb --- /dev/null +++ b/packages/backend/src/core/ReactionsBufferingService.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { MiNote } from '@/models/Note.js'; +import { bindThis } from '@/decorators.js'; +import type { MiUser, NotesRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; + +const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas'; +const REDIS_PAIR_PREFIX = 'reactionsBufferPairs'; + +@Injectable() +export class ReactionsBufferingService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redisForReactions) + private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + ) { + } + + @bindThis + public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1); + for (let i = 0; i < currentPairs.length; i++) { + pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]); + } + pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`); + pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1)); + await pipeline.exec(); + } + + @bindThis + public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1); + pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`); + // TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する + await pipeline.exec(); + } + + @bindThis + public async get(noteId: MiNote['id']): Promise<{ + deltas: Record; + pairs: ([MiUser['id'], string])[]; + }> { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); + const results = await pipeline.exec(); + + const resultDeltas = results![0][1] as Record; + const resultPairs = results![1][1] as string[]; + + const deltas = {} as Record; + for (const [name, count] of Object.entries(resultDeltas)) { + deltas[name] = parseInt(count); + } + + const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); + + return { + deltas, + pairs, + }; + } + + @bindThis + public async getMany(noteIds: MiNote['id'][]): Promise; + pairs: ([MiUser['id'], string])[]; + }>> { + const map = new Map; + pairs: ([MiUser['id'], string])[]; + }>(); + + const pipeline = this.redisForReactions.pipeline(); + for (const noteId of noteIds) { + pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); + } + const results = await pipeline.exec(); + + const opsForEachNotes = 2; + for (let i = 0; i < noteIds.length; i++) { + const noteId = noteIds[i]; + const resultDeltas = results![i * opsForEachNotes][1] as Record; + const resultPairs = results![i * opsForEachNotes + 1][1] as string[]; + + const deltas = {} as Record; + for (const [name, count] of Object.entries(resultDeltas)) { + deltas[name] = parseInt(count); + } + + const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); + + map.set(noteId, { + deltas, + pairs, + }); + } + + return map; + } + + // TODO: scanは重い可能性があるので、別途 bufferedNoteIds を直接Redis上に持っておいてもいいかもしれない + @bindThis + public async bake(): Promise { + const bufferedNoteIds = []; + let cursor = '0'; + do { + // https://github.com/redis/ioredis#transparent-key-prefixing + const result = await this.redisForReactions.scan( + cursor, + 'MATCH', + `${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:*`, + 'COUNT', + '1000'); + + cursor = result[0]; + bufferedNoteIds.push(...result[1].map(x => x.replace(`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:`, ''))); + } while (cursor !== '0'); + + const bufferedMap = await this.getMany(bufferedNoteIds); + + // clear + const pipeline = this.redisForReactions.pipeline(); + for (const noteId of bufferedNoteIds) { + pipeline.del(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.del(`${REDIS_PAIR_PREFIX}:${noteId}`); + } + await pipeline.exec(); + + // TODO: SQL一個にまとめたい + for (const [noteId, buffered] of bufferedMap) { + const sql = Object.entries(buffered.deltas) + .map(([reaction, count]) => + `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + ${count})::text::jsonb)`) + .join(' || '); + + this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + reactionAndUserPairCache: buffered.pairs.map(x => x.join('/')), + }) + .where('id = :id', { id: noteId }) + .execute(); + } + } +} diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2cd092231c..7506d804c3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,24 +11,39 @@ 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 { MiNoteReaction } from '@/models/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { MetaService } from '@/core/MetaService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; +function mergeReactions(src: Record, delta: Record) { + const reactions = { ...src }; + for (const [name, count] of Object.entries(delta)) { + if (reactions[name] != null) { + reactions[name] += count; + } else { + reactions[name] = count; + } + } + return reactions; +} + @Injectable() export class NoteEntityService implements OnModuleInit { private userEntityService: UserEntityService; private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; + private reactionsBufferingService: ReactionsBufferingService; private idService: IdService; + private metaService: MetaService; private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( @@ -59,6 +74,9 @@ export class NoteEntityService implements OnModuleInit { //private driveFileEntityService: DriveFileEntityService, //private customEmojiService: CustomEmojiService, //private reactionService: ReactionService, + //private reactionsBufferingService: ReactionsBufferingService, + //private idService: IdService, + //private metaService: MetaService, ) { } @@ -67,7 +85,9 @@ export class NoteEntityService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.reactionService = this.moduleRef.get('ReactionService'); + this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService'); this.idService = this.moduleRef.get('IdService'); + this.metaService = this.moduleRef.get('MetaService'); } @bindThis @@ -287,6 +307,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; withReactionAndUserPairCache?: boolean; _hint_?: { + bufferdReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; packedUsers: Map> @@ -303,6 +324,16 @@ export class NoteEntityService implements OnModuleInit { const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const host = note.userHost; + const bufferdReactions = opts._hint_?.bufferdReactions != null ? (opts._hint_.bufferdReactions.get(note.id) ?? { deltas: {}, pairs: [] }) : await this.reactionsBufferingService.get(note.id); + const reactions = mergeReactions(note.reactions, bufferdReactions.deltas ?? {}); + for (const [name, count] of Object.entries(reactions)) { + if (count <= 0) { + delete reactions[name]; + } + } + + const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferdReactions.pairs.map(x => x.join('/'))); + let text = note.text; if (note.name && (note.url ?? note.uri)) { @@ -315,7 +346,7 @@ export class NoteEntityService implements OnModuleInit { : await this.channelsRepository.findOneBy({ id: note.channelId }) : null; - const reactionEmojiNames = Object.keys(note.reactions) + const reactionEmojiNames = Object.keys(reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packedFiles = options?._hint_?.packedFiles; @@ -334,10 +365,10 @@ export class NoteEntityService implements OnModuleInit { visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, - reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), - reactions: this.reactionService.convertLegacyReactions(note.reactions), + reactionCount: Object.values(reactions).reduce((a, b) => a + b, 0), + reactions: reactions, reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), - reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, + reactionAndUserPairCache: opts.withReactionAndUserPairCache ? reactionAndUserPairCache : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, @@ -376,8 +407,12 @@ export class NoteEntityService implements OnModuleInit { poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, - ...(meId && Object.keys(note.reactions).length > 0 ? { - myReaction: this.populateMyReaction(note, meId, options?._hint_), + ...(meId && Object.keys(reactions).length > 0 ? { + myReaction: this.populateMyReaction({ + id: note.id, + reactions: reactions, + reactionAndUserPairCache: reactionAndUserPairCache, + }, meId, options?._hint_), } : {}), } : {}), }); @@ -400,6 +435,10 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; + const meta = await this.metaService.fetch(); + + const bufferdReactions = meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; + const meId = me ? me.id : null; const myReactionsMap = new Map(); if (meId) { @@ -410,23 +449,33 @@ export class NoteEntityService implements OnModuleInit { for (const note of notes) { if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote - const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0); + const reactionsCount = Object.values(mergeReactions(note.renote.reactions, bufferdReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.renote.id, null); - } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) { - const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); + } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferdReactions?.get(note.renote.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferdReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.renote.id, pairInBuffer[1]); + } else { + const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); + } } else { idsNeedFetchMyReaction.add(note.renote.id); } } else { if (note.id < oldId) { - const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); + const reactionsCount = Object.values(mergeReactions(note.reactions, bufferdReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length) { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferdReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferdReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.id, pairInBuffer[1]); + } else { + const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } } else { idsNeedFetchMyReaction.add(note.id); } @@ -461,6 +510,7 @@ export class NoteEntityService implements OnModuleInit { return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { + bufferdReactions, myReactions: myReactionsMap, packedFiles, packedUsers, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 271082b4ff..b6f003c2e6 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -11,6 +11,7 @@ export const DI = { redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), redisForTimelines: Symbol('redisForTimelines'), + redisForReactions: Symbol('redisForReactions'), //#region Repositories usersRepository: Symbol('usersRepository'), diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 70d41801b5..9ab76d373f 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -589,6 +589,11 @@ export class MiMeta { }) public perUserListTimelineCacheMax: number; + @Column('boolean', { + default: false, + }) + public enableReactionsBuffering: boolean; + @Column('integer', { default: 0, }) diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index a1fd38fcc5..0027b5ef3d 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -14,6 +14,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; @@ -51,6 +52,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + BakeBufferedReactionsProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7bd74f3210..e9e1c45224 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -39,6 +39,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; @@ -118,6 +119,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -147,6 +149,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } diff --git a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts new file mode 100644 index 0000000000..cd56ba9837 --- /dev/null +++ b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; + +@Injectable() +export class BakeBufferedReactionsProcessorService { + private logger: Logger; + + constructor( + private reactionsBufferingService: ReactionsBufferingService, + private metaService: MetaService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('bake-buffered-reactions'); + } + + @bindThis + public async process(): Promise { + const meta = await this.metaService.fetch(); + if (!meta.enableReactionsBuffering) { + this.logger.info('Reactions buffering is disabled. Skipping...'); + return; + } + + this.logger.info('Baking buffered reactions...'); + + await this.reactionsBufferingService.bake(); + + this.logger.succ('All buffered reactions baked.'); + } +} diff --git a/packages/backend/src/server/HealthServerService.ts b/packages/backend/src/server/HealthServerService.ts index 2c3ed85925..5980609f02 100644 --- a/packages/backend/src/server/HealthServerService.ts +++ b/packages/backend/src/server/HealthServerService.ts @@ -27,6 +27,9 @@ export class HealthServerService { @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, + @Inject(DI.redisForReactions) + private redisForReactions: Redis.Redis, + @Inject(DI.db) private db: DataSource, @@ -43,6 +46,7 @@ export class HealthServerService { this.redisForPub.ping(), this.redisForSub.ping(), this.redisForTimelines.ping(), + this.redisForReactions.ping(), this.db.query('SELECT 1'), ...(this.meilisearch ? [this.meilisearch.health()] : []), ]).then(() => 200, () => 503)); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2e7f73da73..29e8bfaf14 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -377,6 +377,10 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + enableReactionsBuffering: { + type: 'boolean', + optional: false, nullable: false, + }, notesPerOneAd: { type: 'number', optional: false, nullable: false, @@ -617,6 +621,7 @@ export default class extends Endpoint { // eslint- perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, + enableReactionsBuffering: instance.enableReactionsBuffering, notesPerOneAd: instance.notesPerOneAd, summalyProxy: instance.urlPreviewSummaryProxyUrl, urlPreviewEnabled: instance.urlPreviewEnabled, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 5efdc9d8c4..865e73f274 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -142,6 +142,7 @@ export const paramDef = { perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, perUserListTimelineCacheMax: { type: 'integer' }, + enableReactionsBuffering: { type: 'boolean' }, notesPerOneAd: { type: 'integer' }, silencedHosts: { type: 'array', @@ -598,6 +599,10 @@ export default class extends Endpoint { // eslint- set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; } + if (ps.enableReactionsBuffering !== undefined) { + set.enableReactionsBuffering = ps.enableReactionsBuffering; + } + if (ps.notesPerOneAd !== undefined) { set.notesPerOneAd = ps.notesPerOneAd; } diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ee16d421c4..e4f42809f8 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -4,10 +4,10 @@ */ import { Test, TestingModule } from '@nestjs/testing'; +import type { MiUser } from '@/models/User.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; -import type { MiUser } from '@/models/User.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { genAidx } from '@/misc/id/aidx.js'; import { @@ -49,6 +49,7 @@ import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; process.env.NODE_ENV = 'test'; @@ -169,6 +170,7 @@ describe('UserEntityService', () => { ApLoggerService, AccountMoveService, ReactionService, + ReactionsBufferingService, NotificationService, ]; diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue index 345cf333b5..0163daf1ba 100644 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -36,6 +36,55 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
+ + + + +
+
@@ -52,11 +101,20 @@ import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkInput from '@/components/MkInput.vue'; const enableServerMachineStats = ref(false); const enableIdenticonGeneration = ref(false); const enableChartsForRemoteUser = ref(false); const enableChartsForFederatedInstances = ref(false); +const enableFanoutTimeline = ref(false); +const enableFanoutTimelineDbFallback = ref(false); +const perLocalUserUserTimelineCacheMax = ref(0); +const perRemoteUserUserTimelineCacheMax = ref(0); +const perUserHomeTimelineCacheMax = ref(0); +const perUserListTimelineCacheMax = ref(0); +const enableReactionsBuffering = ref(false); async function init() { const meta = await misskeyApi('admin/meta'); @@ -64,6 +122,13 @@ async function init() { enableIdenticonGeneration.value = meta.enableIdenticonGeneration; enableChartsForRemoteUser.value = meta.enableChartsForRemoteUser; enableChartsForFederatedInstances.value = meta.enableChartsForFederatedInstances; + enableFanoutTimeline.value = meta.enableFanoutTimeline; + enableFanoutTimelineDbFallback.value = meta.enableFanoutTimelineDbFallback; + perLocalUserUserTimelineCacheMax.value = meta.perLocalUserUserTimelineCacheMax; + perRemoteUserUserTimelineCacheMax.value = meta.perRemoteUserUserTimelineCacheMax; + perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax; + perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax; + enableReactionsBuffering.value = meta.enableReactionsBuffering; } function save() { @@ -72,6 +137,13 @@ function save() { enableIdenticonGeneration: enableIdenticonGeneration.value, enableChartsForRemoteUser: enableChartsForRemoteUser.value, enableChartsForFederatedInstances: enableChartsForFederatedInstances.value, + enableFanoutTimeline: enableFanoutTimeline.value, + enableFanoutTimelineDbFallback: enableFanoutTimelineDbFallback.value, + perLocalUserUserTimelineCacheMax: perLocalUserUserTimelineCacheMax.value, + perRemoteUserUserTimelineCacheMax: perRemoteUserUserTimelineCacheMax.value, + perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value, + perUserListTimelineCacheMax: perUserListTimelineCacheMax.value, + enableReactionsBuffering: enableReactionsBuffering.value, }).then(() => { fetchInstance(true); }); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 6f45c212ec..ffff57b454 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -96,38 +96,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
- @@ -236,12 +204,6 @@ const cacheRemoteSensitiveFiles = ref(false); const enableServiceWorker = ref(false); const swPublicKey = ref(null); const swPrivateKey = ref(null); -const enableFanoutTimeline = ref(false); -const enableFanoutTimelineDbFallback = ref(false); -const perLocalUserUserTimelineCacheMax = ref(0); -const perRemoteUserUserTimelineCacheMax = ref(0); -const perUserHomeTimelineCacheMax = ref(0); -const perUserListTimelineCacheMax = ref(0); const notesPerOneAd = ref(0); const urlPreviewEnabled = ref(true); const urlPreviewTimeout = ref(10000); @@ -265,12 +227,6 @@ async function init(): Promise { enableServiceWorker.value = meta.enableServiceWorker; swPublicKey.value = meta.swPublickey; swPrivateKey.value = meta.swPrivateKey; - enableFanoutTimeline.value = meta.enableFanoutTimeline; - enableFanoutTimelineDbFallback.value = meta.enableFanoutTimelineDbFallback; - perLocalUserUserTimelineCacheMax.value = meta.perLocalUserUserTimelineCacheMax; - perRemoteUserUserTimelineCacheMax.value = meta.perRemoteUserUserTimelineCacheMax; - perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax; - perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax; notesPerOneAd.value = meta.notesPerOneAd; urlPreviewEnabled.value = meta.urlPreviewEnabled; urlPreviewTimeout.value = meta.urlPreviewTimeout; @@ -295,12 +251,6 @@ async function save() { enableServiceWorker: enableServiceWorker.value, swPublicKey: swPublicKey.value, swPrivateKey: swPrivateKey.value, - enableFanoutTimeline: enableFanoutTimeline.value, - enableFanoutTimelineDbFallback: enableFanoutTimelineDbFallback.value, - perLocalUserUserTimelineCacheMax: perLocalUserUserTimelineCacheMax.value, - perRemoteUserUserTimelineCacheMax: perRemoteUserUserTimelineCacheMax.value, - perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value, - perUserListTimelineCacheMax: perUserListTimelineCacheMax.value, notesPerOneAd: notesPerOneAd.value, urlPreviewEnabled: urlPreviewEnabled.value, urlPreviewTimeout: urlPreviewTimeout.value, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 03828b6552..672d75e267 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5125,6 +5125,7 @@ export type operations = { perRemoteUserUserTimelineCacheMax: number; perUserHomeTimelineCacheMax: number; perUserListTimelineCacheMax: number; + enableReactionsBuffering: boolean; notesPerOneAd: number; backgroundImageUrl: string | null; deeplAuthKey: string | null; @@ -9395,6 +9396,7 @@ export type operations = { perRemoteUserUserTimelineCacheMax?: number; perUserHomeTimelineCacheMax?: number; perUserListTimelineCacheMax?: number; + enableReactionsBuffering?: boolean; notesPerOneAd?: number; silencedHosts?: string[] | null; mediaSilencedHosts?: string[] | null; From f0834ca14c75df429f7d8524f24bc4749639032a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 20 Sep 2024 21:04:58 +0900 Subject: [PATCH 03/46] =?UTF-8?q?enhance:=20=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=B3=E3=83=86=E3=83=B3=E3=83=84=E3=81=AE?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=81=AE=E5=AE=9F=E8=A1=8C=E5=8F=AF=E5=90=A6=E3=82=92=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=A7=E5=88=B6=E5=BE=A1=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#14583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: インポート操作の実行可否をロールで制御できるように * Update Changelog --- CHANGELOG.md | 1 + locales/index.d.ts | 20 ++++ locales/ja-JP.yml | 5 + packages/backend/src/core/RoleService.ts | 15 +++ .../backend/src/models/json-schema/role.ts | 20 ++++ .../server/api/endpoints/i/import-antennas.ts | 1 + .../server/api/endpoints/i/import-blocking.ts | 1 + .../api/endpoints/i/import-following.ts | 1 + .../server/api/endpoints/i/import-muting.ts | 1 + .../api/endpoints/i/import-user-lists.ts | 1 + packages/frontend-shared/js/const.ts | 5 + .../frontend/src/pages/admin/roles.editor.vue | 100 ++++++++++++++++++ packages/frontend/src/pages/admin/roles.vue | 40 +++++++ .../src/pages/settings/import-export.vue | 10 +- packages/misskey-js/src/autogen/types.ts | 5 + 15 files changed, 221 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d2e62a62..cc8f9c5081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### General - Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) +- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように ### Client - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 diff --git a/locales/index.d.ts b/locales/index.d.ts index 798cb89f83..f234262195 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6766,6 +6766,26 @@ export interface Locale extends ILocale { * アイコンデコレーションの最大取付個数 */ "avatarDecorationLimit": string; + /** + * アンテナのインポートを許可 + */ + "canImportAntennas": string; + /** + * ブロックのインポートを許可 + */ + "canImportBlocking": string; + /** + * フォローのインポートを許可 + */ + "canImportFollowing": string; + /** + * ミュートのインポートを許可 + */ + "canImportMuting": string; + /** + * リストのインポートを許可 + */ + "canImportUserLists": string; }; "_condition": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 726e4f4ef4..8e48508e78 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1748,6 +1748,11 @@ _role: canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" + canImportAntennas: "アンテナのインポートを許可" + canImportBlocking: "ブロックのインポートを許可" + canImportFollowing: "フォローのインポートを許可" + canImportMuting: "ミュートのインポートを許可" + canImportUserLists: "リストのインポートを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 0210012a03..24752edcf6 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -58,6 +58,11 @@ export type RolePolicies = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + canImportAntennas: boolean; + canImportBlocking: boolean; + canImportFollowing: boolean; + canImportMuting: boolean; + canImportUserLists: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -87,6 +92,11 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, + canImportAntennas: true, + canImportBlocking: true, + canImportFollowing: true, + canImportMuting: true, + canImportUserLists: true, }; @Injectable() @@ -387,6 +397,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), + canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)), + canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)), + canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), + canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), + canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7366f05356..3537de94c8 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -272,6 +272,26 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + canImportAntennas: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportBlocking: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportFollowing: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportMuting: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportUserLists: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index bc46163e3d..bdf6c065e8 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -16,6 +16,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportAntennas', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 2606108539..d7bb6bcd22 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportBlocking', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index d5e824df27..e03192d8c6 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportFollowing', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 0f5800404e..76b285bb7e 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportMuting', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index bacdd5c88f..76ecfd082c 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportUserLists', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 8391fb638c..b62a69ba24 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -98,6 +98,11 @@ export const ROLE_POLICIES = [ 'userEachUserListsLimit', 'rateLimitFactor', 'avatarDecorationLimit', + 'canImportAntennas', + 'canImportBlocking', + 'canImportFollowing', + 'canImportMuting', + 'canImportUserLists', ] as const; // なんか動かない diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index b0137abb3f..ae01432d0c 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -590,6 +590,106 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 7e29f6e0d8..511e3c0fdf 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -214,6 +214,46 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ i18n.ts.save }} diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index 9bb3957a84..5acbc50756 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -108,7 +108,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 672d75e267..5d5bc52956 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4822,6 +4822,11 @@ export type components = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + canImportAntennas: boolean; + canImportBlocking: boolean; + canImportFollowing: boolean; + canImportMuting: boolean; + canImportUserLists: boolean; }; ReversiGameLite: { /** Format: id */ From 7e9d54fa3a0f32e3ed9b98e352b74ebb720b5ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 20 Sep 2024 21:05:20 +0900 Subject: [PATCH 04/46] =?UTF-8?q?fix(frontend):=20=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=81=AE=E8=A9=B3=E7=B4=B0=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E8=AA=AC=E6=98=8E=E3=81=A7=E6=94=B9=E8=A1=8C=E3=81=8C=E6=AD=A3?= =?UTF-8?q?=E3=81=97=E3=81=8F=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20(#1458?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * upd: don't ignore new lines on file info * Update Changelog * :v: --------- Co-authored-by: Marie --- CHANGELOG.md | 2 ++ packages/frontend/src/pages/drive.file.info.vue | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8f9c5081..76c4e851df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725) - Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正 +- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110) ### Server - Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index ffedaf27bf..12ebbbe3ff 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only