merge: Implement new Background Task queue (!1241)

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

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Marie 2025-11-15 22:50:58 +01:00
commit 8c196d5cb2
229 changed files with 3209 additions and 1384 deletions

View file

@ -253,21 +253,42 @@ id: 'aidx'
# Number of worker processes # Number of worker processes
#clusterLimit: 1 #clusterLimit: 1
# +-------------------------+
# | Job concurrency options |
# +-------------------------+
#
### Available options:
# [type]JobConcurrency - limits the number jobs that can run at the same time.
# Sharkey will allow this many jobs of this type *per worker process*.
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
#
### Job types:
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
# All inbound activities are queued and processed in chronological order by this job.
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
# All outbound activities are queued and processed in chronological order by this job.
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 #deliverJobConcurrency: 128
# inboxJobConcurrency: 16 #inboxJobConcurrency: 16
# relashionshipJobConcurrency: 16 #relationshipJobConcurrency: 16
# What's relashionshipJob?: #backgroundJobConcurrency: 32
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 #deliverJobPerSec: 128
# inboxJobPerSec: 32 #inboxJobPerSec: 32
# relashionshipJobPerSec: 64 #relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 #deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8 #inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests # Local address used for outgoing requests
#outgoingAddress: 127.0.0.1 #outgoingAddress: 127.0.0.1

View file

@ -223,17 +223,42 @@ id: 'aidx'
# Number of worker processes # Number of worker processes
#clusterLimit: 1 #clusterLimit: 1
# +-------------------------+
# | Job concurrency options |
# +-------------------------+
#
### Available options:
# [type]JobConcurrency - limits the number jobs that can run at the same time.
# Sharkey will allow this many jobs of this type *per worker process*.
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
#
### Job types:
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
# All inbound activities are queued and processed in chronological order by this job.
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
# All outbound activities are queued and processed in chronological order by this job.
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 #deliverJobConcurrency: 128
# inboxJobConcurrency: 16 #inboxJobConcurrency: 16
#relationshipJobConcurrency: 16
#backgroundJobConcurrency: 32
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 #deliverJobPerSec: 128
# inboxJobPerSec: 32 #inboxJobPerSec: 32
#relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 #deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8 #inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# IP address family used for outgoing request (ipv4, ipv6 or dual) # IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4 #outgoingAddressFamily: ipv4

View file

@ -307,21 +307,42 @@ id: 'aidx'
# Number of worker processes # Number of worker processes
#clusterLimit: 1 #clusterLimit: 1
# +-------------------------+
# | Job concurrency options |
# +-------------------------+
#
### Available options:
# [type]JobConcurrency - limits the number jobs that can run at the same time.
# Sharkey will allow this many jobs of this type *per worker process*.
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
#
### Job types:
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
# All inbound activities are queued and processed in chronological order by this job.
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
# All outbound activities are queued and processed in chronological order by this job.
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
# Job concurrency per worker # Job concurrency per worker
#deliverJobConcurrency: 128 #deliverJobConcurrency: 128
#inboxJobConcurrency: 16 #inboxJobConcurrency: 16
#relationshipJobConcurrency: 16 #relationshipJobConcurrency: 16
# What's relationshipJob?: #backgroundJobConcurrency: 32
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter # Job rate limiter
#deliverJobPerSec: 128 #deliverJobPerSec: 128
#inboxJobPerSec: 32 #inboxJobPerSec: 32
#relationshipJobPerSec: 64 #relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts # Job attempts
#deliverJobMaxAttempts: 12 #deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8 #inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests # Local address used for outgoing requests
#outgoingAddress: 127.0.0.1 #outgoingAddress: 127.0.0.1

View file

@ -310,21 +310,42 @@ id: 'aidx'
# Number of worker processes # Number of worker processes
#clusterLimit: 1 #clusterLimit: 1
# +-------------------------+
# | Job concurrency options |
# +-------------------------+
#
### Available options:
# [type]JobConcurrency - limits the number jobs that can run at the same time.
# Sharkey will allow this many jobs of this type *per worker process*.
# [type]JobPerSec - limits the total number of jobs that may complete within a single second.
# If this limit is exceeded, then Sharkey will pause this type of job until the next second.
# [type]JobMaxAttempts - limits the number of times that a job is allowed to fail and re-try before it's permanently stopped.
# If this limit is exceeded, then the job is considered "failed" and recorded for debugging.
#
### Job types:
# inbox - processes ActivityPub messages (AKA "Activities") received from remote instances.
# All inbound activities are queued and processed in chronological order by this job.
# deliver - processes ActivityPub messages (AKA "Activities") being set to remote instances.
# All outbound activities are queued and processed in chronological order by this job.
# relationship - processes user-to-user tasks including follow/unfollow, block/unblock, account migrations, and all follow import jobs.
# background - processes background synchronization tasks that need to happen soon (but not immediately), such as remote user updates and instance metadata updates.
# Job concurrency per worker # Job concurrency per worker
#deliverJobConcurrency: 128 #deliverJobConcurrency: 128
#inboxJobConcurrency: 16 #inboxJobConcurrency: 16
#relationshipJobConcurrency: 16 #relationshipJobConcurrency: 16
# What's relationshipJob?: #backgroundJobConcurrency: 32
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter # Job rate limiter
#deliverJobPerSec: 128 #deliverJobPerSec: 128
#inboxJobPerSec: 32 #inboxJobPerSec: 32
#relationshipJobPerSec: 64 #relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts # Job attempts
#deliverJobMaxAttempts: 12 #deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8 #inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests # Local address used for outgoing requests
#outgoingAddress: 127.0.0.1 #outgoingAddress: 127.0.0.1

4
locales/index.d.ts vendored
View file

@ -13669,6 +13669,10 @@ export interface Locale extends ILocale {
* Are you sure you want to restart this account migration? * Are you sure you want to restart this account migration?
*/ */
"restartMigrationConfirm": string; "restartMigrationConfirm": string;
/**
* Background queue
*/
"backgroundQueue": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -4,6 +4,8 @@
*/ */
export class FixIDXInstanceHostKey1748990662839 { export class FixIDXInstanceHostKey1748990662839 {
name = 'FixIDXInstanceHostKey1748990662839';
async up(queryRunner) { async up(queryRunner) {
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html // must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`); await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);

View file

@ -4,6 +4,8 @@
*/ */
export class CreateIDXNoteForTimelines1748991828473 { export class CreateIDXNoteForTimelines1748991828473 {
name = 'CreateIDXNoteForTimelines1748991828473';
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query(` await queryRunner.query(`
create index "IDX_note_for_timelines" create index "IDX_note_for_timelines"

View file

@ -4,6 +4,8 @@
*/ */
export class CreateIDXInstanceHostFilters1748992017688 { export class CreateIDXInstanceHostFilters1748992017688 {
name = 'CreateIDXInstanceHostFilters1748992017688';
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query(` await queryRunner.query(`
create index "IDX_instance_host_filters" create index "IDX_instance_host_filters"

View file

@ -4,6 +4,8 @@
*/ */
export class CreateStatistics1748992128683 { export class CreateStatistics1748992128683 {
name = 'CreateStatistics1748992128683';
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`); await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`); await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);

View file

@ -4,6 +4,8 @@
*/ */
export class FixIDXNoteForTimeline1749097536193 { export class FixIDXNoteForTimeline1749097536193 {
name = 'FixIDXNoteForTimeline1749097536193';
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query('drop index "IDX_note_for_timelines"'); await queryRunner.query('drop index "IDX_note_for_timelines"');
await queryRunner.query(` await queryRunner.query(`

View file

@ -4,6 +4,8 @@
*/ */
export class RemoveIDXInstanceHostFilters1749267016885 { export class RemoveIDXInstanceHostFilters1749267016885 {
name = 'RemoveIDXInstanceHostFilters1749267016885';
async up(queryRunner) { async up(queryRunner) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`); await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
} }

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
// https://www.cybertec-postgresql.com/en/hot-updates-in-postgresql-for-better-performance/
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
*/
/**
* @class
* @implements {MigrationInterface}
*/
export class EnableInstanceHOTUpdates1750217001651 {
name = 'EnableInstanceHOTUpdates1750217001651';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" SET (fillfactor = 50)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" SET (fillfactor = 100)`);
}
}

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MoreNoteEditColumns1750353421706 {
name = 'MoreNoteEditColumns1750353421706'
async up(queryRunner) {
// Update column types
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "cw" TYPE text USING "cw"::text`);
// Rename columns
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "oldText" TO "text"`);
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "cw" TO "newCw"`);
// Add new fields
await queryRunner.query(`ALTER TABLE "note_edit" ADD "userId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."userId" IS 'The ID of author.'`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_7f1ded0f6e8a5bef701b7e698ab" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD "renoteId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."renoteId" IS 'The ID of renote target. Will always be null for older edits'`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_d3003e5256bcbfad6c3588835c0" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD "replyId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."replyId" IS 'The ID of reply target. Will always be null for older edits'`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD CONSTRAINT "FK_f34b53ab9b39774ca014972ad84" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD "visibility" "public"."note_visibility_enum"`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD "cw" text`);
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."cw" IS 'Will always be null for older edits'`);
await queryRunner.query(`ALTER TABLE "note_edit" ADD "hasPoll" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "note_edit"."hasPoll" IS 'Whether this revision had a poll. Will always be false for older edits'`);
// Populate non-nullable fields
await queryRunner.query(`
UPDATE "note_edit" "e"
SET
"visibility" = "n"."visibility",
"userId" = "n"."userId"
FROM "note" "n"
WHERE "n"."id" = "e"."noteId"
`);
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "visibility" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "userId" SET NOT NULL`);
}
async down(queryRunner) {
// Drop new columns
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "visibility"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "hasPoll"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "cw"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "userId"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_f34b53ab9b39774ca014972ad84"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "replyId"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_d3003e5256bcbfad6c3588835c0"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "renoteId"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP CONSTRAINT "FK_7f1ded0f6e8a5bef701b7e698ab"`);
await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "userId"`);
// Rename new columns
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "text" TO "oldText"`);
await queryRunner.query(`ALTER TABLE "note_edit" RENAME COLUMN "newCw" TO "cw"`);
// Revert column types
await queryRunner.query(`ALTER TABLE "note_edit" ALTER COLUMN "cw" TYPE varchar(512) USING "cw"::varchar(512)`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddUserLastFetchedFeaturedAt1758129782800 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "lastFetchedFeaturedAt" DATE`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastFetchedFeaturedAt"`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FixUserLastFetchedFeaturedAtType1758136690898 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "lastFetchedFeaturedAt" TYPE TIMESTAMP WITH TIME ZONE`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "lastFetchedFeaturedAt" TYPE DATE`);
}
}

View file

@ -111,11 +111,14 @@ type Source = {
deliverJobConcurrency?: number; deliverJobConcurrency?: number;
inboxJobConcurrency?: number; inboxJobConcurrency?: number;
relationshipJobConcurrency?: number; relationshipJobConcurrency?: number;
backgroundJobConcurrency?: number;
deliverJobPerSec?: number; deliverJobPerSec?: number;
inboxJobPerSec?: number; inboxJobPerSec?: number;
relationshipJobPerSec?: number; relationshipJobPerSec?: number;
backgroundJobPerSec?: number;
deliverJobMaxAttempts?: number; deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number; inboxJobMaxAttempts?: number;
backgroundJobMaxAttempts?: number;
mediaDirectory?: string; mediaDirectory?: string;
mediaProxy?: string; mediaProxy?: string;
@ -272,11 +275,14 @@ export type Config = {
deliverJobConcurrency: number | undefined; deliverJobConcurrency: number | undefined;
inboxJobConcurrency: number | undefined; inboxJobConcurrency: number | undefined;
relationshipJobConcurrency: number | undefined; relationshipJobConcurrency: number | undefined;
backgroundJobConcurrency: number | undefined;
deliverJobPerSec: number | undefined; deliverJobPerSec: number | undefined;
inboxJobPerSec: number | undefined; inboxJobPerSec: number | undefined;
relationshipJobPerSec: number | undefined; relationshipJobPerSec: number | undefined;
backgroundJobPerSec: number | undefined;
deliverJobMaxAttempts: number | undefined; deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined;
backgroundJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined; proxyRemoteFiles: boolean | undefined;
customMOTD: string[] | undefined; customMOTD: string[] | undefined;
signToActivityPubGet: boolean; signToActivityPubGet: boolean;
@ -475,11 +481,14 @@ export function loadConfig(loggerService: LoggerService): Config {
deliverJobConcurrency: config.deliverJobConcurrency, deliverJobConcurrency: config.deliverJobConcurrency,
inboxJobConcurrency: config.inboxJobConcurrency, inboxJobConcurrency: config.inboxJobConcurrency,
relationshipJobConcurrency: config.relationshipJobConcurrency, relationshipJobConcurrency: config.relationshipJobConcurrency,
backgroundJobConcurrency: config.backgroundJobConcurrency,
deliverJobPerSec: config.deliverJobPerSec, deliverJobPerSec: config.deliverJobPerSec,
inboxJobPerSec: config.inboxJobPerSec, inboxJobPerSec: config.inboxJobPerSec,
relationshipJobPerSec: config.relationshipJobPerSec, relationshipJobPerSec: config.relationshipJobPerSec,
backgroundJobPerSec: config.backgroundJobPerSec,
deliverJobMaxAttempts: config.deliverJobMaxAttempts, deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts,
backgroundJobMaxAttempts: config.backgroundJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles, proxyRemoteFiles: config.proxyRemoteFiles,
customMOTD: config.customMOTD, customMOTD: config.customMOTD,
signToActivityPubGet: config.signToActivityPubGet ?? true, signToActivityPubGet: config.signToActivityPubGet ?? true,

View file

@ -218,9 +218,9 @@ export class AnnouncementService {
announcementId: announcement.id, announcementId: announcement.id,
userId: me.id, userId: me.id,
}); });
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me); return await this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
} else { } else {
return this.announcementEntityService.pack(announcement, null); return await this.announcementEntityService.pack(announcement, null);
} }
} }

View file

@ -18,13 +18,16 @@ import type { AntennasRepository, UserListMembershipsRepository } from '@/models
import type { MiAntenna } from '@/models/Antenna.js'; import type { MiAntenna } from '@/models/Antenna.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { InternalEventService } from '@/global/InternalEventService.js';
import { promiseMap } from '@/misc/promise-map.js';
import { CacheService } from './CacheService.js'; import { CacheService } from './CacheService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
export class AntennaService implements OnApplicationShutdown { export class AntennaService implements OnApplicationShutdown {
// TODO implement QuantumSingleCache then replace this
private antennasFetched: boolean; private antennasFetched: boolean;
private antennas: MiAntenna[]; private antennas: Map<string, MiAntenna>;
constructor( constructor(
@Inject(DI.redisForTimelines) @Inject(DI.redisForTimelines)
@ -43,9 +46,10 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private readonly internalEventService: InternalEventService,
) { ) {
this.antennasFetched = false; this.antennasFetched = false;
this.antennas = []; this.antennas = new Map();
this.redisForSub.on('message', this.onRedisMessage); this.redisForSub.on('message', this.onRedisMessage);
} }
@ -58,35 +62,16 @@ export class AntennaService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'antennaCreated': case 'antennaCreated':
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい case 'antennaUpdated':
this.antennas.set(body.id, { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body, ...body,
lastUsedAt: new Date(body.lastUsedAt), lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので userList: null, // joinなカラムは通常取ってこないので
}); });
break; break;
case 'antennaUpdated': {
const idx = this.antennas.findIndex(a => a.id === body.id);
if (idx >= 0) {
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
};
} else {
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
}
}
break;
case 'antennaDeleted': case 'antennaDeleted':
this.antennas = this.antennas.filter(a => a.id !== body.id); this.antennas.delete(body.id);
break; break;
default: default:
break; break;
@ -94,10 +79,27 @@ export class AntennaService implements OnApplicationShutdown {
} }
} }
@bindThis
public async updateAntenna(id: string, data: Partial<MiAntenna>) {
await this.antennasRepository.update({ id }, data);
const antenna = this.antennas.get(id) ?? await this.antennasRepository.findOneBy({ id });
if (antenna) {
// This will be handled above to save result
await this.internalEventService.emit('antennaUpdated', {
...antenna,
...data,
});
}
}
@bindThis @bindThis
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> { public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
const antennas = await this.getAntennas(); const antennas = await this.getAntennas();
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const antennasWithMatchResult = await promiseMap(antennas, async antenna => {
const hit = await this.checkHitAntenna(antenna, note, noteUser);
return [antenna, hit] as const;
});
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
const redisPipeline = this.redisForTimelines.pipeline(); const redisPipeline = this.redisForTimelines.pipeline();
@ -107,7 +109,7 @@ export class AntennaService implements OnApplicationShutdown {
this.globalEventService.publishAntennaStream(antenna.id, 'note', note); this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
} }
redisPipeline.exec(); await redisPipeline.exec();
} }
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@ -212,13 +214,14 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis @bindThis
public async getAntennas() { public async getAntennas() {
if (!this.antennasFetched) { if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.findBy({ const allAntennas = await this.antennasRepository.findBy({
isActive: true, isActive: true,
}); });
this.antennas = new Map(allAntennas.map(a => [a.id, a]));
this.antennasFetched = true; this.antennasFetched = true;
} }
return this.antennas; return Array.from(this.antennas.values());
} }
@bindThis @bindThis

View file

@ -14,7 +14,9 @@ import { JsonValue } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { IActivity, IObject } from './activitypub/type.js'; import { IActivity, IObject } from '@/core/activitypub/type.js';
import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class ApLogService { export class ApLogService {
@ -23,7 +25,7 @@ export class ApLogService {
private readonly config: Config, private readonly config: Config,
@Inject(DI.apContextsRepository) @Inject(DI.apContextsRepository)
private apContextsRepository: ApContextsRepository, private readonly apContextsRepository: ApContextsRepository,
@Inject(DI.apInboxLogsRepository) @Inject(DI.apInboxLogsRepository)
private readonly apInboxLogsRepository: ApInboxLogsRepository, private readonly apInboxLogsRepository: ApInboxLogsRepository,
@ -34,6 +36,7 @@ export class ApLogService {
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
private readonly idService: IdService, private readonly idService: IdService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly queueService: QueueService,
) {} ) {}
/** /**
@ -123,6 +126,16 @@ export class ApLogService {
.execute(); .execute();
} }
@bindThis
public async deleteObjectLogsDeferred(objectUris: string | string[]): Promise<void> {
await this.queueService.createDeleteApLogsJob('object', objectUris);
}
@bindThis
public async deleteInboxLogsDeferred(userIds: string | string[]): Promise<void> {
await this.queueService.createDeleteApLogsJob('inbox', userIds);
}
/** /**
* Deletes all logged copies of an object or objects * Deletes all logged copies of an object or objects
* @param objectUris URIs / AP IDs of the objects to delete * @param objectUris URIs / AP IDs of the objects to delete

View file

@ -109,7 +109,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
if (noCache) { if (noCache) {
this.cache.delete(); this.cache.delete();
} }
return this.cache.fetch(() => this.avatarDecorationsRepository.find()); return await this.cache.fetch(() => this.avatarDecorationsRepository.find());
} }
@bindThis @bindThis

View file

@ -605,12 +605,12 @@ export class ChatService {
@bindThis @bindThis
public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) { public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId }); return await this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
} }
@bindThis @bindThis
public async findRoomById(roomId: MiChatRoom['id']) { public async findRoomById(roomId: MiChatRoom['id']) {
return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] }); return await this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
} }
@bindThis @bindThis

View file

@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import type { MiLocalUser } from '@/models/User.js'; import type { MiLocalUser } from '@/models/User.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
@ -35,6 +36,7 @@ export class ClipService {
private roleService: RoleService, private roleService: RoleService,
private idService: IdService, private idService: IdService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly collapsedQueueService: CollapsedQueueService,
) { ) {
} }
@ -130,7 +132,7 @@ export class ClipService {
lastClippedAt: this.timeService.date, lastClippedAt: this.timeService.date,
}); });
this.notesRepository.increment({ id: noteId }, 'clippedCount', 1); await this.collapsedQueueService.updateNoteQueue.enqueue(noteId, { clippedCountDelta: 1 });
} }
@bindThis @bindThis
@ -155,6 +157,6 @@ export class ClipService {
clipId: clip.id, clipId: clip.id,
}); });
this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1); await this.collapsedQueueService.updateNoteQueue.enqueue(noteId, { clippedCountDelta: -1 });
} }
} }

View file

@ -0,0 +1,383 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { EnvService } from '@/global/EnvService.js';
import { bindThis } from '@/decorators.js';
import { InternalEventService } from '@/global/InternalEventService.js';
import type { UsersRepository, NotesRepository, AccessTokensRepository, MiAntenna, FollowingsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { AntennaService } from '@/core/AntennaService.js';
import { CacheService } from '@/core/CacheService.js';
import { TimeService } from '@/global/TimeService.js';
export type UpdateInstanceJob = {
latestRequestReceivedAt?: Date,
notRespondingSince?: Date | null,
shouldUnsuspend?: boolean,
shouldSuspendGone?: boolean,
shouldSuspendNotResponding?: boolean,
notesCountDelta?: number,
usersCountDelta?: number,
followingCountDelta?: number,
followersCountDelta?: number,
};
export type UpdateUserJob = {
updatedAt?: Date,
lastActiveDate?: Date,
notesCountDelta?: number,
followingCountDelta?: number,
followersCountDelta?: number,
};
export type UpdateNoteJob = {
repliesCountDelta?: number;
renoteCountDelta?: number;
clippedCountDelta?: number;
};
export type UpdateAccessTokenJob = {
lastUsedAt: Date;
};
export type UpdateAntennaJob = {
isActive: boolean,
lastUsedAt?: Date,
};
@Injectable()
export class CollapsedQueueService implements OnApplicationShutdown {
// Moved from InboxProcessorService
public readonly updateInstanceQueue: CollapsedQueue<UpdateInstanceJob>;
// Moved from NoteCreateService, NoteEditService, and NoteDeleteService
public readonly updateUserQueue: CollapsedQueue<UpdateUserJob>;
public readonly updateNoteQueue: CollapsedQueue<UpdateNoteJob>;
public readonly updateAccessTokenQueue: CollapsedQueue<UpdateAccessTokenJob>;
public readonly updateAntennaQueue: CollapsedQueue<UpdateAntennaJob>;
private readonly logger: Logger;
constructor(
@Inject(DI.usersRepository)
private readonly usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
@Inject(DI.accessTokensRepository)
private readonly accessTokensRepository: AccessTokensRepository,
@Inject(DI.followingsRepository)
private readonly followingsRepository: FollowingsRepository,
private readonly federatedInstanceService: FederatedInstanceService,
private readonly envService: EnvService,
private readonly internalEventService: InternalEventService,
private readonly antennaService: AntennaService,
private readonly cacheService: CacheService,
private readonly timeService: TimeService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('collapsed-queue');
const fiveMinuteInterval = this.envService.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0;
const oneMinuteInterval = this.envService.env.NODE_ENV !== 'test' ? 60 * 1000 : 0;
this.updateInstanceQueue = new CollapsedQueue(
this.internalEventService,
this.timeService,
'updateInstance',
fiveMinuteInterval,
(oldJob, newJob) => ({
latestRequestReceivedAt: maxDate(oldJob.latestRequestReceivedAt, newJob.latestRequestReceivedAt),
notRespondingSince: maxDate(oldJob.notRespondingSince, newJob.notRespondingSince),
shouldUnsuspend: oldJob.shouldUnsuspend || newJob.shouldUnsuspend,
shouldSuspendGone: oldJob.shouldSuspendGone || newJob.shouldSuspendGone,
shouldSuspendNotResponding: oldJob.shouldSuspendNotResponding || newJob.shouldSuspendNotResponding,
notesCountDelta: (oldJob.notesCountDelta ?? 0) + (newJob.notesCountDelta ?? 0),
usersCountDelta: (oldJob.usersCountDelta ?? 0) + (newJob.usersCountDelta ?? 0),
followingCountDelta: (oldJob.followingCountDelta ?? 0) + (newJob.followingCountDelta ?? 0),
followersCountDelta: (oldJob.followersCountDelta ?? 0) + (newJob.followersCountDelta ?? 0),
}),
async (id, job) => {
// Have to check this because all properties are optional
if (
job.latestRequestReceivedAt ||
job.notRespondingSince !== undefined ||
job.shouldSuspendNotResponding ||
job.shouldSuspendGone ||
job.shouldUnsuspend ||
job.notesCountDelta ||
job.usersCountDelta ||
job.followingCountDelta ||
job.followersCountDelta
) {
await this.federatedInstanceService.update(id, {
// Direct update if defined
latestRequestReceivedAt: job.latestRequestReceivedAt,
// null (responding) > Date (not responding)
notRespondingSince: job.latestRequestReceivedAt
? null
: job.notRespondingSince,
// false (responding) > true (not responding)
isNotResponding: job.latestRequestReceivedAt
? false
: job.notRespondingSince
? true
: undefined,
// gone > none > auto
suspensionState: job.shouldSuspendGone
? 'goneSuspended'
: job.shouldUnsuspend
? 'none'
: job.shouldSuspendNotResponding
? 'autoSuspendedForNotResponding'
: undefined,
// Increment if defined
notesCount: job.notesCountDelta ? () => `"notesCount" + ${job.notesCountDelta}` : undefined,
usersCount: job.usersCountDelta ? () => `"usersCount" + ${job.usersCountDelta}` : undefined,
followingCount: job.followingCountDelta ? () => `"followingCount" + ${job.followingCountDelta}` : undefined,
followersCount: job.followersCountDelta ? () => `"followersCount" + ${job.followersCountDelta}` : undefined,
});
}
},
{
onError: this.onQueueError,
concurrency: 2, // Low concurrency, this table is slow for some reason
redisParser: data => ({
...data,
latestRequestReceivedAt: data.latestRequestReceivedAt != null
? new Date(data.latestRequestReceivedAt)
: data.latestRequestReceivedAt,
notRespondingSince: data.notRespondingSince != null
? new Date(data.notRespondingSince)
: data.notRespondingSince,
}),
},
);
this.updateUserQueue = new CollapsedQueue(
this.internalEventService,
this.timeService,
'updateUser',
oneMinuteInterval,
(oldJob, newJob) => ({
updatedAt: maxDate(oldJob.updatedAt, newJob.updatedAt),
lastActiveDate: maxDate(oldJob.lastActiveDate, newJob.lastActiveDate),
notesCountDelta: (oldJob.notesCountDelta ?? 0) + (newJob.notesCountDelta ?? 0),
followingCountDelta: (oldJob.followingCountDelta ?? 0) + (newJob.followingCountDelta ?? 0),
followersCountDelta: (oldJob.followersCountDelta ?? 0) + (newJob.followersCountDelta ?? 0),
}),
async (id, job) => {
// Have to check this because all properties are optional
if (job.updatedAt || job.lastActiveDate || job.notesCountDelta || job.followingCountDelta || job.followersCountDelta) {
// Updating the user should implicitly mark them as active
const lastActiveDate = job.lastActiveDate ?? job.updatedAt;
const isWakingUp = lastActiveDate && (await this.cacheService.findUserById(id)).isHibernated;
// Update user before the hibernation cache, because the latter may refresh from DB
await this.usersRepository.update({ id }, {
updatedAt: job.updatedAt,
lastActiveDate,
isHibernated: isWakingUp ? false : undefined,
notesCount: job.notesCountDelta ? () => `"notesCount" + ${job.notesCountDelta}` : undefined,
followingCount: job.followingCountDelta ? () => `"followingCount" + ${job.followingCountDelta}` : undefined,
followersCount: job.followersCountDelta ? () => `"followersCount" + ${job.followersCountDelta}` : undefined,
});
await this.internalEventService.emit('userUpdated', { id });
// Wake up hibernated users
if (isWakingUp) {
await this.followingsRepository.update({ followerId: id }, { isFollowerHibernated: false });
await this.cacheService.hibernatedUserCache.set(id, false);
}
}
},
{
onError: this.onQueueError,
concurrency: 4, // High concurrency - this queue gets a lot of activity
redisParser: data => ({
...data,
updatedAt: data.updatedAt != null
? new Date(data.updatedAt)
: data.updatedAt,
lastActiveDate: data.lastActiveDate != null
? new Date(data.lastActiveDate)
: data.lastActiveDate,
}),
},
);
this.updateNoteQueue = new CollapsedQueue(
this.internalEventService,
this.timeService,
'updateNote',
oneMinuteInterval,
(oldJob, newJob) => ({
repliesCountDelta: (oldJob.repliesCountDelta ?? 0) + (newJob.repliesCountDelta ?? 0),
renoteCountDelta: (oldJob.renoteCountDelta ?? 0) + (newJob.renoteCountDelta ?? 0),
clippedCountDelta: (oldJob.clippedCountDelta ?? 0) + (newJob.clippedCountDelta ?? 0),
}),
async (id, job) => {
// Have to check this because all properties are optional
if (job.repliesCountDelta || job.renoteCountDelta || job.clippedCountDelta) {
await this.notesRepository.update({ id }, {
repliesCount: job.repliesCountDelta ? () => `"repliesCount" + ${job.repliesCountDelta}` : undefined,
renoteCount: job.renoteCountDelta ? () => `"renoteCount" + ${job.renoteCountDelta}` : undefined,
clippedCount: job.clippedCountDelta ? () => `"clippedCount" + ${job.clippedCountDelta}` : undefined,
});
}
},
{
onError: this.onQueueError,
concurrency: 4, // High concurrency - this queue gets a lot of activity
},
);
this.updateAccessTokenQueue = new CollapsedQueue(
this.internalEventService,
this.timeService,
'updateAccessToken',
fiveMinuteInterval,
(oldJob, newJob) => ({
lastUsedAt: maxDate(oldJob.lastUsedAt, newJob.lastUsedAt),
}),
async (id, job) => await this.accessTokensRepository.update({ id }, {
lastUsedAt: job.lastUsedAt,
}),
{
onError: this.onQueueError,
concurrency: 2,
redisParser: data => ({
...data,
lastUsedAt: new Date(data.lastUsedAt),
}),
},
);
this.updateAntennaQueue = new CollapsedQueue(
this.internalEventService,
this.timeService,
'updateAntenna',
fiveMinuteInterval,
(oldJob, newJob) => ({
isActive: oldJob.isActive || newJob.isActive,
lastUsedAt: maxDate(oldJob.lastUsedAt, newJob.lastUsedAt),
}),
async (id, job) => await this.antennaService.updateAntenna(id, {
isActive: job.isActive,
lastUsedAt: job.lastUsedAt,
}),
{
onError: this.onQueueError,
concurrency: 4,
redisParser: data => ({
...data,
lastUsedAt: data.lastUsedAt != null
? new Date(data.lastUsedAt)
: data.lastUsedAt,
}),
},
);
this.internalEventService.on('userChangeDeletedState', this.onUserDeleted);
this.internalEventService.on('antennaDeleted', this.onAntennaDeleted);
this.internalEventService.on('antennaUpdated', this.onAntennaDeleted);
}
@bindThis
private async performQueue<V>(queue: CollapsedQueue<V>): Promise<void> {
try {
const results = await queue.performAllNow();
const [succeeded, failed] = results.reduce((counts, result) => {
counts[result ? 0 : 1]++;
return counts;
}, [0, 0]);
this.logger.debug(`Persistence completed for ${queue.name}: ${succeeded} succeeded and ${failed} failed`);
} catch (err) {
this.logger.error(`Persistence failed for ${queue.name}: ${renderInlineError(err)}`);
}
}
@bindThis
private onQueueError<V>(queue: CollapsedQueue<V>, error: unknown): void {
this.logger.error(`Error persisting ${queue.name}: ${renderInlineError(error)}`);
}
@bindThis
private async onUserDeleted(data: { id: string, isDeleted: boolean }) {
if (data.isDeleted) {
await this.updateUserQueue.delete(data.id);
}
}
@bindThis
private async onAntennaDeleted(data: MiAntenna) {
await this.updateAntennaQueue.delete(data.id);
}
@bindThis
async dispose() {
this.internalEventService.off('userChangeDeletedState', this.onUserDeleted);
this.internalEventService.off('antennaDeleted', this.onAntennaDeleted);
this.internalEventService.off('antennaUpdated', this.onAntennaDeleted);
this.logger.info('Persisting all collapsed queues...');
await this.performQueue(this.updateInstanceQueue);
await this.performQueue(this.updateUserQueue);
await this.performQueue(this.updateNoteQueue);
await this.performQueue(this.updateAccessTokenQueue);
await this.performQueue(this.updateAntennaQueue);
this.logger.info('Persistence complete.');
}
async onApplicationShutdown() {
await this.dispose();
}
}
function maxDate(first: Date | undefined, second: Date): Date;
function maxDate(first: Date, second: Date | undefined): Date;
function maxDate(first: Date | undefined, second: Date | undefined): Date | undefined;
function maxDate(first: Date | null | undefined, second: Date | null | undefined): Date | null | undefined;
function maxDate(first: Date | null | undefined, second: Date | null | undefined): Date | null | undefined {
if (first !== undefined && second !== undefined) {
if (first != null && second != null) {
if (first.getTime() > second.getTime()) {
return first;
} else {
return second;
}
} else {
// Null is considered infinitely in the future, and is therefore newer than any date.
return null;
}
} else if (first !== undefined) {
return first;
} else if (second !== undefined) {
return second;
} else {
// Undefined in considered infinitely in the past, and is therefore older than any date.
return undefined;
}
}

View file

@ -17,7 +17,7 @@ import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js'; import { FlashService } from '@/core/FlashService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApLogService } from '@/core/ApLogService.js'; import { ApLogService } from '@/core/ApLogService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import { InstanceStatsService } from '@/core/InstanceStatsService.js'; import { InstanceStatsService } from '@/core/InstanceStatsService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
@ -218,7 +218,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $UpdateInstanceQueue: Provider = { provide: 'UpdateInstanceQueue', useExisting: UpdateInstanceQueue }; const $CollapsedQueueService: Provider = { provide: 'CollapsedQueueService', useExisting: CollapsedQueueService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
@ -377,7 +377,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
UpdateInstanceQueue, CollapsedQueueService,
VideoProcessingService, VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
@ -531,7 +531,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$UserSearchService, $UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,
$UpdateInstanceQueue, $CollapsedQueueService,
$VideoProcessingService, $VideoProcessingService,
$UserWebhookService, $UserWebhookService,
$SystemWebhookService, $SystemWebhookService,
@ -686,7 +686,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
UpdateInstanceQueue, CollapsedQueueService,
VideoProcessingService, VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
@ -839,7 +839,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$UserSearchService, $UserSearchService,
$UserSuspendService, $UserSuspendService,
$UserAuthService, $UserAuthService,
$UpdateInstanceQueue, $CollapsedQueueService,
$VideoProcessingService, $VideoProcessingService,
$UserWebhookService, $UserWebhookService,
$SystemWebhookService, $SystemWebhookService,

View file

@ -23,6 +23,7 @@ import { DriveService } from '@/core/DriveService.js';
import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js'; import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { promiseMap } from '@/misc/promise-map.js';
import { isRetryableSymbol } from '@/misc/is-retryable-error.js'; import { isRetryableSymbol } from '@/misc/is-retryable-error.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { KeyNotFoundError } from '@/misc/errors/KeyNotFoundError.js'; import { KeyNotFoundError } from '@/misc/errors/KeyNotFoundError.js';
@ -577,7 +578,7 @@ export class CustomEmojiService {
*/ */
@bindThis @bindThis
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> { public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> {
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); const emojis = await promiseMap(emojiNames, async x => await this.populateEmoji(x, noteUserHost), { limit: 4 });
const res = {} as Record<string, string>; const res = {} as Record<string, string>;
for (let i = 0; i < emojiNames.length; i++) { for (let i = 0; i < emojiNames.length; i++) {
const resolvedEmoji = emojis[i]; const resolvedEmoji = emojis[i];

View file

@ -207,7 +207,7 @@ export class DriveService {
//#region Uploads //#region Uploads
this.registerLogger.debug(`uploading original: ${key}`); this.registerLogger.debug(`uploading original: ${key}`);
const uploads = [ const uploads: Promise<void>[] = [
this.upload(key, fs.createReadStream(path), type, null, name), this.upload(key, fs.createReadStream(path), type, null, name),
]; ];
@ -470,7 +470,7 @@ export class DriveService {
for (const fileId of exceedFileIds) { for (const fileId of exceedFileIds) {
const file = await this.driveFilesRepository.findOneBy({ id: fileId }); const file = await this.driveFilesRepository.findOneBy({ id: fileId });
if (file == null) continue; if (file == null) continue;
this.deleteFile(file, true); await this.deleteFile(file, true);
} }
} }
@ -718,14 +718,14 @@ export class DriveService {
if (values.isSensitive !== undefined && values.isSensitive !== file.isSensitive) { if (values.isSensitive !== undefined && values.isSensitive !== file.isSensitive) {
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null; const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
if (values.isSensitive) { if (values.isSensitive) {
this.moderationLogService.log(updater, 'markSensitiveDriveFile', { await this.moderationLogService.log(updater, 'markSensitiveDriveFile', {
fileId: file.id, fileId: file.id,
fileUserId: file.userId, fileUserId: file.userId,
fileUserUsername: user?.username ?? null, fileUserUsername: user?.username ?? null,
fileUserHost: user?.host ?? null, fileUserHost: user?.host ?? null,
}); });
} else { } else {
this.moderationLogService.log(updater, 'unmarkSensitiveDriveFile', { await this.moderationLogService.log(updater, 'unmarkSensitiveDriveFile', {
fileId: file.id, fileId: file.id,
fileUserId: file.userId, fileUserId: file.userId,
fileUserUsername: user?.username ?? null, fileUserUsername: user?.username ?? null,
@ -740,29 +740,7 @@ export class DriveService {
@bindThis @bindThis
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: { id: string }) { public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
if (file.storedInternal) { await this.queueService.createDeleteFileJob(file.id, isExpired, deleter?.id);
this.deleteLocalFile(file.accessKey!);
if (file.thumbnailUrl) {
this.deleteLocalFile(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.deleteLocalFile(file.webpublicAccessKey!);
}
} else if (!file.isLink) {
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
if (file.thumbnailUrl) {
this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
}
if (file.webpublicUrl) {
this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
}
}
this.deletePostProcess(file, isExpired, deleter);
} }
@bindThis @bindThis
@ -793,14 +771,14 @@ export class DriveService {
await Promise.all(promises); await Promise.all(promises);
this.deletePostProcess(file, isExpired, deleter); await this.deletePostProcess(file, isExpired, deleter);
} }
@bindThis @bindThis
private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: { id: string }) { private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: { id: string }) {
// リモートファイル期限切れ削除後は直リンクにする // リモートファイル期限切れ削除後は直リンクにする
if (isExpired && file.userHost !== null && file.uri != null) { if (isExpired && file.userHost !== null && file.uri != null) {
this.driveFilesRepository.update(file.id, { await this.driveFilesRepository.update(file.id, {
isLink: true, isLink: true,
url: file.uri, url: file.uri,
thumbnailUrl: null, thumbnailUrl: null,
@ -812,7 +790,7 @@ export class DriveService {
webpublicAccessKey: 'webpublic-' + randomUUID(), webpublicAccessKey: 'webpublic-' + randomUUID(),
}); });
} else { } else {
this.driveFilesRepository.delete(file.id); await this.driveFilesRepository.delete(file.id);
} }
this.driveChart.update(file, false); this.driveChart.update(file, false);
@ -831,7 +809,7 @@ export class DriveService {
if (deleter && await this.roleService.isModerator(deleter) && (file.userId !== deleter.id)) { if (deleter && await this.roleService.isModerator(deleter) && (file.userId !== deleter.id)) {
const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null; const user = file.userId ? await this.usersRepository.findOneByOrFail({ id: file.userId }) : null;
this.moderationLogService.log(deleter, 'deleteDriveFile', { await this.moderationLogService.log(deleter, 'deleteDriveFile', {
fileId: file.id, fileId: file.id,
fileUserId: file.userId, fileUserId: file.userId,
fileUserUsername: user?.username ?? null, fileUserUsername: user?.username ?? null,

View file

@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { QueueService } from '@/core/QueueService.js';
import type { CheerioAPI } from 'cheerio/slim'; import type { CheerioAPI } from 'cheerio/slim';
type NodeInfo = { type NodeInfo = {
@ -50,6 +51,7 @@ export class FetchInstanceMetadataService {
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly queueService: QueueService,
) { ) {
this.logger = this.loggerService.getLogger('metadata', 'cyan'); this.logger = this.loggerService.getLogger('metadata', 'cyan');
} }
@ -73,8 +75,21 @@ export class FetchInstanceMetadataService {
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`); return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
} }
/**
* Schedules a deferred update on the background task worker.
* Duplicate updates are automatically skipped.
*/
@bindThis
public async fetchInstanceMetadataLazy(instance: MiInstance): Promise<void> {
if (!instance.isBlocked) {
await this.queueService.createUpdateInstanceJob(instance.host);
}
}
@bindThis @bindThis
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> { public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
if (instance.isBlocked) return;
const host = instance.host; const host = instance.host;
// finallyでunlockされてしまうのでtry内でロックチェックをしない // finallyでunlockされてしまうのでtry内でロックチェックをしない
@ -110,25 +125,30 @@ export class FetchInstanceMetadataService {
this.getDescription(info, dom, manifest).catch(() => null), this.getDescription(info, dom, manifest).catch(() => null),
]); ]);
this.logger.debug(`Successfuly fetched metadata of ${instance.host}`); this.logger.debug(`Successfully fetched metadata of ${instance.host}`);
const updates = { const updates = {
infoUpdatedAt: this.timeService.date, infoUpdatedAt: this.timeService.date,
} as Record<string, any>; } as Record<string, any>;
if (info) { if (info) {
updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?'; const softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
updates.softwareVersion = info.software?.version; if (softwareName !== instance.softwareName) updates.softwareName = softwareName;
updates.openRegistrations = info.openRegistrations; const softwareVersion = typeof info.software?.version === 'string' ? info.software.version.toLowerCase() : '?';
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; if (softwareVersion !== instance.softwareVersion) updates.softwareVersion = softwareVersion;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; if (info.openRegistrations !== instance.openRegistrations) updates.openRegistrations = info.openRegistrations;
const maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
if (maintainerName !== instance.maintainerName) updates.maintainerName = maintainerName;
const maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
if (maintainerEmail !== instance.maintainerEmail) updates.maintainerEmail = maintainerEmail;
} }
if (name) updates.name = name; if (name !== instance.name) updates.name = name;
if (description) updates.description = description; if (description !== instance.description) updates.description = description;
if (icon ?? favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon; const iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
if (favicon) updates.faviconUrl = favicon; if (iconUrl !== instance.iconUrl) updates.iconUrl = iconUrl;
if (themeColor) updates.themeColor = themeColor; if (favicon !== instance.faviconUrl) updates.faviconUrl = favicon;
if (themeColor !== instance.themeColor) updates.themeColor = themeColor;
await this.federatedInstanceService.update(instance.id, updates); await this.federatedInstanceService.update(instance.id, updates);
@ -169,10 +189,7 @@ export class FetchInstanceMetadataService {
throw new Error('No nodeinfo link provided'); throw new Error('No nodeinfo link provided');
} }
const info = await this.httpRequestService.getJson(link.href) const info = await this.httpRequestService.getJson(link.href);
.catch(err => {
throw err.statusCode ?? err.message;
});
this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`); this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`);

View file

@ -277,6 +277,8 @@ export interface InternalEventTypes {
userListMemberBulkRemoved: { userListIds: MiUserList['id'][]; memberId: MiUser['id']; }; userListMemberBulkRemoved: { userListIds: MiUserList['id'][]; memberId: MiUser['id']; };
quantumCacheUpdated: { name: string, keys: string[] }; quantumCacheUpdated: { name: string, keys: string[] };
quantumCacheReset: { name: string }; quantumCacheReset: { name: string };
collapsedQueueDefer: { name: string, key: string, deferred: boolean };
collapsedQueueEnqueue: { name: string, key: string, value: unknown };
} }
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>; type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;

View file

@ -59,7 +59,7 @@ export class HashtagService {
tag = normalizeForSearch(tag); tag = normalizeForSearch(tag);
// TODO: サンプリング // TODO: サンプリング
this.updateHashtagsRanking(tag, user.id); await this.updateHashtagsRanking(tag, user.id);
const index = await this.hashtagsRepository.findOneBy({ name: tag }); const index = await this.hashtagsRepository.findOneBy({ name: tag });
@ -119,11 +119,11 @@ export class HashtagService {
if (Object.keys(set).length > 0) { if (Object.keys(set).length > 0) {
q.set(set); q.set(set);
q.execute(); await q.execute();
} }
} else { } else {
if (isUserAttached) { if (isUserAttached) {
this.hashtagsRepository.insert({ await this.hashtagsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
name: tag, name: tag,
mentionedUserIds: [], mentionedUserIds: [],
@ -140,7 +140,7 @@ export class HashtagService {
attachedRemoteUsersCount: isRemoteUser(user) ? 1 : 0, attachedRemoteUsersCount: isRemoteUser(user) ? 1 : 0,
} as MiHashtag); } as MiHashtag);
} else { } else {
this.hashtagsRepository.insert({ await this.hashtagsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
name: tag, name: tag,
mentionedUserIds: [user.id], mentionedUserIds: [user.id],
@ -174,7 +174,7 @@ export class HashtagService {
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId); const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
if (exist === 1) return; if (exist === 1) return;
this.featuredService.updateHashtagsRanking(hashtag, 1); await this.featuredService.updateHashtagsRanking(hashtag, 1);
const redisPipeline = this.redisClient.pipeline(); const redisPipeline = this.redisClient.pipeline();
@ -193,7 +193,7 @@ export class HashtagService {
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定 'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
); );
redisPipeline.exec(); await redisPipeline.exec();
} }
@bindThis @bindThis

View file

@ -58,7 +58,7 @@ export class ImageProcessingService {
*/ */
@bindThis @bindThis
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> { public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
return this.convertSharpToWebp(sharp(path), width, height, options); return await this.convertSharpToWebp(sharp(path), width, height, options);
} }
@bindThis @bindThis
@ -100,7 +100,7 @@ export class ImageProcessingService {
*/ */
@bindThis @bindThis
public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> { public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
return this.convertSharpToAvif(sharp(path), width, height, options); return await this.convertSharpToAvif(sharp(path), width, height, options);
} }
@bindThis @bindThis
@ -142,7 +142,7 @@ export class ImageProcessingService {
*/ */
@bindThis @bindThis
public async convertToPng(path: string, width: number, height: number): Promise<IImage> { public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
return this.convertSharpToPng(sharp(path), width, height); return await this.convertSharpToPng(sharp(path), width, height);
} }
@bindThis @bindThis

View file

@ -1,18 +1,14 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not } from 'typeorm'; import { Not } from 'typeorm';
import { MiNote } from '@/models/Note.js'; import { isPureRenote, MinimalNote } from '@/misc/is-renote.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { SkLatestNote } from '@/models/LatestNote.js'; import { SkLatestNote } from '@/models/LatestNote.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js'; import type { LatestNotesRepository, MiNote, NotesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js'; import { QueryService } from '@/core/QueryService.js';
import Logger from '@/logger.js'; import { QueueService } from '@/core/QueueService.js';
import { QueryService } from './QueryService.js';
@Injectable() @Injectable()
export class LatestNoteService { export class LatestNoteService {
private readonly logger: Logger;
constructor( constructor(
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository, private readonly notesRepository: NotesRepository,
@ -21,19 +17,23 @@ export class LatestNoteService {
private readonly latestNotesRepository: LatestNotesRepository, private readonly latestNotesRepository: LatestNotesRepository,
private readonly queryService: QueryService, private readonly queryService: QueryService,
loggerService: LoggerService, private readonly queueService: QueueService,
) { ) {}
this.logger = loggerService.getLogger('LatestNoteService');
async handleUpdatedNoteDeferred(note: MiNote): Promise<void> {
await this.queueService.createUpdateLatestNoteJob(note);
} }
handleUpdatedNoteBG(before: MiNote, after: MiNote): void { async handleCreatedNoteDeferred(note: MiNote): Promise<void> {
this await this.queueService.createUpdateLatestNoteJob(note);
.handleUpdatedNote(before, after)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
} }
async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> { async handleDeletedNoteDeferred(note: MiNote): Promise<void> {
// If the key didn't change, then there's nothing to update await this.queueService.createUpdateLatestNoteJob(note);
}
async handleUpdatedNote(before: MinimalNote, after: MinimalNote): Promise<void> {
// If the key didn't change, then there's nothing to update.
if (SkLatestNote.areEquivalent(before, after)) return; if (SkLatestNote.areEquivalent(before, after)) return;
// Simulate update as delete + create // Simulate update as delete + create
@ -41,13 +41,7 @@ export class LatestNoteService {
await this.handleCreatedNote(after); await this.handleCreatedNote(after);
} }
handleCreatedNoteBG(note: MiNote): void { async handleCreatedNote(note: MinimalNote): Promise<void> {
this
.handleCreatedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
}
async handleCreatedNote(note: MiNote): Promise<void> {
// Ignore DMs. // Ignore DMs.
// Followers-only posts are *included*, as this table is used to back the "following" feed. // Followers-only posts are *included*, as this table is used to back the "following" feed.
if (note.visibility === 'specified') return; if (note.visibility === 'specified') return;
@ -71,13 +65,7 @@ export class LatestNoteService {
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']); await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
} }
handleDeletedNoteBG(note: MiNote): void { async handleDeletedNote(note: MinimalNote): Promise<void> {
this
.handleDeletedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
}
async handleDeletedNote(note: MiNote): Promise<void> {
// If it's a DM, then it can't possibly be the latest note so we can safely skip this. // If it's a DM, then it can't possibly be the latest note so we can safely skip this.
if (note.visibility === 'specified') return; if (note.visibility === 'specified') return;

View file

@ -59,6 +59,8 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import { promiseMap } from '@/misc/promise-map.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -154,7 +156,6 @@ export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } |
@Injectable() @Injectable()
export class NoteCreateService implements OnApplicationShutdown { export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -226,8 +227,8 @@ export class NoteCreateService implements OnApplicationShutdown {
private latestNoteService: LatestNoteService, private latestNoteService: LatestNoteService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly noteVisibilityService: NoteVisibilityService, private readonly noteVisibilityService: NoteVisibilityService,
private readonly collapsedQueueService: CollapsedQueueService,
) { ) {
this.updateNotesCountQueue = new CollapsedQueue(this.timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
} }
@bindThis @bindThis
@ -458,10 +459,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then( await this.queueService.createPostNoteJob(note.id, silent, 'create');
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return note; return note;
} }
@ -474,7 +472,7 @@ export class NoteCreateService implements OnApplicationShutdown {
isBot: MiUser['isBot']; isBot: MiUser['isBot'];
noindex: MiUser['noindex']; noindex: MiUser['noindex'];
}, data: Option): Promise<MiNote> { }, data: Option): Promise<MiNote> {
return this.create(user, data, true); return await this.create(user, data, true);
} }
@bindThis @bindThis
@ -577,13 +575,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async postNoteCreated(note: MiNote, user: MiUser & { public async postNoteCreated(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
this.notesChart.update(note, true); this.notesChart.update(note, true);
if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) { if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
this.perUserNotesChart.update(user, note, true); this.perUserNotesChart.update(user, note, true);
@ -594,7 +586,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (isRemoteUser(user)) { if (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (!this.isRenote(note) || this.isQuote(note)) { if (!this.isRenote(note) || this.isQuote(note)) {
this.updateNotesCountQueue.enqueue(i.id, 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: 1 });
} }
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true); this.instanceChart.updateNote(i.host, note, true);
@ -606,26 +598,26 @@ export class NoteCreateService implements OnApplicationShutdown {
// ハッシュタグ更新 // ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') { if (data.visibility === 'public' || data.visibility === 'home') {
if (!user.isBot || this.meta.enableBotTrending) { if (!user.isBot || this.meta.enableBotTrending) {
this.hashtagService.updateHashtags(user, tags); await this.queueService.createUpdateNoteTagsJob(note.id);
} }
} }
if (!this.isRenote(note) || this.isQuote(note)) { if (!this.isRenote(note) || this.isQuote(note)) {
// Increment notes count (user) // Increment notes count (user)
this.incNotesCountOfUser(user); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: 1 });
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
} }
this.pushToTl(note, user); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
this.antennaService.addNoteToAntennas({ await this.pushToTl(note, user);
await this.antennaService.addNoteToAntennas({
...note, ...note,
channel: data.channel ?? null, channel: data.channel ?? null,
}, user); }, user);
if (data.reply) { if (data.reply) {
this.saveReply(data.reply, note); await this.collapsedQueueService.updateNoteQueue.enqueue(data.reply.id, { repliesCountDelta: 1 });
} }
if (data.reply == null) { if (data.reply == null) {
@ -653,13 +645,14 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
} }
if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) { if (this.isPureRenote(data)) {
this.incRenoteCount(data.renote, user); await this.collapsedQueueService.updateNoteQueue.enqueue(data.renote.id, { renoteCountDelta: 1 });
await this.incRenoteCount(data.renote, user);
} }
if (data.poll && data.poll.expiresAt) { if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - this.timeService.now; const delay = data.poll.expiresAt.getTime() - this.timeService.now;
this.queueService.endedPollNotificationQueue.add(note.id, { await this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id, noteId: note.id,
}, { }, {
jobId: `pollEnd_${note.id}`, jobId: `pollEnd_${note.id}`,
@ -683,9 +676,9 @@ export class NoteCreateService implements OnApplicationShutdown {
this.globalEventService.publishNotesStream(noteObj); this.globalEventService.publishNotesStream(noteObj);
this.roleService.addNoteToRoleTimeline(noteObj); await this.roleService.addNoteToRoleTimeline(noteObj);
this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj }); await this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
@ -714,7 +707,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!isThreadMuted && !muted) { if (!isThreadMuted && !muted) {
nm.push(data.reply.userId, 'reply'); nm.push(data.reply.userId, 'reply');
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); await this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
} }
} }
} }
@ -745,15 +738,15 @@ export class NoteCreateService implements OnApplicationShutdown {
// Publish event // Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) { if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj }); await this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj });
} }
} }
nm.notify(); await nm.notify();
//#region AP deliver //#region AP deliver
if (!data.localOnly && isLocalUser(user)) { if (!data.localOnly && isLocalUser(user)) {
trackTask(async () => { await trackTask(async () => {
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
@ -790,12 +783,12 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
if (data.channel) { if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, { await this.channelsRepository.update(data.channel.id, {
lastNotedAt: this.timeService.date, lastNotedAt: this.timeService.date,
}); });
this.notesRepository.countBy({ await this.notesRepository.countBy({
userId: user.id, userId: user.id,
channelId: data.channel.id, channelId: data.channel.id,
}).then(count => { }).then(count => {
@ -808,10 +801,10 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
// Update the Latest Note index / following feed // Update the Latest Note index / following feed
this.latestNoteService.handleCreatedNoteBG(note); await this.latestNoteService.handleCreatedNoteDeferred(note);
// Register to search database // Register to search database
if (!user.noindex) this.index(note); if (!user.noindex) await this.index(note);
} }
/** /**
@ -829,14 +822,11 @@ export class NoteCreateService implements OnApplicationShutdown {
*/ */
readonly isQuote = isQuote; readonly isQuote = isQuote;
// Note: does not increment the count! used only for featured rankings.
@bindThis @bindThis
private async incRenoteCount(renote: MiNote, user: MiUser) { private async incRenoteCount(renote: MiNote, user: MiUser) {
await this.notesRepository.createQueryBuilder().update() // Moved down from the containing block
.set({ if (renote.userId === user.id || user.isBot) return;
renoteCount: () => '"renoteCount" + 1',
})
.where('id = :id', { id: renote.id })
.execute();
// 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、3日以内に投稿されたートの場合ハイライト用ランキング更新
if (user.isExplorable && Math.random() < 0.3 && (this.timeService.now - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { if (user.isExplorable && Math.random() < 0.3 && (this.timeService.now - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
@ -844,12 +834,12 @@ export class NoteCreateService implements OnApplicationShutdown {
if (policies.canTrend) { if (policies.canTrend) {
if (renote.channelId != null) { if (renote.channelId != null) {
if (renote.replyId == null) { if (renote.replyId == null) {
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5); await this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
} }
} else { } else {
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
this.featuredService.updateGlobalNotesRanking(renote, 5); await this.featuredService.updateGlobalNotesRanking(renote, 5);
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5); await this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
} }
} }
} }
@ -883,7 +873,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote }); await this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
// Create notification // Create notification
nm.push(u.id, 'mention'); nm.push(u.id, 'mention');
@ -891,43 +881,23 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private saveReply(reply: MiNote, note: MiNote) { private async index(note: MiNote) {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return; if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note); await this.searchService.indexNote(note);
} }
@bindThis @bindThis
private incNotesCountOfUser(user: { id: MiUser['id']; }) { public async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
this.usersRepository.createQueryBuilder().update() if (tokens == null || tokens.length === 0) return [];
.set({
updatedAt: this.timeService.date,
notesCount: () => '"notesCount" + 1',
})
.where('id = :id', { id: user.id })
.execute();
}
@bindThis const allMentions = extractMentions(tokens);
private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> { const mentions = new Map(allMentions.map(m => [`${m.username.toLowerCase()}@${m.host?.toLowerCase()}`, m]));
if (tokens == null) return [];
const mentions = extractMentions(tokens); const allMentionedUsers = await promiseMap(mentions.values(), async m => await this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), { limit: 2 });
let mentionedUsers = (await Promise.all(mentions.map(m => const mentionedUsers = new Map(allMentionedUsers.filter(u => u != null).map(u => [u.id, u]));
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
))).filter(x => x != null);
// Drop duplicate users return Array.from(mentionedUsers.values());
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id),
);
return mentionedUsers;
} }
@bindThis @bindThis
@ -1040,7 +1010,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// checkHibernation moved to HibernateUsersProcessorService // checkHibernation moved to HibernateUsersProcessorService
} }
r.exec(); await r.exec();
} }
// checkHibernation moved to HibernateUsersProcessorService // checkHibernation moved to HibernateUsersProcessorService
@ -1062,20 +1032,11 @@ export class NoteCreateService implements OnApplicationShutdown {
return false; return false;
} }
@bindThis // collapseNotesCount moved to CollapsedQueueService
private collapseNotesCount(oldValue: number, newValue: number) {
return oldValue + newValue;
}
@bindThis
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
}
@bindThis @bindThis
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
this.#shutdownController.abort(); this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
} }
@bindThis @bindThis
@ -1100,8 +1061,8 @@ export class NoteCreateService implements OnApplicationShutdown {
// Instance cannot quote // Instance cannot quote
if (user.host) { if (user.host) {
const instance = await this.federatedInstanceService.fetch(user.host); const instance = await this.federatedInstanceService.fetchOrRegister(user.host);
if (instance?.rejectQuotes) { if (instance.rejectQuotes) {
(data as Option).renote = null; (data as Option).renote = null;
(data.processErrors ??= []).push('quoteUnavailable'); (data.processErrors ??= []).push('quoteUnavailable');
} }

View file

@ -22,18 +22,15 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isPureRenote } from '@/misc/is-renote.js';
import { LatestNoteService } from '@/core/LatestNoteService.js'; import { LatestNoteService } from '@/core/LatestNoteService.js';
import { ApLogService } from '@/core/ApLogService.js'; import { ApLogService } from '@/core/ApLogService.js';
import type Logger from '@/logger.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackTask } from '@/misc/promise-tracker.js';
import { LoggerService } from '@/core/LoggerService.js'; import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
@Injectable() @Injectable()
export class NoteDeleteService { export class NoteDeleteService {
private readonly logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -63,53 +60,56 @@ export class NoteDeleteService {
private latestNoteService: LatestNoteService, private latestNoteService: LatestNoteService,
private readonly apLogService: ApLogService, private readonly apLogService: ApLogService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly collapsedQueueService: CollapsedQueueService,
loggerService: LoggerService, ) {}
) {
this.logger = loggerService.getLogger('note-delete-service');
}
/** /**
* 稿 * 稿
* @param user 稿
* @param note 稿
*/ */
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) { async delete(user: MiUser, note: MiNote, deleter?: MiUser, immediate = false) {
// This kicks off lots of things that can run in parallel, but we should still wait for completion to ensure consistent state and to avoid task flood when calling in a loop.
const promises: Promise<unknown>[] = [];
const deletedAt = this.timeService.date; const deletedAt = this.timeService.date;
const cascadingNotes = await this.findCascadingNotes(note); const cascadingNotes = await this.findCascadingNotes(note);
if (note.replyId) { if (note.replyId) {
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); await this.collapsedQueueService.updateNoteQueue.enqueue(note.replyId, { repliesCountDelta: -1 });
} else if (isPureRenote(note)) {
await this.collapsedQueueService.updateNoteQueue.enqueue(note.renoteId, { renoteCountDelta: -1 });
} }
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { for (const cascade of cascadingNotes) {
await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => { if (cascade.replyId) {
if (!renote) return; await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.replyId, { repliesCountDelta: -1 });
if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1); } else if (isPureRenote(cascade)) {
}); await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.renoteId, { renoteCountDelta: -1 });
}
} }
if (!quiet) { // Braces preserved to avoid merge conflicts
this.globalEventService.publishNoteStream(note.id, 'deleted', { {
promises.push(this.globalEventService.publishNoteStream(note.id, 'deleted', {
deletedAt: deletedAt, deletedAt: deletedAt,
}); }));
for (const cascade of cascadingNotes) {
promises.push(this.globalEventService.publishNoteStream(cascade.id, 'deleted', {
deletedAt: deletedAt,
}));
}
//#region ローカルの投稿なら削除アクティビティを配送 //#region ローカルの投稿なら削除アクティビティを配送
if (isLocalUser(user) && !note.localOnly) { if (isLocalUser(user) && !note.localOnly) {
let renote: MiNote | null = null; const renote = isPureRenote(note)
? await this.notesRepository.findOneBy({ id: note.renoteId })
// if deleted note is renote : null;
if (isRenote(note) && !isQuote(note)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});
}
const content = this.apRendererService.addContext(renote const content = this.apRendererService.addContext(renote
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user) ? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user)); : this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
trackPromise(this.deliverToConcerned(user, note, content)); promises.push(this.deliverToConcerned(user, note, content));
} }
// also deliver delete activity to cascaded notes // also deliver delete activity to cascaded notes
@ -118,7 +118,7 @@ export class NoteDeleteService {
if (!cascadingNote.user) continue; if (!cascadingNote.user) continue;
if (!isLocalUser(cascadingNote.user)) continue; if (!isLocalUser(cascadingNote.user)) continue;
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
trackPromise(this.deliverToConcerned(cascadingNote.user, cascadingNote, content)); promises.push(this.deliverToConcerned(cascadingNote.user, cascadingNote, content));
} }
//#endregion //#endregion
@ -127,90 +127,142 @@ export class NoteDeleteService {
this.perUserNotesChart.update(user, note, false); this.perUserNotesChart.update(user, note, false);
} }
if (!isRenote(note) || isQuote(note)) { for (const cascade of cascadingNotes) {
this.notesChart.update(cascade, false);
if (this.meta.enableChartsForRemoteUser || (cascade.user.host == null)) {
this.perUserNotesChart.update(cascade.user, cascade, false);
}
}
if (!isPureRenote(note)) {
// Decrement notes count (user) // Decrement notes count (user)
this.decNotesCountOfUser(user); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: -1 });
} else { }
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
for (const cascade of cascadingNotes) {
if (!isPureRenote(cascade)) {
await this.collapsedQueueService.updateUserQueue.enqueue(cascade.user.id, { notesCountDelta: -1 });
}
// Don't mark cascaded user as updated (active)
} }
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
if (isRemoteUser(user)) { if (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { if (!isPureRenote(note)) {
if (note.renoteId && note.text || !note.renoteId) { const i = await this.federatedInstanceService.fetchOrRegister(user.host);
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: -1 });
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(user.host, note, false);
}
}
for (const cascade of cascadingNotes) {
if (isRemoteUser(cascade.user)) {
if (!isPureRenote(cascade)) {
const i = await this.federatedInstanceService.fetchOrRegister(cascade.user.host);
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: -1 });
} }
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false); this.instanceChart.updateNote(cascade.user.host, cascade, false);
} }
}); }
} }
} }
} }
for (const cascadingNote of cascadingNotes) { for (const cascadingNote of cascadingNotes) {
this.searchService.unindexNote(cascadingNote); promises.push(this.searchService.unindexNote(cascadingNote));
} }
this.searchService.unindexNote(note); promises.push(this.searchService.unindexNote(note));
// Don't put this in the promise array, since it needs to happen before the next section
await this.notesRepository.delete({ await this.notesRepository.delete({
id: note.id, id: note.id,
userId: user.id, userId: user.id,
}); });
this.latestNoteService.handleDeletedNoteBG(note); // Update the Latest Note index / following feed *after* note is deleted
promises.push(immediate
? this.latestNoteService.handleDeletedNote(note)
: this.latestNoteService.handleDeletedNoteDeferred(note));
for (const cascadingNote of cascadingNotes) {
promises.push(immediate
? this.latestNoteService.handleDeletedNote(cascadingNote)
: this.latestNoteService.handleDeletedNoteDeferred(cascadingNote));
}
if (deleter && (note.userId !== deleter.id)) { if (deleter && (user.id !== deleter.id)) {
const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); promises.push(this.moderationLogService.log(deleter, 'deleteNote', {
this.moderationLogService.log(deleter, 'deleteNote', {
noteId: note.id, noteId: note.id,
noteUserId: note.userId, noteUserId: note.userId,
noteUserUsername: user.username, noteUserUsername: user.username,
noteUserHost: user.host, noteUserHost: user.host,
}); }));
} }
const deletedUris = [note, ...cascadingNotes] const deletedUris = [note, ...cascadingNotes]
.map(n => n.uri) .map(n => n.uri)
.filter((u): u is string => u != null); .filter((u): u is string => u != null);
if (deletedUris.length > 0) { if (deletedUris.length > 0) {
this.apLogService.deleteObjectLogs(deletedUris) promises.push(immediate
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`)); ? this.apLogService.deleteObjectLogs(deletedUris)
: this.apLogService.deleteObjectLogsDeferred(deletedUris));
} }
await trackTask(async () => {
await Promise.allSettled(promises);
// This is deferred to make sure we don't race the enqueue() calls
if (immediate) {
await Promise.allSettled([
this.collapsedQueueService.updateNoteQueue.performAllNow(),
this.collapsedQueueService.updateUserQueue.performAllNow(),
this.collapsedQueueService.updateInstanceQueue.performAllNow(),
]);
}
});
} }
@bindThis @bindThis
private decNotesCountOfUser(user: { id: MiUser['id']; }) { private async findCascadingNotes(note: MiNote): Promise<(MiNote & { user: MiUser })[]> {
this.usersRepository.createQueryBuilder().update() const cascadingNotes: MiNote[] = [];
.set({
updatedAt: this.timeService.date,
notesCount: () => '"notesCount" - 1',
})
.where('id = :id', { id: user.id })
.execute();
}
@bindThis /**
private async findCascadingNotes(note: MiNote): Promise<MiNote[]> { * Finds all replies, quotes, and renotes of the given list of notes.
const recursive = async (noteId: string): Promise<MiNote[]> => { * These are the notes that will be CASCADE deleted when the origin note is deleted.
const query = this.notesRepository.createQueryBuilder('note') *
.where('note.replyId = :noteId', { noteId }) * This works by operating in "layers" that radiate out from the origin note like a web.
.orWhere(new Brackets(q => { * The process is roughly like this:
q.where('note.renoteId = :noteId', { noteId }) * 1. Find all immediate replies and renotes of the origin.
.andWhere('note.text IS NOT NULL'); * 2. Find all immediate replies and renotes of the results from step one.
})) * 3. Repeat until step 2 returns no new results.
.leftJoinAndSelect('note.user', 'user'); * 4. Collect all the step 2 results; those are the set of all cascading notes.
const replies = await query.getMany(); */
const cascade = async (layer: MiNote[]): Promise<void> => {
const layerIds = layer.map(layer => layer.id);
const refs = await this.notesRepository.find({
where: [
{ replyId: In(layerIds) },
{ renoteId: In(layerIds) },
],
relations: { user: true },
});
return [ // Stop when we reach the end of all threads
replies, if (refs.length === 0) return;
...await Promise.all(replies.map(reply => recursive(reply.id))),
].flat(); cascadingNotes.push(...refs);
await cascade(refs);
}; };
const cascadingNotes: MiNote[] = await recursive(note.id); // Start with the origin, which should *not* be in the result set!
await cascade([note]);
return cascadingNotes; // Type cast is safe - we load the relation above.
return cascadingNotes as (MiNote & { user: MiUser })[];
} }
@bindThis @bindThis

View file

@ -3,18 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { DataSource, In, IsNull, LessThan } from 'typeorm'; import { DataSource, In } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq'; import { UnrecoverableError } from 'bullmq';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; import type { NoteEditsRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@ -50,11 +48,11 @@ import { trackTask } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { LatestNoteService } from '@/core/LatestNoteService.js'; import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { isPureRenote } from '@/misc/is-renote.js'; import { isPureRenote } from '@/misc/is-renote.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
@ -150,7 +148,6 @@ export type Option = {
@Injectable() @Injectable()
export class NoteEditService implements OnApplicationShutdown { export class NoteEditService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -195,8 +192,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository) @Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.noteEditRepository) @Inject(DI.noteEditsRepository)
private noteEditRepository: NoteEditRepository, private noteEditsRepository: NoteEditsRepository,
@Inject(DI.pollsRepository) @Inject(DI.pollsRepository)
private pollsRepository: PollsRepository, private pollsRepository: PollsRepository,
@ -224,8 +221,8 @@ export class NoteEditService implements OnApplicationShutdown {
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly noteVisibilityService: NoteVisibilityService, private readonly noteVisibilityService: NoteVisibilityService,
private readonly collapsedQueueService: CollapsedQueueService,
) { ) {
this.updateNotesCountQueue = new CollapsedQueue(this.timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
} }
@bindThis @bindThis
@ -234,29 +231,29 @@ export class NoteEditService implements OnApplicationShutdown {
throw new UnrecoverableError('edit failed: missing editid'); throw new UnrecoverableError('edit failed: missing editid');
} }
const oldnote = await this.notesRepository.findOneBy({ const oldNote = await this.notesRepository.findOneBy({
id: editid, id: editid,
}); });
if (oldnote == null) { if (oldNote == null) {
throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`); throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`);
} }
if (oldnote.userId !== user.id) { if (oldNote.userId !== user.id) {
throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`); throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`);
} }
// we never want to change the replyId, so fetch the original "parent" // we never want to change the replyId, so fetch the original "parent"
if (oldnote.replyId) { if (oldNote.replyId) {
data.reply = await this.notesRepository.findOneBy({ id: oldnote.replyId }); data.reply = await this.notesRepository.findOneBy({ id: oldNote.replyId });
} else { } else {
data.reply = undefined; data.reply = undefined;
} }
// changing visibility on an edit is ill-defined, let's try to // changing visibility on an edit is ill-defined, let's try to
// keep the same visibility as the original note // keep the same visibility as the original note
data.visibility = oldnote.visibility; data.visibility = oldNote.visibility;
data.localOnly = oldnote.localOnly; data.localOnly = oldNote.localOnly;
// チャンネル外にリプライしたら対象のスコープに合わせる // チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
@ -354,12 +351,12 @@ export class NoteEditService implements OnApplicationShutdown {
} }
// Check for recursion // Check for recursion
if (data.renote.id === oldnote.id) { if (data.renote.id === oldNote.id) {
throw new IdentifiableError('33510210-8452-094c-6227-4a6c05d99f02', `edit failed for ${oldnote.id}: note cannot quote itself`); throw new IdentifiableError('33510210-8452-094c-6227-4a6c05d99f02', `edit failed for ${oldNote.id}: note cannot quote itself`);
} }
for (let nextRenoteId = data.renote.renoteId; nextRenoteId != null;) { for (let nextRenoteId = data.renote.renoteId; nextRenoteId != null;) {
if (nextRenoteId === oldnote.id) { if (nextRenoteId === oldNote.id) {
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: note cannot quote a quote of itself`); throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldNote.id}: note cannot quote a quote of itself`);
} }
// TODO create something like threadId but for quotes, that way we don't need full recursion // TODO create something like threadId but for quotes, that way we don't need full recursion
@ -432,7 +429,7 @@ export class NoteEditService implements OnApplicationShutdown {
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); mentionedUsers = data.apMentions ?? await this.noteCreateService.extractMentionedUsers(user, combinedTokens);
} }
// if the host is media-silenced, custom emojis are not allowed // if the host is media-silenced, custom emojis are not allowed
@ -463,46 +460,52 @@ export class NoteEditService implements OnApplicationShutdown {
} }
const update: Partial<MiNote> = {}; const update: Partial<MiNote> = {};
if (data.text !== undefined && data.text !== oldnote.text) { if (data.text !== undefined && data.text !== oldNote.text) {
update.text = data.text; update.text = data.text;
} }
if (data.cw !== undefined && data.cw !== oldnote.cw) { if (data.cw !== undefined && data.cw !== oldNote.cw) {
update.cw = data.cw; update.cw = data.cw;
} }
if (data.poll !== undefined && oldnote.hasPoll !== !!data.poll) { if (data.poll !== undefined && oldNote.hasPoll !== !!data.poll) {
update.hasPoll = !!data.poll; update.hasPoll = !!data.poll;
} }
if (data.mandatoryCW !== undefined && oldnote.mandatoryCW !== data.mandatoryCW) { if (data.mandatoryCW !== undefined && oldNote.mandatoryCW !== data.mandatoryCW) {
update.mandatoryCW = data.mandatoryCW; update.mandatoryCW = data.mandatoryCW;
} }
// TODO deep-compare files // TODO deep-compare files
const filesChanged = oldnote.fileIds.length || data.files?.length; const filesChanged = oldNote.fileIds.length || data.files?.length;
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); const oldPoll = await this.pollsRepository.findOneBy({ noteId: oldNote.id });
const oldPollData = oldPoll ? { choices: oldPoll.choices, multiple: oldPoll.multiple, expiresAt: oldPoll.expiresAt?.toISOString() ?? null } : null;
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; const newPollData = data.poll ? { choices: data.poll.choices, multiple: data.poll.multiple, expiresAt: data.poll.expiresAt ?? null } : null;
const pollChanged = data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll); const pollChanged = data.poll !== undefined && JSON.stringify(oldPollData) !== JSON.stringify(newPollData);
if (Object.keys(update).length > 0 || filesChanged || pollChanged) { if (Object.keys(update).length > 0 || filesChanged || pollChanged) {
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id }); const exists = await this.noteEditsRepository.findOneBy({ noteId: oldNote.id });
await this.noteEditRepository.insert({ await this.noteEditsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
noteId: oldnote.id, userId: oldNote.userId,
oldText: oldnote.text || undefined, noteId: oldNote.id,
renoteId: oldNote.renoteId,
replyId: oldNote.replyId,
visibility: oldNote.visibility,
text: oldNote.text || undefined,
newText: update.text || undefined, newText: update.text || undefined,
cw: update.cw || undefined, cw: oldNote.cw || undefined,
fileIds: undefined, newCw: update.cw || undefined,
oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date, fileIds: oldNote.fileIds,
oldDate: exists ? oldNote.updatedAt as Date : this.idService.parse(oldNote.id).date,
updatedAt: this.timeService.date, updatedAt: this.timeService.date,
hasPoll: oldPoll != null,
}); });
const note = new MiNote({ const note = new MiNote({
id: oldnote.id, id: oldNote.id,
updatedAt: data.updatedAt ? data.updatedAt : this.timeService.date, updatedAt: data.updatedAt ? data.updatedAt : this.timeService.date,
fileIds: data.files ? data.files.map(file => file.id) : [], fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: oldnote.replyId, replyId: oldNote.replyId,
renoteId: data.renote ? data.renote.id : null, renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null, channelId: data.channel ? data.channel.id : null,
threadId: data.reply threadId: data.reply
@ -516,7 +519,7 @@ export class NoteEditService implements OnApplicationShutdown {
cw: data.cw ?? null, cw: data.cw ?? null,
tags: tags.map(tag => normalizeForSearch(tag)), tags: tags.map(tag => normalizeForSearch(tag)),
emojis, emojis,
reactions: oldnote.reactions, reactions: oldNote.reactions,
userId: user.id, userId: user.id,
localOnly: data.localOnly!, localOnly: data.localOnly!,
reactionAcceptance: data.reactionAcceptance, reactionAcceptance: data.reactionAcceptance,
@ -535,7 +538,7 @@ export class NoteEditService implements OnApplicationShutdown {
renoteUserId: data.renote ? data.renote.userId : null, renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null, renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host, userHost: user.host,
reactionAndUserPairCache: oldnote.reactionAndUserPairCache, reactionAndUserPairCache: oldNote.reactionAndUserPairCache,
mandatoryCW: data.mandatoryCW, mandatoryCW: data.mandatoryCW,
}); });
@ -561,58 +564,55 @@ export class NoteEditService implements OnApplicationShutdown {
if (pollChanged) { if (pollChanged) {
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.update(MiNote, oldnote.id, note); await transactionalEntityManager.update(MiNote, oldNote.id, note);
const poll = new MiPoll({ // Insert or update poll
noteId: note.id, if (data.poll) {
choices: data.poll!.choices, const poll = new MiPoll({
expiresAt: data.poll!.expiresAt, noteId: note.id,
multiple: data.poll!.multiple, choices: data.poll.choices,
votes: new Array(data.poll!.choices.length).fill(0), expiresAt: data.poll.expiresAt,
noteVisibility: note.visibility, multiple: data.poll.multiple,
userId: user.id, votes: new Array(data.poll.choices.length).fill(0),
userHost: user.host, noteVisibility: note.visibility,
channelId: data.channel?.id ?? null, userId: user.id,
}); userHost: user.host,
channelId: data.channel?.id ?? null,
});
if (!oldnote.hasPoll) { if (oldPoll) {
await transactionalEntityManager.insert(MiPoll, poll); await transactionalEntityManager.update(MiPoll, { noteId: oldPoll.noteId }, poll);
} else { } else {
await transactionalEntityManager.update(MiPoll, oldnote.id, poll); await transactionalEntityManager.insert(MiPoll, poll);
}
// Delete poll
} else if (oldPoll) {
await transactionalEntityManager.delete(MiPoll, { noteId: oldPoll.noteId });
} }
}); });
} else { } else {
await this.notesRepository.update(oldnote.id, note); await this.notesRepository.update(oldNote.id, note);
} }
// Re-fetch note to get the default values of null / unset fields. // Re-fetch note to get the default values of null / unset fields.
const edited = await this.notesRepository.findOneByOrFail({ id: note.id }); const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
setImmediate('post edited', { signal: this.#shutdownController.signal }).then( await this.queueService.createPostNoteJob(note.id, silent, 'edit');
() => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return edited; return edited;
} else { } else {
return oldnote; return oldNote;
} }
} }
@bindThis @bindThis
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & { public async postNoteEdited(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
// Register host // Register host
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
if (isRemoteUser(user)) { if (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (note.renote && note.text || !note.renote) { if (note.renote && note.text || !note.renote) {
this.updateNotesCountQueue.enqueue(i.id, 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { notesCountDelta: 1 });
} }
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true); this.instanceChart.updateNote(i.host, note, true);
@ -621,15 +621,15 @@ export class NoteEditService implements OnApplicationShutdown {
} }
} }
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date }); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
// ハッシュタグ更新 // ハッシュタグ更新
this.pushToTl(note, user); await this.pushToTl(note, user);
if (data.poll && data.poll.expiresAt) { if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - this.timeService.now; const delay = data.poll.expiresAt.getTime() - this.timeService.now;
this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`); await this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
this.queueService.endedPollNotificationQueue.add(note.id, { await this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id, noteId: note.id,
}, { }, {
jobId: `pollEnd_${note.id}`, jobId: `pollEnd_${note.id}`,
@ -648,9 +648,9 @@ export class NoteEditService implements OnApplicationShutdown {
text: note.text ?? '', text: note.text ?? '',
}); });
this.roleService.addNoteToRoleTimeline(noteObj); await this.roleService.addNoteToRoleTimeline(noteObj);
this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj }); await this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
@ -673,16 +673,16 @@ export class NoteEditService implements OnApplicationShutdown {
if (!isThreadMuted && !muted) { if (!isThreadMuted && !muted) {
nm.push(data.reply.userId, 'edited'); nm.push(data.reply.userId, 'edited');
this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj); this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj);
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); await this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
} }
} }
} }
nm.notify(); await nm.notify();
//#region AP deliver //#region AP deliver
if (!data.localOnly && isLocalUser(user)) { if (!data.localOnly && isLocalUser(user)) {
trackTask(async () => { await trackTask(async () => {
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
@ -737,8 +737,8 @@ export class NoteEditService implements OnApplicationShutdown {
} }
if (data.channel) { if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, { await this.channelsRepository.update(data.channel.id, {
lastNotedAt: this.timeService.date, lastNotedAt: this.timeService.date,
}); });
@ -755,10 +755,10 @@ export class NoteEditService implements OnApplicationShutdown {
} }
// Update the Latest Note index / following feed // Update the Latest Note index / following feed
this.latestNoteService.handleUpdatedNoteBG(oldNote, note); await this.latestNoteService.handleUpdatedNoteDeferred(note);
// Register to search database // Register to search database
if (!user.noindex) this.index(note); if (!user.noindex) await this.index(note);
} }
@bindThis @bindThis
@ -779,27 +779,10 @@ export class NoteEditService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private index(note: MiNote) { private async index(note: MiNote) {
if (note.text == null && note.cw == null) return; if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note); await this.searchService.indexNote(note);
}
@bindThis
private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
if (tokens == null) return [];
const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m =>
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
))).filter(x => x !== null) as MiUser[];
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id),
);
return mentionedUsers;
} }
@bindThis @bindThis
@ -912,25 +895,14 @@ export class NoteEditService implements OnApplicationShutdown {
// checkHibernation moved to HibernateUsersProcessorService // checkHibernation moved to HibernateUsersProcessorService
} }
r.exec(); await r.exec();
} }
// checkHibernation moved to HibernateUsersProcessorService // checkHibernation moved to HibernateUsersProcessorService
@bindThis
private collapseNotesCount(oldValue: number, newValue: number) {
return oldValue + newValue;
}
@bindThis
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
}
@bindThis @bindThis
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
this.#shutdownController.abort(); this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
} }
@bindThis @bindThis

View file

@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
@Injectable() @Injectable()
@ -84,7 +85,7 @@ export class NotePiningService {
// Deliver to remote followers // Deliver to remote followers
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
await this.deliverPinnedChange(user, note.id, true); trackPromise(this.deliverPinnedChange(user, note.id, true));
} }
} }
@ -112,7 +113,7 @@ export class NotePiningService {
// Deliver to remote followers // Deliver to remote followers
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) { if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
await this.deliverPinnedChange(user, noteId, false); trackPromise(this.deliverPinnedChange(user, noteId, false));
} }
} }

View file

@ -72,9 +72,9 @@ export class NotificationService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private postReadAllNotifications(userId: MiUser['id']) { private async postReadAllNotifications(userId: MiUser['id']) {
this.globalEventService.publishMainStream(userId, 'readAllNotifications'); this.globalEventService.publishMainStream(userId, 'readAllNotifications');
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); await this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
} }
@bindThis @bindThis

View file

@ -92,7 +92,7 @@ export class PollService {
public async deliverQuestionUpdate(note: MiNote) { public async deliverQuestionUpdate(note: MiNote) {
if (note.localOnly) return; if (note.localOnly) return;
const user = await this.usersRepository.findOneBy({ id: note.userId }); const user = note.user ?? await this.usersRepository.findOneBy({ id: note.userId });
if (user == null) throw new Error('note not found'); if (user == null) throw new Error('note not found');
if (isLocalUser(user)) { if (isLocalUser(user)) {

View file

@ -20,6 +20,7 @@ import {
UserWebhookDeliverJobData, UserWebhookDeliverJobData,
SystemWebhookDeliverJobData, SystemWebhookDeliverJobData,
ScheduleNotePostJobData, ScheduleNotePostJobData,
BackgroundTaskJobData,
} from '../queue/types.js'; } from '../queue/types.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -33,6 +34,7 @@ export type ObjectStorageQueue = Bull.Queue;
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>; export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>; export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>; export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
export type BackgroundTaskQueue = Bull.Queue<BackgroundTaskJobData>;
const $system: Provider = { const $system: Provider = {
provide: 'queue:system', provide: 'queue:system',
@ -94,6 +96,12 @@ const $scheduleNotePost: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $backgroundTask: Provider = {
provide: 'queue:backgroundTask',
useFactory: (config: Config) => new Bull.Queue(QUEUE.BACKGROUND_TASK, baseQueueOptions(config, QUEUE.BACKGROUND_TASK)),
inject: [DI.config],
};
@Module({ @Module({
imports: [ imports: [
], ],
@ -108,6 +116,7 @@ const $scheduleNotePost: Provider = {
$userWebhookDeliver, $userWebhookDeliver,
$systemWebhookDeliver, $systemWebhookDeliver,
$scheduleNotePost, $scheduleNotePost,
$backgroundTask,
], ],
exports: [ exports: [
$system, $system,
@ -120,6 +129,7 @@ const $scheduleNotePost: Provider = {
$userWebhookDeliver, $userWebhookDeliver,
$systemWebhookDeliver, $systemWebhookDeliver,
$scheduleNotePost, $scheduleNotePost,
$backgroundTask,
], ],
}) })
export class QueueModule implements OnApplicationShutdown { export class QueueModule implements OnApplicationShutdown {
@ -136,6 +146,7 @@ export class QueueModule implements OnApplicationShutdown {
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
@Inject('queue:backgroundTask') public readonly backgroundTaskQueue: BackgroundTaskQueue,
) {} ) {}
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@ -155,6 +166,7 @@ export class QueueModule implements OnApplicationShutdown {
this.userWebhookDeliverQueue.close(), this.userWebhookDeliverQueue.close(),
this.systemWebhookDeliverQueue.close(), this.systemWebhookDeliverQueue.close(),
this.scheduleNotePostQueue.close(), this.scheduleNotePostQueue.close(),
this.backgroundTaskQueue.close(),
]).then(res => { ]).then(res => {
for (const result of res) { for (const result of res) {
if (result.status === 'rejected') { if (result.status === 'rejected') {

View file

@ -19,8 +19,10 @@ import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js'; import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MinimalNote } from '@/misc/is-renote.js';
import { type UserWebhookPayload } from './UserWebhookService.js'; import { type UserWebhookPayload } from './UserWebhookService.js';
import type { import type {
BackgroundTaskJobData,
DbJobData, DbJobData,
DeliverJobData, DeliverJobData,
RelationshipJobData, RelationshipJobData,
@ -39,6 +41,7 @@ import type {
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
ScheduleNotePostQueue, ScheduleNotePostQueue,
BackgroundTaskQueue,
} from './QueueModule.js'; } from './QueueModule.js';
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@ -54,6 +57,7 @@ export const QUEUE_TYPES = [
'userWebhookDeliver', 'userWebhookDeliver',
'systemWebhookDeliver', 'systemWebhookDeliver',
'scheduleNotePost', 'scheduleNotePost',
'backgroundTask',
] as const; ] as const;
@Injectable() @Injectable()
@ -72,6 +76,7 @@ export class QueueService implements OnModuleInit {
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue, @Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
@Inject('queue:backgroundTask') public readonly backgroundTaskQueue: BackgroundTaskQueue,
private readonly timeService: TimeService, private readonly timeService: TimeService,
) {} ) {}
@ -839,6 +844,107 @@ export class QueueService implements OnModuleInit {
}); });
} }
@bindThis
public async createUpdateUserJob(userId: string) {
return await this.createBackgroundTask({ type: 'update-user', userId }, userId);
}
@bindThis
public async createUpdateFeaturedJob(userId: string) {
return await this.createBackgroundTask({ type: 'update-featured', userId }, userId);
}
@bindThis
public async createUpdateInstanceJob(host: string) {
return await this.createBackgroundTask({ type: 'update-instance', host }, host);
}
@bindThis
public async createPostDeliverJob(host: string, result: 'success' | 'temp-fail' | 'perm-fail') {
return await this.createBackgroundTask({ type: 'post-deliver', host, result });
}
@bindThis
public async createPostInboxJob(host: string) {
return await this.createBackgroundTask({ type: 'post-inbox', host });
}
@bindThis
public async createPostNoteJob(noteId: string, silent: boolean, type: 'create' | 'edit') {
const edit = type === 'edit';
const duplication = `${noteId}_${type}`;
return await this.createBackgroundTask({ type: 'post-note', noteId, silent, edit }, duplication);
}
@bindThis
public async createUpdateUserTagsJob(userId: string) {
return await this.createBackgroundTask({ type: 'update-user-tags', userId }, userId);
}
@bindThis
public async createUpdateNoteTagsJob(noteId: string) {
return await this.createBackgroundTask({ type: 'update-note-tags', noteId }, noteId);
}
@bindThis
public async createDeleteFileJob(fileId: string, isExpired?: boolean, deleterId?: string) {
return await this.createBackgroundTask({ type: 'delete-file', fileId, isExpired, deleterId }, fileId);
}
@bindThis
public async createUpdateLatestNoteJob(note: MinimalNote) {
// Compact the note to avoid storing the entire thing in Redis, when all we need is minimal data for categorization
const minimizedNote: MinimalNote = {
id: note.id,
visibility: note.visibility,
userId: note.userId,
replyId: note.replyId,
renoteId: note.renoteId,
hasPoll: note.hasPoll,
text: note.text ? '1' : null,
cw: note.text ? '1' : null,
fileIds: note.fileIds.length > 0 ? ['1'] : [],
};
return await this.createBackgroundTask({ type: 'update-latest-note', note: minimizedNote }, note.id);
}
@bindThis
public async createPostSuspendJob(userId: string) {
return await this.createBackgroundTask({ type: 'post-suspend', userId }, userId);
}
@bindThis
public async createPostUnsuspendJob(userId: string) {
return await this.createBackgroundTask({ type: 'post-unsuspend', userId }, userId);
}
@bindThis
public async createDeleteApLogsJob(dataType: 'inbox' | 'object', data: string | string[]) {
return await this.createBackgroundTask({ type: 'delete-ap-logs', dataType, data });
}
private async createBackgroundTask<T extends BackgroundTaskJobData>(data: T, duplication?: string | { id: string, ttl?: number }) {
return await this.backgroundTaskQueue.add(
data.type,
data,
{
// https://docs.bullmq.io/guide/retrying-failing-jobs#custom-back-off-strategies
attempts: this.config.backgroundJobMaxAttempts ?? 8,
backoff: {
// Resolves to QueueProcessorService::HttpRelatedBackoff()
type: 'custom',
},
// https://docs.bullmq.io/guide/jobs/deduplication
deduplication: typeof(duplication) === 'string'
? { id: `${data.type}_${duplication}` }
: duplication,
},
);
};
/** /**
* @see UserWebhookDeliverJobData * @see UserWebhookDeliverJobData
* @see UserWebhookDeliverProcessorService * @see UserWebhookDeliverProcessorService
@ -927,6 +1033,7 @@ export class QueueService implements OnModuleInit {
case 'userWebhookDeliver': return this.userWebhookDeliverQueue; case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue; case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
case 'scheduleNotePost': return this.ScheduleNotePostQueue; case 'scheduleNotePost': return this.ScheduleNotePostQueue;
case 'backgroundTask': return this.backgroundTaskQueue;
default: throw new Error(`Unrecognized queue type: ${type}`); default: throw new Error(`Unrecognized queue type: ${type}`);
} }
} }

View file

@ -33,6 +33,7 @@ import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
const FALLBACK = '\u2764'; const FALLBACK = '\u2764';
@ -110,6 +111,7 @@ export class ReactionService implements OnModuleInit {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService, private readonly noteVisibilityService: NoteVisibilityService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly collapsedQueueService: CollapsedQueueService,
) { ) {
} }
@ -119,7 +121,7 @@ export class ReactionService implements OnModuleInit {
} }
@bindThis @bindThis
public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { public async create(user: MiUser, note: MiNote, _reaction?: string | null) {
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -224,7 +226,7 @@ export class ReactionService implements OnModuleInit {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date }); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新 // 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if ( if (
@ -289,16 +291,18 @@ export class ReactionService implements OnModuleInit {
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
const dm = this.apDeliverManagerService.createDeliverManager(user, content); const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId }); const reactee = await this.cacheService.findRemoteUserById(note.userId);
dm.addDirectRecipe(reactee as MiRemoteUser); dm.addDirectRecipe(reactee as MiRemoteUser);
} }
if (['public', 'home', 'followers'].includes(note.visibility)) { if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe(); dm.addFollowersRecipe();
} else if (note.visibility === 'specified') { } else if (note.visibility === 'specified') {
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id }))); const visibleUsers = await this.cacheService.findUsersById(note.visibleUserIds);
for (const u of visibleUsers.filter(u => u && isRemoteUser(u))) { for (const u of visibleUsers.values()) {
dm.addDirectRecipe(u as MiRemoteUser); if (isRemoteUser(u)) {
dm.addDirectRecipe(u as MiRemoteUser);
}
} }
} }
@ -308,7 +312,7 @@ export class ReactionService implements OnModuleInit {
} }
@bindThis @bindThis
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) { public async delete(user: MiUser, note: MiNote, exist?: MiNoteReaction | null) {
// if already unreacted // if already unreacted
exist ??= await this.noteReactionsRepository.findOneBy({ exist ??= await this.noteReactionsRepository.findOneBy({
noteId: note.id, noteId: note.id,
@ -340,7 +344,7 @@ export class ReactionService implements OnModuleInit {
.execute(); .execute();
} }
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date }); await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
this.globalEventService.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,
@ -352,7 +356,7 @@ export class ReactionService implements OnModuleInit {
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
const dm = this.apDeliverManagerService.createDeliverManager(user, content); const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId }); const reactee = await this.cacheService.findRemoteUserById(note.userId);
dm.addDirectRecipe(reactee as MiRemoteUser); dm.addDirectRecipe(reactee as MiRemoteUser);
} }
dm.addFollowersRecipe(); dm.addFollowersRecipe();

View file

@ -8,7 +8,7 @@ import chalk from 'chalk';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
@ -59,7 +59,7 @@ export class RemoteUserResolveService {
const acct = Acct.toString({ username, host }); // username+host -> acct (handle) const acct = Acct.toString({ username, host }); // username+host -> acct (handle)
// Try fetch from DB // Try fetch from DB
let user = await this.cacheService.findUserByAcct(acct).catch(() => null); // Error is expected if the user doesn't exist yet let user: MiUser | null | undefined = await this.cacheService.findOptionalUserByAcct(acct);
// Opportunistically update remote users // Opportunistically update remote users
if (user != null && isRemoteUser(user)) { if (user != null && isRemoteUser(user)) {

View file

@ -587,6 +587,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null, updatedAt: parsed.user1.updatedAt != null ? new Date(parsed.user1.updatedAt) : null,
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null, lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
lastFetchedFeaturedAt: parsed.user1.lastFetchedFeaturedAt != null ? new Date(parsed.user1.lastFetchedFeaturedAt) : null,
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
instance: null, instance: null,
userProfile: null, userProfile: null,
@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null, updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null, lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
lastFetchedFeaturedAt: parsed.user2.lastFetchedFeaturedAt != null ? new Date(parsed.user2.lastFetchedFeaturedAt) : null,
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
instance: null, instance: null,
userProfile: null, userProfile: null,

View file

@ -844,7 +844,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
} }
redisPipeline.exec(); await redisPipeline.exec();
} }
@bindThis @bindThis

View file

@ -91,7 +91,7 @@ export class S3Service implements OnApplicationShutdown {
@bindThis @bindThis
public async upload(input: PutObjectCommandInput) { public async upload(input: PutObjectCommandInput) {
const client = this.getS3Client(); const client = this.getS3Client();
return new Upload({ return await new Upload({
client, client,
params: input, params: input,
partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com')

View file

@ -256,10 +256,10 @@ export class SearchService {
case 'sqlTsvector': { case 'sqlTsvector': {
// ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている. // ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
// 今後の拡張で差が出る用であれば関数を分ける. // 今後の拡張で差が出る用であれば関数を分ける.
return this.searchNoteByLike(q, me, opts, pagination); return await this.searchNoteByLike(q, me, opts, pagination);
} }
case 'meilisearch': { case 'meilisearch': {
return this.searchNoteByMeiliSearch(q, me, opts, pagination); return await this.searchNoteByMeiliSearch(q, me, opts, pagination);
} }
default: { default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars

View file

@ -61,6 +61,7 @@ export class SponsorsService {
} }
try { try {
// TODO use HTTP service
const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise<Sponsor[]>); const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise<Sponsor[]>);
// Merge both together into one array and make sure it only has Active subscriptions // Merge both together into one array and make sure it only has Active subscriptions
@ -76,6 +77,7 @@ export class SponsorsService {
@bindThis @bindThis
private async fetchSharkeySponsors(): Promise<Sponsor[]> { private async fetchSharkeySponsors(): Promise<Sponsor[]> {
try { try {
// TODO use HTTP service
const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise<Sponsor[]>); const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise<Sponsor[]>);
const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise<Sponsor[]>); const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise<Sponsor[]>);
@ -92,12 +94,12 @@ export class SponsorsService {
@bindThis @bindThis
public async instanceSponsors(forceUpdate: boolean) { public async instanceSponsors(forceUpdate: boolean) {
if (forceUpdate) await this.cache.refresh('instance'); if (forceUpdate) await this.cache.refresh('instance');
return this.cache.fetch('instance'); return await this.cache.fetch('instance');
} }
@bindThis @bindThis
public async sharkeySponsors(forceUpdate: boolean) { public async sharkeySponsors(forceUpdate: boolean) {
if (forceUpdate) await this.cache.refresh('sharkey'); if (forceUpdate) await this.cache.refresh('sharkey');
return this.cache.fetch('sharkey'); return await this.cache.fetch('sharkey');
} }
} }

View file

@ -1,54 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { bindThis } from '@/decorators.js';
import { MiNote } from '@/models/Note.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { TimeService } from '@/global/TimeService.js';
type UpdateInstanceJob = {
latestRequestReceivedAt: Date,
shouldUnsuspend: boolean,
};
// Moved from InboxProcessorService to allow access from ApInboxService
@Injectable()
export class UpdateInstanceQueue extends CollapsedQueue<MiNote['id'], UpdateInstanceJob> implements OnApplicationShutdown {
constructor(
private readonly federatedInstanceService: FederatedInstanceService,
timeService: TimeService,
) {
super(timeService, process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, (id, job) => this.collapseUpdateInstanceJobs(id, job), (id, job) => this.performUpdateInstance(id, job));
}
@bindThis
private collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) {
const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt
? newJob.latestRequestReceivedAt
: oldJob.latestRequestReceivedAt;
const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend;
return {
latestRequestReceivedAt,
shouldUnsuspend,
};
}
@bindThis
private async performUpdateInstance(id: string, job: UpdateInstanceJob) {
await this.federatedInstanceService.update(id, {
latestRequestReceivedAt: this.timeService.date,
isNotResponding: false,
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
suspensionState: job.shouldUnsuspend ? 'none' : undefined,
});
}
@bindThis
async onApplicationShutdown() {
await this.performAllNow();
}
}

View file

@ -30,6 +30,8 @@ import { UtilityService } from '@/core/UtilityService.js';
import type { ThinUser } from '@/queue/types.js'; import type { ThinUser } from '@/queue/types.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { InternalEventService } from '@/global/InternalEventService.js'; import { InternalEventService } from '@/global/InternalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import type Logger from '../logger.js'; import type Logger from '../logger.js';
type Local = MiLocalUser | { type Local = MiLocalUser | {
@ -88,6 +90,7 @@ export class UserFollowingService implements OnModuleInit {
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private readonly internalEventService: InternalEventService, private readonly internalEventService: InternalEventService,
private readonly collapsedQueueService: CollapsedQueueService,
loggerService: LoggerService, loggerService: LoggerService,
) { ) {
@ -102,7 +105,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) { public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox, false); await this.queueService.deliver(followee, content, follower.inbox, false);
} }
@bindThis @bindThis
@ -152,7 +155,7 @@ export class UserFollowingService implements OnModuleInit {
// すでにフォロー関係が存在している場合 // すでにフォロー関係が存在している場合
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
// リモート → ローカル: acceptを送り返しておしまい // リモート → ローカル: acceptを送り返しておしまい
this.deliverAccept(follower, followee, requestId); trackPromise(this.deliverAccept(follower, followee, requestId));
return; return;
} }
if (this.userEntityService.isLocalUser(follower)) { if (this.userEntityService.isLocalUser(follower)) {
@ -206,7 +209,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, silent, withReplies); await this.insertFollowingDoc(followee, follower, silent, withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.deliverAccept(follower, followee, requestId); trackPromise(this.deliverAccept(follower, followee, requestId));
} }
} }
@ -285,24 +288,22 @@ export class UserFollowingService implements OnModuleInit {
// Neither followee nor follower has moved. // Neither followee nor follower has moved.
if (!followeeUser.movedToUri && !followerUser.movedToUri) { if (!followeeUser.movedToUri && !followerUser.movedToUri) {
//#region Increment counts //#region Increment counts
await Promise.all([ await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: 1 });
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: 1 });
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
]);
//#endregion //#endregion
//#region Update instance stats //#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followingCountDelta: 1 });
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true); this.instanceChart.updateFollowing(i.host, true);
} }
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followersCountDelta: 1 });
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true); this.instanceChart.updateFollowers(i.host, true);
} }
@ -397,24 +398,22 @@ export class UserFollowingService implements OnModuleInit {
// Neither followee nor follower has moved. // Neither followee nor follower has moved.
if (!follower.movedToUri && !followee.movedToUri) { if (!follower.movedToUri && !followee.movedToUri) {
//#region Decrement following / followers counts //#region Decrement following / followers counts
await Promise.all([ await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: -1 });
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: -1 });
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
]);
//#endregion //#endregion
//#region Update instance stats //#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followingCountDelta: -1 });
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false); this.instanceChart.updateFollowing(i.host, false);
} }
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { followersCountDelta: -1 });
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false); this.instanceChart.updateFollowers(i.host, false);
} }
@ -581,7 +580,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, false, request.withReplies); await this.insertFollowingDoc(followee, follower, false, request.withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined); trackPromise(this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined));
} }
this.userEntityService.pack(followee.id, followee, { this.userEntityService.pack(followee.id, followee, {
@ -595,14 +594,13 @@ export class UserFollowingService implements OnModuleInit {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']; id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'];
}, },
): Promise<void> { ): Promise<void> {
const requests = await this.followRequestsRepository.findBy({ const requests = await this.followRequestsRepository.find({ where: {
followeeId: user.id, followeeId: user.id,
}); }, relations: {
follower: true,
} });
for (const request of requests) { await Promise.all(requests.map(request => this.acceptFollowRequest(user, request.follower as MiUser)));
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
this.acceptFollowRequest(user, follower);
}
} }
/** /**
@ -611,7 +609,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> { public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) { if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower); trackPromise(this.deliverReject(user, follower));
} }
await this.removeFollowRequest(user, follower); await this.removeFollowRequest(user, follower);
@ -627,7 +625,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async rejectFollow(user: Local, follower: Both): Promise<void> { public async rejectFollow(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) { if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower); trackPromise(this.deliverReject(user, follower));
} }
await this.removeFollow(user, follower); await this.removeFollow(user, follower);
@ -696,7 +694,7 @@ export class UserFollowingService implements OnModuleInit {
}); });
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
this.queueService.deliver(followee, content, follower.inbox, false); await this.queueService.deliver(followee, content, follower.inbox, false);
} }
/** /**
@ -720,7 +718,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis @bindThis
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
return this.cacheService.isFollowing(followerId, followeeId); return await this.cacheService.isFollowing(followerId, followeeId);
} }
@bindThis @bindThis

View file

@ -99,7 +99,7 @@ export class UserSearchService {
} }
} }
return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>( return await this.userEntityService.packMany<'UserLite' | 'UserDetailed'>(
[...resultSet].slice(0, limit), [...resultSet].slice(0, limit),
me, me,
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' }, { schema: opts?.detail ? 'UserDetailed' : 'UserLite' },

View file

@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js'; import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
@Injectable() @Injectable()
@ -22,43 +22,14 @@ export class UserService {
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private systemWebhookService: SystemWebhookService, private systemWebhookService: SystemWebhookService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private readonly cacheService: CacheService, private readonly collapsedQueueService: CollapsedQueueService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
) { ) {
} }
@bindThis @bindThis
public async updateLastActiveDate(user: MiUser): Promise<void> { public async updateLastActiveDate(user: MiUser): Promise<void> {
if (user.isHibernated) { await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { lastActiveDate: this.timeService.date });
const result = await this.usersRepository.createQueryBuilder().update()
.set({
lastActiveDate: this.timeService.date,
})
.where('id = :id', { id: user.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
const wokeUp = result.isHibernated;
if (wokeUp) {
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, {
lastActiveDate: this.timeService.date,
});
}
} }
/** /**
@ -70,6 +41,6 @@ export class UserService {
@bindThis @bindThis
public async notifySystemWebhook(user: MiUser, type: 'userCreated') { public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' }); const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
return this.systemWebhookService.enqueueSystemWebhook(type, packedUser); return await this.systemWebhookService.enqueueSystemWebhook(type, packedUser);
} }
} }

View file

@ -17,16 +17,10 @@ import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isSystemAccount } from '@/misc/is-system-account.js'; import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { InternalEventService } from '@/global/InternalEventService.js'; import { InternalEventService } from '@/global/InternalEventService.js';
@Injectable() @Injectable()
export class UserSuspendService { export class UserSuspendService {
private readonly logger: Logger;
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -47,11 +41,7 @@ export class UserSuspendService {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly internalEventService: InternalEventService, private readonly internalEventService: InternalEventService,
) {}
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('user-suspend');
}
@bindThis @bindThis
public async suspend(user: MiUser, moderator: MiUser): Promise<void> { public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
@ -69,10 +59,7 @@ export class UserSuspendService {
userHost: user.host, userHost: user.host,
}); });
trackPromise((async () => { await this.queueService.createPostSuspendJob(user.id);
await this.postSuspend(user);
await this.freezeAll(user);
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
} }
@bindThis @bindThis
@ -89,14 +76,11 @@ export class UserSuspendService {
userHost: user.host, userHost: user.host,
}); });
trackPromise((async () => { await this.queueService.createPostUnsuspendJob(user.id);
await this.postUnsuspend(user);
await this.unFreezeAll(user);
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
} }
@bindThis @bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> { public async postSuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
/* /*
@ -132,10 +116,12 @@ export class UserSuspendService {
await this.queueService.deliverMany(user, content, queue); await this.queueService.deliverMany(user, content, queue);
} }
await this.freezeAll(user);
} }
@bindThis @bindThis
private async postUnsuspend(user: MiUser): Promise<void> { public async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
@ -162,6 +148,8 @@ export class UserSuspendService {
await this.queueService.deliverMany(user, content, queue); await this.queueService.deliverMany(user, content, queue);
} }
await this.unFreezeAll(user);
} }
@bindThis @bindThis

View file

@ -94,7 +94,7 @@ export class UserWebhookService implements OnApplicationShutdown {
) { ) {
const webhooks = await this.getActiveWebhooks() const webhooks = await this.getActiveWebhooks()
.then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type))); .then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type)));
return Promise.all( return await Promise.all(
webhooks.map(webhook => { webhooks.map(webhook => {
return this.queueService.userWebhookDeliver(webhook, type, content); return this.queueService.userWebhookDeliver(webhook, type, content);
}), }),

View file

@ -8,6 +8,7 @@ import promiseLimit from 'promise-limit';
import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js';
import { concat, unique } from '@/misc/prelude/array.js'; import { concat, unique } from '@/misc/prelude/array.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { promiseMap } from '@/misc/promise-map.js';
import { getApIds } from './type.js'; import { getApIds } from './type.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import type { ApObject } from './type.js'; import type { ApObject } from './type.js';
@ -37,10 +38,12 @@ export class ApAudienceService {
const others = unique(concat([toGroups.other, ccGroups.other])); const others = unique(concat([toGroups.other, ccGroups.other]));
const limit = promiseLimit<MiUser | null>(2); const resolved = await promiseMap(others, async x => {
const mentionedUsers = (await Promise.all( return await this.apPersonService.resolvePerson(x, resolver).catch(() => null) as MiUser | null;
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), }, {
)).filter(x => x != null); limit: 2,
});
const mentionedUsers = resolved.filter(x => x != null);
// If no audience is specified, then assume public // If no audience is specified, then assume public
if ( if (

View file

@ -92,10 +92,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
key: MiUserPublickey; key: MiUserPublickey;
} | null> { } | null> {
const key = await this.apPersonService.findPublicKeyByKeyId(keyId); const key = await this.apPersonService.findPublicKeyByKeyId(keyId);
if (key == null) return null; if (key == null) return null;
const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null; const user = await this.cacheService.findOptionalRemoteUserById(key.userId);
if (user == null) return null; if (user == null) return null;
if (user.isDeleted) return null; if (user.isDeleted) return null;

View file

@ -33,10 +33,6 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { fromTuple } from '@/misc/from-tuple.js'; import { fromTuple } from '@/misc/from-tuple.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { renderInlineError } from '@/misc/render-inline-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';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js'; import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
@ -97,10 +93,6 @@ export class ApInboxService {
private queueService: QueueService, private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private readonly federatedInstanceService: FederatedInstanceService, private readonly federatedInstanceService: FederatedInstanceService,
private readonly fetchInstanceMetadataService: FetchInstanceMetadataService,
private readonly instanceChart: InstanceChart,
private readonly federationChart: FederationChart,
private readonly updateInstanceQueue: UpdateInstanceQueue,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService, private readonly noteVisibilityService: NoteVisibilityService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
@ -115,7 +107,7 @@ export class ApInboxService {
const results = [] as [string, string | void][]; const results = [] as [string, string | void][];
resolver ??= this.apResolverService.createResolver(); resolver ??= this.apResolverService.createResolver();
const items = await resolver.resolveCollectionItems(activity); const items = await resolver.resolveCollectionItems(activity, true, getNullableApId(activity) ?? undefined);
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const act = items[i]; const act = items[i];
if (act.id != null) { if (act.id != null) {
@ -153,11 +145,10 @@ export class ApInboxService {
// ついでにリモートユーザーの情報が古かったら更新しておく // ついでにリモートユーザーの情報が古かったら更新しておく
if (actor.uri) { if (actor.uri) {
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => { {
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri) await this.apPersonService.updatePersonLazy(actor);
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`)); }
});
} }
} }
return result; return result;
@ -424,42 +415,14 @@ export class ApInboxService {
} }
// Update stats (adapted from InboxProcessorService) // Update stats (adapted from InboxProcessorService)
this.federationChart.inbox(actor.host).then(); await this.queueService.createPostInboxJob(actor.host);
process.nextTick(async () => {
const i = await (this.meta.enableStatsForFederatedInstances
? this.federatedInstanceService.fetchOrRegister(actor.host)
: this.federatedInstanceService.fetch(actor.host));
if (i == null) return;
this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: this.timeService.date,
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
});
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host).then();
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i).then();
});
// Process it! // Process it!
return await this.performOneActivity(actor, activity, resolver) try {
.finally(() => { return await this.performOneActivity(actor, activity, resolver);
// Update user (adapted from performActivity) } finally {
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { await this.apPersonService.updatePersonLazy(actor);
setImmediate(() => { }
// Don't re-use the resolver, or it may throw recursion errors.
// Instead, create a new resolver with an appropriately-reduced recursion limit.
const subResolver = this.apResolverService.createResolver({
recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length,
});
this.apPersonService.updatePerson(actor.uri, subResolver)
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
});
}
});
} }
@bindThis @bindThis

View file

@ -5,7 +5,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm'; import { IsNull, Not } from 'typeorm';
import promiseLimit from 'promise-limit';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -23,6 +22,9 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js'; import { toArray } from '@/misc/prelude/array.js';
import { isPureRenote } from '@/misc/is-renote.js'; import { isPureRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { promiseMap } from '@/misc/promise-map.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js'; import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
@ -68,27 +70,21 @@ export class Resolver {
return this.recursionLimit; return this.recursionLimit;
} }
public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>;
public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>;
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>;
@bindThis @bindThis
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> { public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
const collection = typeof value === 'string' const collection = sentFromUri
? sentFromUri ? await this.secureResolve(value, sentFromUri, allowAnonymous)
? await this.secureResolve(value, sentFromUri, allowAnonymous) : allowAnonymous
: await this.resolve(value, allowAnonymous) ? await this.resolveAnonymous(value)
: value; // TODO try and remove this eventually, as it's a major security foot-gun : await this.resolve(value, allowAnonymous);
if (isCollectionOrOrderedCollection(collection)) { if (isCollectionOrOrderedCollection(collection)) {
return collection; return collection;
} else { } else {
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getApId(value)} has unsupported type: ${collection.type}`); throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getNullableApId(value)} has unsupported type: ${collection.type}`);
} }
} }
public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>;
public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>;
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>;
/** /**
* Recursively resolves items from a collection. * Recursively resolves items from a collection.
* Stops when reaching the resolution limit or an optional item limit - whichever is lower. * Stops when reaching the resolution limit or an optional item limit - whichever is lower.
@ -96,11 +92,13 @@ export class Resolver {
* Malformed collections (mixing Ordered and un-Ordered types) are also supported. * Malformed collections (mixing Ordered and un-Ordered types) are also supported.
* @param collection Collection to resolve from - can be a URL or object of any supported collection type. * @param collection Collection to resolve from - can be a URL or object of any supported collection type.
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit. * @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
* @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID. * @param allowAnonymous If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
* @param sentFromUri If collection is an object, this is the URI where it was sent from.
* @param concurrency Maximum number of items to resolve at once. (default: 4) * @param concurrency Maximum number of items to resolve at once. (default: 4)
* @param ignoreErrors If true (default), inaccessible items will be skipped instead of causing an exception. Inaccessible collections will still throw.
*/ */
@bindThis @bindThis
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> { public async resolveCollectionItems(collection: string | IObject, allowAnonymous = false, sentFromUri?: string, limit?: number | null, concurrency = 4, ignoreErrors = true): Promise<IObject[]> {
const resolvedItems: IObject[] = []; const resolvedItems: IObject[] = [];
// This is pulled up to avoid code duplication below // This is pulled up to avoid code duplication below
@ -108,11 +106,10 @@ export class Resolver {
const sentFrom = current.id; const sentFrom = current.id;
const itemArr = toArray(items); const itemArr = toArray(items);
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
const allowAnonymous = allowAnonymousItems ?? false; await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems, ignoreErrors);
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
}; };
let current: AnyCollection | null = await this.resolveCollection(collection); let current: AnyCollection | null = await this.resolveCollection(collection, allowAnonymous, sentFromUri);
do { do {
// Iterate all items in the current page // Iterate all items in the current page
if (current.items) { if (current.items) {
@ -130,10 +127,10 @@ export class Resolver {
current = null; current = null;
} else if (isCollection(current) || isOrderedCollection(current)) { } else if (isCollection(current) || isOrderedCollection(current)) {
// Continue to first page // Continue to first page
current = current.first ? await this.resolveCollection(current.first, true, current.id) : null; current = current.first ? await this.resolveCollection(current.first, allowAnonymous, current.id) : null;
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) { } else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
// Continue to next page // Continue to next page
current = current.next ? await this.resolveCollection(current.next, true, current.id) : null; current = current.next ? await this.resolveCollection(current.next, allowAnonymous, current.id) : null;
} else { } else {
// Stop in all other conditions // Stop in all other conditions
current = null; current = null;
@ -143,17 +140,12 @@ export class Resolver {
return resolvedItems; return resolvedItems;
} }
private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>; private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[], ignoreErrors?: boolean): Promise<void> {
private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>;
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>;
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> {
const recursionLimit = this.recursionLimit - this.history.size; const recursionLimit = this.recursionLimit - this.history.size;
const batchLimit = Math.min(source.length, recursionLimit, itemLimit); const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
const limiter = promiseLimit<IObject>(concurrency); const batch = await promiseMap(source.slice(0, batchLimit), async item => {
const batch = await Promise.all(source try {
.slice(0, batchLimit)
.map(item => limiter(async () => {
if (sentFrom) { if (sentFrom) {
// Use secureResolve to avoid re-fetching items that were included inline. // Use secureResolve to avoid re-fetching items that were included inline.
return await this.secureResolve(item, sentFrom, allowAnonymousItems); return await this.secureResolve(item, sentFrom, allowAnonymousItems);
@ -164,9 +156,22 @@ export class Resolver {
const id = getApId(item); const id = getApId(item);
return await this.resolve(id); return await this.resolve(id);
} }
}))); } catch (err) {
if (ignoreErrors) {
this.logger.warn(`Ignoring error in collection item ${getNullableApId(item)}: ${renderInlineError(err)}`);
return null;
} else {
throw err;
}
}
}, {
limit: concurrency,
});
destination.push(...batch); // Items will be null if a request fails and ignoreErrors is true
const batchItems = batch.filter(item => item != null);
destination.push(...batchItems);
}; };
/** /**
@ -269,8 +274,8 @@ export class Resolver {
log.duration = calculateDurationSince(startTime); log.duration = calculateDurationSince(startTime);
// Save or finalize asynchronously // Save or finalize asynchronously
this.apLogService.saveFetchLog(log) trackPromise(this.apLogService.saveFetchLog(log)
.catch(err => this.logger.error('Failed to record AP object fetch:', err)); .catch(err => this.logger.error('Failed to record AP object fetch:', err)));
} }
} }

View file

@ -134,7 +134,7 @@ export class JsonLdService {
const customLoader = this.getLoader(); const customLoader = this.getLoader();
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
return (await import('jsonld')).default.compact(data, context, { return await (await import('jsonld')).default.compact(data, context, {
documentLoader: customLoader, documentLoader: customLoader,
}); });
} }
@ -142,7 +142,7 @@ export class JsonLdService {
@bindThis @bindThis
public async normalize(data: Document): Promise<string> { public async normalize(data: Document): Promise<string> {
const customLoader = this.getLoader(); const customLoader = this.getLoader();
return (await import('jsonld')).default.normalize(data, { return await (await import('jsonld')).default.normalize(data, {
documentLoader: customLoader, documentLoader: customLoader,
}); });
} }

View file

@ -12,6 +12,7 @@ import { isMention } from '../type.js';
import { Resolver } from '../ApResolverService.js'; import { Resolver } from '../ApResolverService.js';
import { ApPersonService } from './ApPersonService.js'; import { ApPersonService } from './ApPersonService.js';
import type { IObject, IApMention } from '../type.js'; import type { IObject, IApMention } from '../type.js';
import { promiseMap } from '@/misc/promise-map.js';
@Injectable() @Injectable()
export class ApMentionService { export class ApMentionService {
@ -24,12 +25,13 @@ export class ApMentionService {
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<MiUser[]> { public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<MiUser[]> {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href)); const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
const limit = promiseLimit<MiUser | null>(2); const mentionedUsers = await promiseMap(hrefs, async x => {
const mentionedUsers = (await Promise.all( return await this.apPersonService.resolvePerson(x, resolver).catch(() => null) as MiUser | null;
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), }, {
)).filter(x => x != null); limit: 2,
});
return mentionedUsers; return mentionedUsers.filter(resolved => resolved != null);
} }
@bindThis @bindThis

View file

@ -6,7 +6,6 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq'; import { UnrecoverableError } from 'bullmq';
import promiseLimit from 'promise-limit';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js'; import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
@ -32,6 +31,7 @@ import { renderInlineError } from '@/misc/render-inline-error.js';
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js'; import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js'; import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js'; import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
import { promiseMap } from '@/misc/promise-map.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { CustomEmojiService, encodeEmojiKey, isValidEmojiName } from '@/core/CustomEmojiService.js'; import { CustomEmojiService, encodeEmojiKey, isValidEmojiName } from '@/core/CustomEmojiService.js';
import { TimeService } from '@/global/TimeService.js'; import { TimeService } from '@/global/TimeService.js';
@ -277,7 +277,7 @@ export class ApNoteService implements OnModuleInit {
return x; return x;
}) })
.catch(async err => { .catch(err => {
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err); throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
}) })
@ -456,7 +456,7 @@ export class ApNoteService implements OnModuleInit {
return x; return x;
}) })
.catch(async err => { .catch(err => {
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`); this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err); throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
}) })
@ -583,8 +583,8 @@ export class ApNoteService implements OnModuleInit {
const emojiKeys = eomjiTags.map(tag => encodeEmojiKey({ name: tag.name, host })); const emojiKeys = eomjiTags.map(tag => encodeEmojiKey({ name: tag.name, host }));
const existingEmojis = await this.customEmojiService.emojisByKeyCache.fetchMany(emojiKeys); const existingEmojis = await this.customEmojiService.emojisByKeyCache.fetchMany(emojiKeys);
return await Promise.all(eomjiTags.map(async tag => { return await promiseMap(eomjiTags, async tag => {
const name = tag.name; const name = tag.name.replaceAll(':', '');
tag.icon = toSingle(tag.icon); tag.icon = toSingle(tag.icon);
const exists = existingEmojis.values.find(x => x.name === name); const exists = existingEmojis.values.find(x => x.name === name);
@ -627,7 +627,9 @@ export class ApNoteService implements OnModuleInit {
// _misskey_license が存在しなければ `null` // _misskey_license が存在しなければ `null`
license: (tag._misskey_license?.freeText ?? null), license: (tag._misskey_license?.freeText ?? null),
}); });
})); }, {
limit: 4,
});
} }
/** /**
@ -691,7 +693,7 @@ export class ApNoteService implements OnModuleInit {
} }
}; };
const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u))); const results = await promiseMap(quoteUris, async u => resolveQuote(u), { limit: 2 });
// Success - return the quote // Success - return the quote
const quote = results.find(r => typeof(r) === 'object'); const quote = results.find(r => typeof(r) === 'object');
@ -753,14 +755,10 @@ export class ApNoteService implements OnModuleInit {
// Resolve all files w/ concurrency 2. // Resolve all files w/ concurrency 2.
// This prevents one big file from blocking the others. // This prevents one big file from blocking the others.
const limiter = promiseLimit<MiDriveFile | null>(2); const results = await promiseMap(attachments.values(), async attach => {
const results = await Promise attach.sensitive ??= note.sensitive;
.all(Array return await this.resolveImage(actor, attach);
.from(attachments.values()) }, { limit: 2 });
.map(attach => limiter(async () => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
})));
// Process results // Process results
let hasFileError = false; let hasFileError = false;

View file

@ -24,7 +24,6 @@ import type { MiNote } from '@/models/Note.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { MfmService } from '@/core/MfmService.js'; import type { MfmService } from '@/core/MfmService.js';
import { toArray } from '@/misc/prelude/array.js'; import { toArray } from '@/misc/prelude/array.js';
import type { GlobalEventService } from '@/core/GlobalEventService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
@ -45,9 +44,12 @@ import { TimeService } from '@/global/TimeService.js';
import { verifyFieldLinks } from '@/misc/verify-field-link.js'; import { verifyFieldLinks } from '@/misc/verify-field-link.js';
import { isRetryableError } from '@/misc/is-retryable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { errorCodes, IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { InternalEventService } from '@/global/InternalEventService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import { promiseMap } from '@/misc/promise-map.js';
import { getApId, getApType, getNullableApId, isActor, isPost, isPropertyValue } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -72,7 +74,6 @@ export class ApPersonService implements OnModuleInit {
private readonly publicKeyByUserIdCache: ManagedQuantumKVCache<MiUserPublickey>; private readonly publicKeyByUserIdCache: ManagedQuantumKVCache<MiUserPublickey>;
private driveFileEntityService: DriveFileEntityService; private driveFileEntityService: DriveFileEntityService;
private globalEventService: GlobalEventService;
private federatedInstanceService: FederatedInstanceService; private federatedInstanceService: FederatedInstanceService;
private fetchInstanceMetadataService: FetchInstanceMetadataService; private fetchInstanceMetadataService: FetchInstanceMetadataService;
private cacheService: CacheService; private cacheService: CacheService;
@ -86,6 +87,7 @@ export class ApPersonService implements OnModuleInit {
private instanceChart: InstanceChart; private instanceChart: InstanceChart;
private accountMoveService: AccountMoveService; private accountMoveService: AccountMoveService;
private logger: Logger; private logger: Logger;
private idService: IdService;
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@ -120,9 +122,10 @@ export class ApPersonService implements OnModuleInit {
private readonly cacheManagementService: CacheManagementService, private readonly cacheManagementService: CacheManagementService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
private readonly apUtilityService: ApUtilityService, private readonly apUtilityService: ApUtilityService,
private readonly idService: IdService,
private readonly timeService: TimeService, private readonly timeService: TimeService,
private readonly queueService: QueueService, private readonly queueService: QueueService,
private readonly collapsedQueueService: CollapsedQueueService,
private readonly internalEventService: InternalEventService,
apLoggerService: ApLoggerService, apLoggerService: ApLoggerService,
) { ) {
@ -181,7 +184,6 @@ export class ApPersonService implements OnModuleInit {
@bindThis @bindThis
onModuleInit(): void { onModuleInit(): void {
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.globalEventService = this.moduleRef.get('GlobalEventService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
this.cacheService = this.moduleRef.get('CacheService'); this.cacheService = this.moduleRef.get('CacheService');
@ -194,6 +196,7 @@ export class ApPersonService implements OnModuleInit {
this.usersChart = this.moduleRef.get('UsersChart'); this.usersChart = this.moduleRef.get('UsersChart');
this.instanceChart = this.moduleRef.get('InstanceChart'); this.instanceChart = this.moduleRef.get('InstanceChart');
this.accountMoveService = this.moduleRef.get('AccountMoveService'); this.accountMoveService = this.moduleRef.get('AccountMoveService');
this.idService = this.moduleRef.get('IdService');
} }
/** /**
@ -301,14 +304,14 @@ export class ApPersonService implements OnModuleInit {
withSuspended: opts?.withSuspended ?? true, withSuspended: opts?.withSuspended ?? true,
}; };
let userId; let userId: string | null | undefined;
// Resolve URI -> User ID // Resolve URI -> User ID
const parsed = this.utilityService.parseUri(uri); const parsed = this.utilityService.parseUri(uri);
if (parsed.local) { if (parsed.local) {
userId = parsed.type === 'users' ? parsed.id : null; userId = parsed.type === 'users' ? parsed.id : null;
} else { } else {
userId = await this.uriPersonCache.fetch(uri).catch(() => null); userId = await this.uriPersonCache.fetchMaybe(uri);
} }
// No match // No match
@ -316,8 +319,7 @@ export class ApPersonService implements OnModuleInit {
return null; return null;
} }
const user = await this.cacheService.findUserById(userId) const user = await this.cacheService.findOptionalUserById(userId) as MiLocalUser | MiRemoteUser | null;
.catch(() => null) as MiLocalUser | MiRemoteUser | null;
if (user?.isDeleted && !_opts.withDeleted) { if (user?.isDeleted && !_opts.withDeleted) {
return null; return null;
@ -329,8 +331,9 @@ export class ApPersonService implements OnModuleInit {
return user; return user;
} }
// TODO fix these "any" types
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> { private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => { const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(async img => {
// icon and image may be arrays // icon and image may be arrays
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon // see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
if (Array.isArray(img)) { if (Array.isArray(img)) {
@ -343,7 +346,7 @@ export class ApPersonService implements OnModuleInit {
return { id: null, url: null, blurhash: null }; return { id: null, url: null, blurhash: null };
} }
return this.apImageService.resolveImage(user, img).catch(() => null); return await this.apImageService.resolveImage(user, img).catch(() => null);
})); }));
if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null)) if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null))
@ -574,28 +577,23 @@ export class ApPersonService implements OnModuleInit {
// Register host // Register host
if (this.meta.enableStatsForFederatedInstances) { if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => { this.federatedInstanceService.fetchOrRegister(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { usersCountDelta: 1 });
if (this.meta.enableChartsForFederatedInstances) { if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host); this.instanceChart.newUser(i.host);
} }
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); await this.fetchInstanceMetadataService.fetchInstanceMetadataLazy(i);
}); });
} }
this.usersChart.update(user, true); this.usersChart.update(user, true);
// ハッシュタグ更新
this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ //#region アバターとヘッダー画像をフェッチ
try { try {
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl); const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl);
await this.usersRepository.update(user.id, updates); await this.usersRepository.update(user.id, updates);
await this.internalEventService.emit('remoteUserUpdated', { id: user.id });
user = { ...user, ...updates }; user = { ...user, ...updates };
// Register to the cache
await this.uriPersonCache.set(user.uri, user.id);
} catch (err) { } catch (err) {
// Permanent error implies hidden or inaccessible, which is a normal thing. // Permanent error implies hidden or inaccessible, which is a normal thing.
if (isRetryableError(err)) { if (isRetryableError(err)) {
@ -604,16 +602,29 @@ export class ApPersonService implements OnModuleInit {
} }
//#endregion //#endregion
await this.updateFeatured(user.id, resolver).catch(err => { // ハッシュタグ更新
// Permanent error implies hidden or inaccessible, which is a normal thing. await this.queueService.createUpdateUserTagsJob(user.id);
if (isRetryableError(err)) {
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`); await this.updateFeaturedLazy(user);
}
});
return user; return user;
} }
/**
* Schedules a deferred update on the background task worker.
* Duplicate updates are automatically skipped.
*/
@bindThis
public async updatePersonLazy(uriOrUser: string | MiUser): Promise<void> {
const user = typeof(uriOrUser) === 'string'
? await this.fetchPerson(uriOrUser)
: uriOrUser;
if (user && user.host != null) {
await this.queueService.createUpdateUserJob(user.id);
}
}
/** /**
* Personの情報を更新します * Personの情報を更新します
* Misskeyに対象のPersonが登録されていなければ無視します * Misskeyに対象のPersonが登録されていなければ無視します
@ -688,13 +699,16 @@ export class ApPersonService implements OnModuleInit {
const profileUrls = url ? [url, person.id] : [person.id]; const profileUrls = url ? [url, person.id] : [person.id];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService); const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
const featuredUri = person.featured ? getApId(person.featured) : undefined;
const updates = { const updates = {
lastFetchedAt: this.timeService.date, lastFetchedAt: this.timeService.date,
inbox: person.inbox, inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined, followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured ? getApId(person.featured) : undefined, // If the featured collection changes, then reset the fetch timeout.
lastFetchedFeaturedAt: featuredUri !== exist.featured ? null : undefined,
featured: featuredUri,
emojis: emojiNames, emojis: emojiNames,
name: truncate(person.name, nameLength), name: truncate(person.name, nameLength),
tags, tags,
@ -751,9 +765,15 @@ export class ApPersonService implements OnModuleInit {
return `skip: user ${exist.id} is deleted`; return `skip: user ${exist.id} is deleted`;
} }
// Notify event ASAP
await this.internalEventService.emit('remoteUserUpdated', { id: exist.id });
// Do not use "exist" after this point!!
const updated = { ...exist, ...updates };
if (person.publicKey) { if (person.publicKey) {
const publicKey = new MiUserPublickey({ const publicKey = new MiUserPublickey({
userId: exist.id, userId: updated.id,
keyId: person.publicKey.id, keyId: person.publicKey.id,
keyPem: person.publicKey.publicKeyPem, keyPem: person.publicKey.publicKeyPem,
}); });
@ -767,7 +787,7 @@ export class ApPersonService implements OnModuleInit {
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey), this.publicKeyByUserIdCache.set(publicKey.userId, publicKey),
]); ]);
} else { } else {
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: exist.id }); const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: updated.id });
if (existingPublicKey) { if (existingPublicKey) {
// Delete key // Delete key
await Promise.all([ await Promise.all([
@ -786,7 +806,7 @@ export class ApPersonService implements OnModuleInit {
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag); _description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
} }
await this.userProfilesRepository.update({ userId: exist.id }, { await this.userProfilesRepository.update({ userId: updated.id }, {
url, url,
fields, fields,
verifiedLinks, verifiedLinks,
@ -798,33 +818,25 @@ export class ApPersonService implements OnModuleInit {
location: person['vcard:Address'] ?? null, location: person['vcard:Address'] ?? null,
listenbrainz: person.listenbrainz ?? null, listenbrainz: person.listenbrainz ?? null,
}); });
await this.cacheService.userProfileCache.delete(updated.id);
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id });
// ハッシュタグ更新
this.hashtagService.updateUsertags(exist, tags);
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) { if (updated.inbox !== person.inbox || updated.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
await this.followingsRepository.update( await this.followingsRepository.update(
{ followerId: exist.id }, { followerId: updated.id },
{ {
followerInbox: person.inbox, followerInbox: person.inbox,
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
}, },
); );
await this.cacheService.refreshFollowRelationsFor(exist.id); await this.cacheService.refreshFollowRelationsFor(updated.id);
} }
await this.updateFeatured(exist.id, resolver).catch(err => { // ハッシュタグ更新
// Permanent error implies hidden or inaccessible, which is a normal thing. await this.queueService.createUpdateUserTagsJob(updated.id);
if (isRetryableError(err)) {
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
}
});
const updated = { ...exist, ...updates }; await this.updateFeaturedLazy(updated);
// 移行処理を行う // 移行処理を行う
if (updated.movedAt && ( if (updated.movedAt && (
@ -902,43 +914,71 @@ export class ApPersonService implements OnModuleInit {
return fields; return fields;
} }
/**
* Schedules a deferred update on the background task worker.
* Duplicate updates are automatically skipped.
*/
@bindThis @bindThis
public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise<void> { public async updateFeaturedLazy(userOrId: MiRemoteUser | MiUser['id']): Promise<void> {
const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false }); const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
if (!isRemoteUser(user)) return; const user = typeof(userOrId) === 'object' ? userOrId : await this.cacheService.findRemoteUserById(userId);
if (!user.featured) return;
this.logger.info(`Updating the featured: ${user.uri}`); if (user.isDeleted || user.isSuspended) {
this.logger.debug(`Not updating featured for ${userId}: user is deleted`);
return;
}
const _resolver = resolver ?? this.apResolverService.createResolver(); if (!user.featured) {
this.logger.debug(`Not updating featured for ${userId}: no featured collection`);
return;
}
// Resolve to (Ordered)Collection Object await this.queueService.createUpdateFeaturedJob(userId);
const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => { }
// Permanent error implies hidden or inaccessible, which is a normal thing.
if (isRetryableError(err)) {
this.logger.warn(`Failed to update featured notes: ${renderInlineError(err)}`);
}
return null; @bindThis
}) : null; public async updateFeatured(userOrId: MiRemoteUser | MiUser['id'], resolver?: Resolver): Promise<void> {
if (!collection) return; const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
const user = typeof(userOrId) === 'object' ? userOrId : await this.cacheService.findRemoteUserById(userId);
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`); if (user.isDeleted) throw new IdentifiableError(errorCodes.userIsDeleted, `Can't update featured for ${userId}: user is deleted`);
if (user.isSuspended) throw new IdentifiableError(errorCodes.userIsSuspended, `Can't update featured for ${userId}: user is suspended`);
if (!user.featured) throw new IdentifiableError(errorCodes.noFeaturedCollection, `Can't update featured for ${userId}: no featured collection`);
// Resolve to Object(may be Note) arrays this.logger.info(`Updating featured notes for: ${user.uri}`);
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x))); resolver ??= this.apResolverService.createResolver();
// Mark as updated
await this.usersRepository.update({ id: userId }, { lastFetchedFeaturedAt: this.timeService.date });
await this.internalEventService.emit('remoteUserUpdated', { id: userId });
// Resolve and regist Notes // Resolve and regist Notes
const limit = promiseLimit<MiNote | null>(2);
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit; const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
const featuredNotes = await Promise.all(items const items = await resolver.resolveCollectionItems(user.featured, true, user.uri, maxPinned, 2);
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも const featuredNotes = await promiseMap(items, async item => {
.slice(0, maxPinned) const itemId = getNullableApId(item);
.map(item => limit(() => this.apNoteService.resolveNote(item, { if (itemId && isPost(item)) {
resolver: _resolver, try {
sentFrom: user.uri, const note = await this.apNoteService.resolveNote(item, {
})))); resolver: resolver,
sentFrom: itemId, // resolveCollectionItems has already verified this, so we can re-use it to avoid double fetch
});
if (note && note.userId !== user.id) {
this.logger.warn(`Ignoring cross-note pin: user ${user.id} tried to pin note ${note.id} belonging to other user ${note.userId}`);
return null;
}
return note;
} catch (err) {
this.logger.warn(`Couldn't fetch pinned note ${itemId} for user ${user.id} (@${user.username}@${user.host}): ${renderInlineError(err)}`);
}
}
return null;
}, {
limit: 2,
});
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.delete(MiUserNotePining, { userId: user.id }); await transactionalEntityManager.delete(MiUserNotePining, { userId: user.id });
@ -947,7 +987,7 @@ export class ApPersonService implements OnModuleInit {
let td = 0; let td = 0;
for (const note of featuredNotes.filter(x => x != null)) { for (const note of featuredNotes.filter(x => x != null)) {
td -= 1000; td -= 1000;
transactionalEntityManager.insert(MiUserNotePining, { await transactionalEntityManager.insert(MiUserNotePining, {
id: this.idService.gen(this.timeService.now + td), id: this.idService.gen(this.timeService.now + td),
userId: user.id, userId: user.id,
noteId: note.id, noteId: note.id,
@ -971,6 +1011,7 @@ export class ApPersonService implements OnModuleInit {
let dst = await this.fetchPerson(src.movedToUri); let dst = await this.fetchPerson(src.movedToUri);
if (dst && isLocalUser(dst)) { if (dst && isLocalUser(dst)) {
// TODO this branch should not be possible
// targetがローカルユーザーだった場合データベースから引っ張ってくる // targetがローカルユーザーだった場合データベースから引っ張ってくる
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser; dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser;
} else if (dst) { } else if (dst) {

View file

@ -50,9 +50,9 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
} }
@bindThis @bindThis
public async read(user: { id: MiUser['id'], host: null }): Promise<void> { public read(user: { id: MiUser['id'], host: null }): void {
const createdAt = this.idService.parse(user.id).date; const createdAt = this.idService.parse(user.id).date;
await this.commit({ this.commit({
'read': [user.id], 'read': [user.id],
'registeredWithinWeek': (this.timeService.now - createdAt.getTime() < week) ? [user.id] : [], 'registeredWithinWeek': (this.timeService.now - createdAt.getTime() < week) ? [user.id] : [],
'registeredWithinMonth': (this.timeService.now - createdAt.getTime() < month) ? [user.id] : [], 'registeredWithinMonth': (this.timeService.now - createdAt.getTime() < month) ? [user.id] : [],
@ -64,8 +64,8 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
} }
@bindThis @bindThis
public async write(user: { id: MiUser['id'], host: null }): Promise<void> { public write(user: { id: MiUser['id'], host: null }): void {
await this.commit({ this.commit({
'write': [user.id], 'write': [user.id],
}); });
} }

View file

@ -43,22 +43,22 @@ export default class ApRequestChart extends Chart<typeof schema> { // eslint-dis
} }
@bindThis @bindThis
public async deliverSucc(): Promise<void> { public deliverSucc(): void {
await this.commit({ this.commit({
'deliverSucceeded': 1, 'deliverSucceeded': 1,
}); });
} }
@bindThis @bindThis
public async deliverFail(): Promise<void> { public deliverFail(): void {
await this.commit({ this.commit({
'deliverFailed': 1, 'deliverFailed': 1,
}); });
} }
@bindThis @bindThis
public async inbox(): Promise<void> { public inbox(): void {
await this.commit({ this.commit({
'inboxReceived': 1, 'inboxReceived': 1,
}); });
} }

View file

@ -44,9 +44,9 @@ export default class DriveChart extends Chart<typeof schema> { // eslint-disable
} }
@bindThis @bindThis
public async update(file: MiDriveFile, isAdditional: boolean): Promise<void> { public update(file: MiDriveFile, isAdditional: boolean): void {
const fileSizeKb = file.size / 1000; const fileSizeKb = file.size / 1000;
await this.commit(file.userHost === null ? { this.commit(file.userHost === null ? {
'local.incCount': isAdditional ? 1 : 0, 'local.incCount': isAdditional ? 1 : 0,
'local.incSize': isAdditional ? fileSizeKb : 0, 'local.incSize': isAdditional ? fileSizeKb : 0,
'local.decCount': isAdditional ? 0 : 1, 'local.decCount': isAdditional ? 0 : 1,

View file

@ -118,8 +118,8 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
} }
@bindThis @bindThis
public async deliverd(host: string, succeeded: boolean): Promise<void> { public deliverd(host: string, succeeded: boolean): void {
await this.commit(succeeded ? { this.commit(succeeded ? {
'deliveredInstances': [host], 'deliveredInstances': [host],
} : { } : {
'stalled': [host], 'stalled': [host],
@ -127,8 +127,8 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
} }
@bindThis @bindThis
public async inbox(host: string): Promise<void> { public inbox(host: string): void {
await this.commit({ this.commit({
'inboxInstances': [host], 'inboxInstances': [host],
}); });
} }

View file

@ -80,31 +80,31 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
} }
@bindThis @bindThis
public async requestReceived(host: string): Promise<void> { public requestReceived(host: string): void {
await this.commit({ this.commit({
'requests.received': 1, 'requests.received': 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.toPuny(host));
} }
@bindThis @bindThis
public async requestSent(host: string, isSucceeded: boolean): Promise<void> { public requestSent(host: string, isSucceeded: boolean): void {
await this.commit({ this.commit({
'requests.succeeded': isSucceeded ? 1 : 0, 'requests.succeeded': isSucceeded ? 1 : 0,
'requests.failed': isSucceeded ? 0 : 1, 'requests.failed': isSucceeded ? 0 : 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.toPuny(host));
} }
@bindThis @bindThis
public async newUser(host: string): Promise<void> { public newUser(host: string): void {
await this.commit({ this.commit({
'users.total': 1, 'users.total': 1,
'users.inc': 1, 'users.inc': 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.toPuny(host));
} }
@bindThis @bindThis
public async updateNote(host: string, note: MiNote, isAdditional: boolean): Promise<void> { public updateNote(host: string, note: MiNote, isAdditional: boolean): void {
await this.commit({ this.commit({
'notes.total': isAdditional ? 1 : -1, 'notes.total': isAdditional ? 1 : -1,
'notes.inc': isAdditional ? 1 : 0, 'notes.inc': isAdditional ? 1 : 0,
'notes.dec': isAdditional ? 0 : 1, 'notes.dec': isAdditional ? 0 : 1,
@ -116,8 +116,8 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
} }
@bindThis @bindThis
public async updateFollowing(host: string, isAdditional: boolean): Promise<void> { public updateFollowing(host: string, isAdditional: boolean): void {
await this.commit({ this.commit({
'following.total': isAdditional ? 1 : -1, 'following.total': isAdditional ? 1 : -1,
'following.inc': isAdditional ? 1 : 0, 'following.inc': isAdditional ? 1 : 0,
'following.dec': isAdditional ? 0 : 1, 'following.dec': isAdditional ? 0 : 1,
@ -125,8 +125,8 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
} }
@bindThis @bindThis
public async updateFollowers(host: string, isAdditional: boolean): Promise<void> { public updateFollowers(host: string, isAdditional: boolean): void {
await this.commit({ this.commit({
'followers.total': isAdditional ? 1 : -1, 'followers.total': isAdditional ? 1 : -1,
'followers.inc': isAdditional ? 1 : 0, 'followers.inc': isAdditional ? 1 : 0,
'followers.dec': isAdditional ? 0 : 1, 'followers.dec': isAdditional ? 0 : 1,
@ -134,9 +134,9 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
} }
@bindThis @bindThis
public async updateDrive(file: MiDriveFile, isAdditional: boolean): Promise<void> { public updateDrive(file: MiDriveFile, isAdditional: boolean): void {
const fileSizeKb = file.size / 1000; const fileSizeKb = file.size / 1000;
await this.commit({ this.commit({
'drive.totalFiles': isAdditional ? 1 : -1, 'drive.totalFiles': isAdditional ? 1 : -1,
'drive.incFiles': isAdditional ? 1 : 0, 'drive.incFiles': isAdditional ? 1 : 0,
'drive.incUsage': isAdditional ? fileSizeKb : 0, 'drive.incUsage': isAdditional ? fileSizeKb : 0,

View file

@ -56,10 +56,10 @@ export default class NotesChart extends Chart<typeof schema> { // eslint-disable
} }
@bindThis @bindThis
public async update(note: MiNote, isAdditional: boolean): Promise<void> { public update(note: MiNote, isAdditional: boolean): void {
const prefix = note.userHost === null ? 'local' : 'remote'; const prefix = note.userHost === null ? 'local' : 'remote';
await this.commit({ this.commit({
[`${prefix}.total`]: isAdditional ? 1 : -1, [`${prefix}.total`]: isAdditional ? 1 : -1,
[`${prefix}.inc`]: isAdditional ? 1 : 0, [`${prefix}.inc`]: isAdditional ? 1 : 0,
[`${prefix}.dec`]: isAdditional ? 0 : 1, [`${prefix}.dec`]: isAdditional ? 0 : 1,

View file

@ -58,9 +58,9 @@ export default class PerUserDriveChart extends Chart<typeof schema> { // eslint-
} }
@bindThis @bindThis
public async update(file: MiDriveFile, isAdditional: boolean): Promise<void> { public update(file: MiDriveFile, isAdditional: boolean): void {
const fileSizeKb = file.size / 1000; const fileSizeKb = file.size / 1000;
await this.commit({ this.commit({
'totalCount': isAdditional ? 1 : -1, 'totalCount': isAdditional ? 1 : -1,
'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb,
'incCount': isAdditional ? 1 : 0, 'incCount': isAdditional ? 1 : 0,

View file

@ -70,7 +70,7 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
} }
@bindThis @bindThis
public async update(follower: { id: MiUser['id']; host: MiUser['host']; }, followee: { id: MiUser['id']; host: MiUser['host']; }, isFollow: boolean): Promise<void> { public update(follower: { id: MiUser['id']; host: MiUser['host']; }, followee: { id: MiUser['id']; host: MiUser['host']; }, isFollow: boolean): void {
const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote'; const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote';
const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote'; const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';

View file

@ -44,16 +44,16 @@ export default class PerUserPvChart extends Chart<typeof schema> { // eslint-dis
} }
@bindThis @bindThis
public async commitByUser(user: { id: MiUser['id'] }, key: string): Promise<void> { public commitByUser(user: { id: MiUser['id'] }, key: string): void {
await this.commit({ this.commit({
'upv.user': [key], 'upv.user': [key],
'pv.user': 1, 'pv.user': 1,
}, user.id); }, user.id);
} }
@bindThis @bindThis
public async commitByVisitor(user: { id: MiUser['id'] }, key: string): Promise<void> { public commitByVisitor(user: { id: MiUser['id'] }, key: string): void {
await this.commit({ this.commit({
'upv.visitor': [key], 'upv.visitor': [key],
'pv.visitor': 1, 'pv.visitor': 1,
}, user.id); }, user.id);

View file

@ -47,7 +47,7 @@ export default class PerUserReactionsChart extends Chart<typeof schema> { // esl
} }
@bindThis @bindThis
public async update(user: { id: MiUser['id'], host: MiUser['host'] }, note: MiNote): Promise<void> { public update(user: { id: MiUser['id'], host: MiUser['host'] }, note: MiNote): void {
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
this.commit({ this.commit({
[`${prefix}.count`]: 1, [`${prefix}.count`]: 1,

View file

@ -48,12 +48,12 @@ export default class TestGroupedChart extends Chart<typeof schema> { // eslint-d
} }
@bindThis @bindThis
public async increment(group: string): Promise<void> { public increment(group: string): void {
if (this.total[group] == null) this.total[group] = 0; if (this.total[group] == null) this.total[group] = 0;
this.total[group]++; this.total[group]++;
await this.commit({ this.commit({
'foo.total': 1, 'foo.total': 1,
'foo.inc': 1, 'foo.inc': 1,
}, group); }, group);

View file

@ -44,15 +44,15 @@ export default class TestIntersectionChart extends Chart<typeof schema> { // esl
} }
@bindThis @bindThis
public async addA(key: string): Promise<void> { public addA(key: string): void {
await this.commit({ this.commit({
a: [key], a: [key],
}); });
} }
@bindThis @bindThis
public async addB(key: string): Promise<void> { public addB(key: string): void {
await this.commit({ this.commit({
b: [key], b: [key],
}); });
} }

View file

@ -44,8 +44,8 @@ export default class TestUniqueChart extends Chart<typeof schema> { // eslint-di
} }
@bindThis @bindThis
public async uniqueIncrement(key: string): Promise<void> { public uniqueIncrement(key: string): void {
await this.commit({ this.commit({
foo: [key], foo: [key],
}); });
} }

View file

@ -48,20 +48,20 @@ export default class TestChart extends Chart<typeof schema> { // eslint-disable-
} }
@bindThis @bindThis
public async increment(): Promise<void> { public increment(): void {
this.total++; this.total++;
await this.commit({ this.commit({
'foo.total': 1, 'foo.total': 1,
'foo.inc': 1, 'foo.inc': 1,
}); });
} }
@bindThis @bindThis
public async decrement(): Promise<void> { public decrement(): void {
this.total--; this.total--;
await this.commit({ this.commit({
'foo.total': -1, 'foo.total': -1,
'foo.dec': 1, 'foo.dec': 1,
}); });

View file

@ -61,10 +61,10 @@ export default class UsersChart extends Chart<typeof schema> { // eslint-disable
} }
@bindThis @bindThis
public async update(user: { id: MiUser['id'], host: MiUser['host'] }, isAdditional: boolean): Promise<void> { public update(user: { id: MiUser['id'], host: MiUser['host'] }, isAdditional: boolean): void {
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
await this.commit({ this.commit({
[`${prefix}.total`]: isAdditional ? 1 : -1, [`${prefix}.total`]: isAdditional ? 1 : -1,
[`${prefix}.inc`]: isAdditional ? 1 : 0, [`${prefix}.inc`]: isAdditional ? 1 : 0,
[`${prefix}.dec`]: isAdditional ? 0 : 1, [`${prefix}.dec`]: isAdditional ? 0 : 1,

View file

@ -15,6 +15,7 @@ import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MiRepository, miRepository } from '@/models/_.js'; import { MiRepository, miRepository } from '@/models/_.js';
import { promiseMap } from '@/misc/promise-map.js';
import type { DataSource, Repository } from 'typeorm'; import type { DataSource, Repository } from 'typeorm';
import type { Lock } from 'redis-lock'; import type { Lock } from 'redis-lock';
@ -526,13 +527,13 @@ export default abstract class Chart<T extends Schema> {
const groups = removeDuplicates(this.buffer.map(log => log.group)); const groups = removeDuplicates(this.buffer.map(log => log.group));
await Promise.all( await promiseMap(groups, async group => {
groups.map(group => const logHour = await this.claimCurrentLog(group, 'hour');
Promise.all([ const logDay = await this.claimCurrentLog(group, 'day');
this.claimCurrentLog(group, 'hour'), await update(logHour, logDay);
this.claimCurrentLog(group, 'day'), }, {
]).then(([logHour, logDay]) => limit: 2,
update(logHour, logDay)))); });
} }
@bindThis @bindThis
@ -564,7 +565,7 @@ export default abstract class Chart<T extends Schema> {
]); ]);
}; };
return Promise.all([ return await Promise.all([
this.claimCurrentLog(group, 'hour'), this.claimCurrentLog(group, 'hour'),
this.claimCurrentLog(group, 'day'), this.claimCurrentLog(group, 'day'),
]).then(([logHour, logDay]) => ]).then(([logHour, logDay]) =>

View file

@ -35,6 +35,7 @@ export class BlockingEntityService {
): Promise<Packed<'Blocking'>> { ): Promise<Packed<'Blocking'>> {
const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: blocking.id, id: blocking.id,
createdAt: this.idService.parse(blocking.id).date.toISOString(), createdAt: this.idService.parse(blocking.id).date.toISOString(),
@ -53,6 +54,6 @@ export class BlockingEntityService {
const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId); const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId);
const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' }) const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) }))); return await Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) })));
} }
} }

View file

@ -117,7 +117,7 @@ export class ChatEntityService {
.then(rooms => new Map(rooms.map(r => [r.id, r]))), .then(rooms => new Map(rooms.map(r => [r.id, r]))),
]); ]);
return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } }))); return await Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
} }
@bindThis @bindThis
@ -165,7 +165,7 @@ export class ChatEntityService {
.then(files => new Map(files.map(f => [f.id, f]))), .then(files => new Map(files.map(f => [f.id, f]))),
]); ]);
return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } }))); return await Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
} }
@bindThis @bindThis
@ -228,7 +228,7 @@ export class ChatEntityService {
.then(files => new Map(files.map(f => [f.id, f]))), .then(files => new Map(files.map(f => [f.id, f]))),
]); ]);
return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } }))); return await Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
} }
@bindThis @bindThis
@ -289,7 +289,7 @@ export class ChatEntityService {
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
]); ]);
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); return await Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
} }
@bindThis @bindThis
@ -322,7 +322,7 @@ export class ChatEntityService {
) { ) {
if (invitations.length === 0) return []; if (invitations.length === 0) return [];
return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me))); return await Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
} }
@bindThis @bindThis
@ -371,6 +371,6 @@ export class ChatEntityService {
.then(rooms => new Map(rooms.map(r => [r.id, r]))), .then(rooms => new Map(rooms.map(r => [r.id, r]))),
]); ]);
return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } }))); return await Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
} }
} }

View file

@ -42,6 +42,7 @@ export class ClipEntityService {
const meId = me ? me.id : null; const meId = me ? me.id : null;
const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: clip.id, id: clip.id,
createdAt: this.idService.parse(clip.id).date.toISOString(), createdAt: this.idService.parse(clip.id).date.toISOString(),
@ -65,7 +66,7 @@ export class ClipEntityService {
const _users = clips.map(({ user, userId }) => user ?? userId); const _users = clips.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me) const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) }))); return await Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) })));
} }
} }

View file

@ -201,6 +201,7 @@ export class DriveFileEntityService implements OnModuleInit {
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src }); const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll<Packed<'DriveFile'>>({ return await awaitAll<Packed<'DriveFile'>>({
id: file.id, id: file.id,
createdAt: this.idService.parse(file.id).date.toISOString(), createdAt: this.idService.parse(file.id).date.toISOString(),
@ -239,6 +240,7 @@ export class DriveFileEntityService implements OnModuleInit {
const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src }); const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src });
if (file == null) return null; if (file == null) return null;
// noinspection ES6MissingAwait
return await awaitAll<Packed<'DriveFile'>>({ return await awaitAll<Packed<'DriveFile'>>({
id: file.id, id: file.id,
createdAt: this.idService.parse(file.id).date.toISOString(), createdAt: this.idService.parse(file.id).date.toISOString(),

View file

@ -179,7 +179,7 @@ export class EmojiEntityService implements OnModuleInit {
hintRoles = new Map(roles.map(x => [x.id, x])); hintRoles = new Map(roles.map(x => [x.id, x]));
} }
return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles }))); return await Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
} }
} }

View file

@ -77,7 +77,7 @@ export class FlashEntityService {
.getRawMany<{ flashLike_flashId: string }>() .getRawMany<{ flashLike_flashId: string }>()
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))]) .then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
: []; : [];
return Promise.all( return await Promise.all(
flashes.map(flash => this.pack(flash, me, { flashes.map(flash => this.pack(flash, me, {
packedUser: _userMap.get(flash.userId), packedUser: _userMap.get(flash.userId),
likedFlashIds: _likedFlashIds, likedFlashIds: _likedFlashIds,

View file

@ -50,7 +50,7 @@ export class FollowRequestEntityService {
const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId); const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId);
const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me) const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all( return await Promise.all(
requests.map(req => { requests.map(req => {
const packedFollower = _userMap.get(req.followerId); const packedFollower = _userMap.get(req.followerId);
const packedFollowee = _userMap.get(req.followeeId); const packedFollowee = _userMap.get(req.followeeId);

View file

@ -139,6 +139,7 @@ export class FollowingEntityService {
if (opts == null) opts = {}; if (opts == null) opts = {};
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: following.id, id: following.id,
createdAt: this.idService.parse(following.id).date.toISOString(), createdAt: this.idService.parse(following.id).date.toISOString(),
@ -166,7 +167,7 @@ export class FollowingEntityService {
const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : []; const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : [];
const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' }) const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all( return await Promise.all(
followings.map(following => { followings.map(following => {
const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined; const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined;
const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined; const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined;

View file

@ -42,6 +42,7 @@ export class GalleryPostEntityService {
const meId = me ? me.id : null; const meId = me ? me.id : null;
const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: post.id, id: post.id,
createdAt: this.idService.parse(post.id).date.toISOString(), createdAt: this.idService.parse(post.id).date.toISOString(),
@ -68,7 +69,7 @@ export class GalleryPostEntityService {
const _users = posts.map(({ user, userId }) => user ?? userId); const _users = posts.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me) const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) }))); return await Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) })));
} }
} }

View file

@ -62,7 +62,7 @@ export class InviteCodeEntityService {
const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null); const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null);
const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me) const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all( return await Promise.all(
tickets.map(ticket => { tickets.map(ticket => {
const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined; const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined;
const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined; const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined;

View file

@ -34,6 +34,7 @@ export class ModerationLogEntityService {
) { ) {
const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: log.id, id: log.id,
createdAt: this.idService.parse(log.id).date.toISOString(), createdAt: this.idService.parse(log.id).date.toISOString(),
@ -53,7 +54,7 @@ export class ModerationLogEntityService {
const _users = reports.map(({ user, userId }) => user ?? userId); const _users = reports.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' }) const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) }))); return await Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) })));
} }
} }

View file

@ -36,6 +36,7 @@ export class MutingEntityService {
): Promise<Packed<'Muting'>> { ): Promise<Packed<'Muting'>> {
const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: muting.id, id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(), createdAt: this.idService.parse(muting.id).date.toISOString(),
@ -55,7 +56,7 @@ export class MutingEntityService {
const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' }) const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); return await Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
} }
} }

View file

@ -592,6 +592,7 @@ export class NoteEntityService implements OnModuleInit {
const bypassSilence = opts.bypassSilence || note.userId === meId; const bypassSilence = opts.bypassSilence || note.userId === meId;
// noinspection ES6MissingAwait
const packed: Packed<'Note'> = await awaitAll({ const packed: Packed<'Note'> = await awaitAll({
id: note.id, id: note.id,
threadId, threadId,

View file

@ -5,12 +5,13 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NoteFavoritesRepository } from '@/models/_.js'; import type { MiNote, NoteFavoritesRepository } from '@/models/_.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiNoteFavorite } from '@/models/NoteFavorite.js'; import type { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from './NoteEntityService.js'; import { NoteEntityService } from './NoteEntityService.js';
@Injectable() @Injectable()
@ -28,6 +29,7 @@ export class NoteFavoriteEntityService {
public async pack( public async pack(
src: MiNoteFavorite['id'] | MiNoteFavorite, src: MiNoteFavorite['id'] | MiNoteFavorite,
me?: { id: MiUser['id'] } | null | undefined, me?: { id: MiUser['id'] } | null | undefined,
notes?: Map<string, Packed<'Note'>>,
) { ) {
const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src }); const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src });
@ -35,15 +37,18 @@ export class NoteFavoriteEntityService {
id: favorite.id, id: favorite.id,
createdAt: this.idService.parse(favorite.id).date.toISOString(), createdAt: this.idService.parse(favorite.id).date.toISOString(),
noteId: favorite.noteId, noteId: favorite.noteId,
note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me), note: notes?.get(favorite.noteId) ?? await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
}; };
} }
@bindThis @bindThis
public packMany( public async packMany(
favorites: any[], favorites: (MiNoteFavorite & { note: MiNote })[],
me: { id: MiUser['id'] }, me: { id: MiUser['id'] },
) { ) {
return Promise.all(favorites.map(x => this.pack(x, me))); const packedNotes = await this.noteEntityService.packMany(favorites.map(f => f.note), me);
const packedNotesMap = new Map(packedNotes.map(n => [n.id, n]));
return Promise.all(favorites.map(x => this.pack(x, me, packedNotesMap)));
} }
} }

View file

@ -88,6 +88,6 @@ export class NoteReactionEntityService implements OnModuleInit {
const _users = reactions.map(({ user, userId }) => user ?? userId); const _users = reactions.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me) const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); return await Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
} }
} }

View file

@ -83,11 +83,12 @@ export class PageEntityService {
}; };
migrate(page.content); migrate(page.content);
if (migrated) { if (migrated) {
this.pagesRepository.update(page.id, { await this.pagesRepository.update(page.id, {
content: page.content, content: page.content,
}); });
} }
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: page.id, id: page.id,
createdAt: this.idService.parse(page.id).date.toISOString(), createdAt: this.idService.parse(page.id).date.toISOString(),
@ -104,10 +105,13 @@ export class PageEntityService {
font: page.font, font: page.font,
script: page.script, script: page.script,
eyeCatchingImageId: page.eyeCatchingImageId, eyeCatchingImageId: page.eyeCatchingImageId,
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, eyeCatchingImage: page.eyeCatchingImageId ? this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(x => x != null)), attachedFiles: Promise
.all(attachedFiles)
.then(fs => fs.filter(x => x != null))
.then(fs => this.driveFileEntityService.packMany(fs)),
likedCount: page.likedCount, likedCount: page.likedCount,
isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined, isLiked: meId ? this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
}); });
} }
@ -119,7 +123,7 @@ export class PageEntityService {
const _users = pages.map(({ user, userId }) => user ?? userId); const _users = pages.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me) const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) }))); return await Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) })));
} }
} }

View file

@ -36,6 +36,7 @@ export class RenoteMutingEntityService {
): Promise<Packed<'RenoteMuting'>> { ): Promise<Packed<'RenoteMuting'>> {
const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: muting.id, id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(), createdAt: this.idService.parse(muting.id).date.toISOString(),
@ -54,7 +55,7 @@ export class RenoteMutingEntityService {
const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' }) const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); return await Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) })));
} }
} }

View file

@ -485,7 +485,7 @@ export class UserEntityService implements OnModuleInit {
if (user.avatarId != null && user.avatarUrl === null) { if (user.avatarId != null && user.avatarUrl === null) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
this.usersRepository.update(user.id, { await this.usersRepository.update(user.id, {
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
avatarBlurhash: avatar.blurhash, avatarBlurhash: avatar.blurhash,
}); });
@ -493,7 +493,7 @@ export class UserEntityService implements OnModuleInit {
if (user.bannerId != null && user.bannerUrl === null) { if (user.bannerId != null && user.bannerUrl === null) {
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
this.usersRepository.update(user.id, { await this.usersRepository.update(user.id, {
bannerUrl: user.bannerUrl, bannerUrl: user.bannerUrl,
bannerBlurhash: banner.blurhash, bannerBlurhash: banner.blurhash,
}); });
@ -501,7 +501,7 @@ export class UserEntityService implements OnModuleInit {
if (user.backgroundId != null && user.backgroundUrl === null) { if (user.backgroundId != null && user.backgroundUrl === null) {
const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId }); const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId });
user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background); user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background);
this.usersRepository.update(user.id, { await this.usersRepository.update(user.id, {
backgroundUrl: user.backgroundUrl, backgroundUrl: user.backgroundUrl,
backgroundBlurhash: background.blurhash, backgroundBlurhash: background.blurhash,
}); });
@ -581,6 +581,7 @@ export class UserEntityService implements OnModuleInit {
const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false); const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false);
// noinspection ES6MissingAwait
const packed = { const packed = {
id: user.id, id: user.id,
name: user.name, name: user.name,
@ -644,6 +645,7 @@ export class UserEntityService implements OnModuleInit {
...(isDetailed ? { ...(isDetailed ? {
url: profile!.url, url: profile!.url,
uri: user.uri, uri: user.uri,
// TODO hints for all of this
movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null, movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null,
movedToUri: user.movedToUri, movedToUri: user.movedToUri,
// alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy // alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy
@ -894,7 +896,7 @@ export class UserEntityService implements OnModuleInit {
myFollowingsPromise, myFollowingsPromise,
]); ]);
return Promise.all( return await Promise.all(
_users.map(u => this.pack( _users.map(u => this.pack(
u, u,
me, me,

View file

@ -67,7 +67,7 @@ export class UserListEntityService {
const _users = memberships.map(({ user, userId }) => user ?? userId); const _users = memberships.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users) const _userMap = await this.userEntityService.packMany(_users)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(memberships.map(async x => ({ return await Promise.all(memberships.map(async x => ({
id: x.id, id: x.id,
createdAt: this.idService.parse(x.id).date.toISOString(), createdAt: this.idService.parse(x.id).date.toISOString(),
userId: x.userId, userId: x.userId,

View file

@ -24,6 +24,7 @@ export interface StatsEntry {
export interface Stats { export interface Stats {
deliver: StatsEntry, deliver: StatsEntry,
inbox: StatsEntry, inbox: StatsEntry,
background: StatsEntry,
} }
const ev = new Xev(); const ev = new Xev();
@ -35,9 +36,11 @@ export class QueueStatsService implements OnApplicationShutdown {
private intervalId?: TimerHandle; private intervalId?: TimerHandle;
private activeDeliverJobs = 0; private activeDeliverJobs = 0;
private activeInboxJobs = 0; private activeInboxJobs = 0;
private activeBackgroundJobs = 0;
private deliverQueueEvents?: Bull.QueueEvents; private deliverQueueEvents?: Bull.QueueEvents;
private inboxQueueEvents?: Bull.QueueEvents; private inboxQueueEvents?: Bull.QueueEvents;
private backgroundQueueEvents?: Bull.QueueEvents;
private log?: Stats[]; private log?: Stats[];
@ -60,6 +63,11 @@ export class QueueStatsService implements OnApplicationShutdown {
this.activeInboxJobs++; this.activeInboxJobs++;
} }
@bindThis
private onBackgroundActive() {
this.activeBackgroundJobs++;
}
@bindThis @bindThis
private onRequestQueueStatsLog(x: { id: string, length?: number }) { private onRequestQueueStatsLog(x: { id: string, length?: number }) {
if (this.log) { if (this.log) {
@ -80,13 +88,16 @@ export class QueueStatsService implements OnApplicationShutdown {
this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER)); this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX)); this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
this.backgroundQueueEvents = new Bull.QueueEvents(QUEUE.BACKGROUND_TASK, baseQueueOptions(this.config, QUEUE.BACKGROUND_TASK));
this.deliverQueueEvents.on('active', this.onDeliverActive); this.deliverQueueEvents.on('active', this.onDeliverActive);
this.inboxQueueEvents.on('active', this.onInboxActive); this.inboxQueueEvents.on('active', this.onInboxActive);
this.backgroundQueueEvents.on('active', this.onBackgroundActive);
const tick = async () => { const tick = async () => {
const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts(); const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts();
const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts(); const inboxJobCounts = await this.queueService.inboxQueue.getJobCounts();
const backgroundJobCounts = await this.queueService.backgroundTaskQueue.getJobCounts();
const stats = { const stats = {
deliver: { deliver: {
@ -101,6 +112,12 @@ export class QueueStatsService implements OnApplicationShutdown {
waiting: inboxJobCounts.waiting, waiting: inboxJobCounts.waiting,
delayed: inboxJobCounts.delayed, delayed: inboxJobCounts.delayed,
}, },
background: {
activeSincePrevTick: this.activeBackgroundJobs,
active: backgroundJobCounts.active,
waiting: backgroundJobCounts.waiting,
delayed: backgroundJobCounts.delayed,
},
}; };
ev.emit('queueStats', stats); ev.emit('queueStats', stats);
@ -112,6 +129,7 @@ export class QueueStatsService implements OnApplicationShutdown {
this.activeDeliverJobs = 0; this.activeDeliverJobs = 0;
this.activeInboxJobs = 0; this.activeInboxJobs = 0;
this.activeBackgroundJobs = 0;
}; };
tick(); tick();
@ -120,7 +138,7 @@ export class QueueStatsService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async stop() { public async stop(): Promise<void> {
if (this.intervalId) { if (this.intervalId) {
this.timeService.stopTimer(this.intervalId); this.timeService.stopTimer(this.intervalId);
} }
@ -130,12 +148,15 @@ export class QueueStatsService implements OnApplicationShutdown {
this.deliverQueueEvents?.off('active', this.onDeliverActive); this.deliverQueueEvents?.off('active', this.onDeliverActive);
this.inboxQueueEvents?.off('active', this.onInboxActive); this.inboxQueueEvents?.off('active', this.onInboxActive);
this.backgroundQueueEvents?.off('active', this.onBackgroundActive);
await this.deliverQueueEvents?.close(); await this.deliverQueueEvents?.close();
await this.inboxQueueEvents?.close(); await this.inboxQueueEvents?.close();
await this.backgroundQueueEvents?.close();
this.activeDeliverJobs = 0; this.activeDeliverJobs = 0;
this.activeInboxJobs = 0; this.activeInboxJobs = 0;
this.activeBackgroundJobs = 0;
} }
@bindThis @bindThis

View file

@ -93,7 +93,7 @@ export const DI = {
chatRoomsRepository: Symbol('chatRoomsRepository'), chatRoomsRepository: Symbol('chatRoomsRepository'),
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'), chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
noteEditRepository: Symbol('noteEditRepository'), noteEditsRepository: Symbol('noteEditsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'),
noteScheduleRepository: Symbol('noteScheduleRepository'), noteScheduleRepository: Symbol('noteScheduleRepository'),

View file

@ -3,45 +3,169 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import promiseLimit from 'promise-limit';
import type { TimeService, TimerHandle } from '@/global/TimeService.js'; import type { TimeService, TimerHandle } from '@/global/TimeService.js';
import { InternalEventService } from '@/global/InternalEventService.js';
import { bindThis } from '@/decorators.js';
import { Serialized } from '@/types.js';
type Job<V> = { type Job<V> = {
value: V; value: V;
timer: TimerHandle; timer: TimerHandle;
}; };
// TODO: redis使えるようにする // TODO document IPC sync process
export class CollapsedQueue<K, V> {
private jobs: Map<K, Job<V>> = new Map(); // sync cross-process:
// 1. Emit internal events when scheduling timer, performing queue, and enqueuing data
// 2. On enqueue, mark ID as deferred.
// 3. On perform, clear mark.
// 4. On performAll, skip deferred IDs.
// 5. On enqueue when ID is deferred, send data as event instead.
// 6. On delete, clear mark.
// 7. On delete when ID is deferred, do nothing.
export class CollapsedQueue<V> {
private readonly limiter?: ReturnType<typeof promiseLimit<void>>;
private readonly jobs: Map<string, Job<V>> = new Map();
private readonly deferredKeys = new Set<string>();
constructor( constructor(
protected readonly timeService: TimeService, private readonly internalEventService: InternalEventService,
private timeout: number, private readonly timeService: TimeService,
private collapse: (oldValue: V, newValue: V) => V, public readonly name: string,
private perform: (key: K, value: V) => Promise<void>, private readonly timeout: number,
) {} private readonly collapse: (oldValue: V, newValue: V) => V,
private readonly perform: (key: string, value: V) => Promise<void | unknown>,
enqueue(key: K, value: V) { private readonly opts?: {
if (this.jobs.has(key)) { onError?: (queue: CollapsedQueue<V>, error: unknown) => void | Promise<void>,
const old = this.jobs.get(key)!; concurrency?: number,
const merged = this.collapse(old.value, value); redisParser?: (data: Serialized<V>) => V,
this.jobs.set(key, { ...old, value: merged }); },
} else { ) {
const timer = this.timeService.startTimer(() => { if (opts?.concurrency) {
const job = this.jobs.get(key)!; this.limiter = promiseLimit<void>(opts.concurrency);
this.jobs.delete(key);
this.perform(key, job.value);
}, this.timeout);
this.jobs.set(key, { value, timer });
} }
this.internalEventService.on('collapsedQueueDefer', this.onDefer, { ignoreLocal: true });
this.internalEventService.on('collapsedQueueEnqueue', this.onEnqueue, { ignoreLocal: true });
} }
@bindThis
async enqueue(key: string, value: V) {
// If deferred, then send it out to the owning process
if (this.deferredKeys.has(key)) {
await this.internalEventService.emit('collapsedQueueEnqueue', { name: this.name, key, value });
return;
}
// If already queued, then merge
const job = this.jobs.get(key);
if (job) {
job.value = this.collapse(job.value, value);
return;
}
// Otherwise, create a new job
const timer = this.timeService.startTimer(async () => {
const job = this.jobs.get(key);
if (!job) return;
this.jobs.delete(key);
await this._perform(key, job.value);
}, this.timeout);
this.jobs.set(key, { value, timer });
// Mark as deferred so other processes will forward their state to us
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: true });
}
@bindThis
async delete(key: string) {
const job = this.jobs.get(key);
if (!job) return;
this.timeService.stopTimer(job.timer);
this.jobs.delete(key);
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: false });
}
@bindThis
async performAllNow() { async performAllNow() {
const entries = [...this.jobs.entries()]; for (const job of this.jobs.values()) {
this.jobs.clear();
for (const [_key, job] of entries) {
this.timeService.stopTimer(job.timer); this.timeService.stopTimer(job.timer);
} }
await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value)));
const entries = Array.from(this.jobs.entries());
this.jobs.clear();
return await Promise.all(entries.map(([key, job]) => this._perform(key, job.value)));
}
private async _perform(key: string, value: V) {
try {
await this.internalEventService.emit('collapsedQueueDefer', { name: this.name, key, deferred: false });
if (this.limiter) {
await this.limiter(async () => {
await this.perform(key, value);
});
} else {
await this.perform(key, value);
}
return true;
} catch (err) {
await this.opts?.onError?.(this, err);
return false;
}
}
//#region Events from other processes
@bindThis
private async onDefer(data: { name: string, key: string, deferred: boolean }) {
if (data.name !== this.name) return;
// Check for and recover from de-sync conditions where multiple processes try to "own" the same job.
const job = this.jobs.get(data.key);
if (job) {
if (data.deferred) {
// If another process tries to claim our job, then give it to them and queue our latest state.
this.timeService.stopTimer(job.timer);
this.jobs.delete(data.key);
await this.internalEventService.emit('collapsedQueueEnqueue', { name: this.name, key: data.key, value: job.value });
} else {
// If another process tries to release our job, then just continue.
return;
}
}
if (data.deferred) {
this.deferredKeys.add(data.key);
} else {
this.deferredKeys.delete(data.key);
}
}
@bindThis
private async onEnqueue(data: { name: string, key: string, value: unknown }) {
if (data.name !== this.name) return;
// Only enqueue if not deferred
if (!this.deferredKeys.has(data.key)) {
const value = this.opts?.redisParser
? this.opts.redisParser(data.value as Serialized<V>)
: data.value as V;
await this.enqueue(data.key, value);
}
}
//#endregion
async dispose() {
this.internalEventService.off('collapsedQueueDefer', this.onDefer);
this.internalEventService.off('collapsedQueueEnqueue', this.onEnqueue);
return await this.performAllNow();
} }
} }

View file

@ -25,3 +25,15 @@ export class IdentifiableError extends Error {
this.isRetryable = isRetryable; this.isRetryable = isRetryable;
} }
} }
/**
* Standard error codes to reference throughout the app
*/
export const errorCodes = {
// User has been deleted (hard or soft deleted)
userIsDeleted: '4cac9436-baa3-4955-a368-7628aea676cf',
// User is suspended (directly or by instance)
userIsSuspended: '1e56d624-737f-48e4-beb6-0bdddb9fa809',
// User has no valid featured collection (not defined, invalid, etc)
noFeaturedCollection: '2aa4766e-b7d8-4291-a671-56800498b085',
} as const;

View file

@ -5,42 +5,51 @@
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { NoteEdit } from '@/models/NoteEdit.js';
// NoteEntityService.isPureRenote とよしなにリンク // NoteEntityService.isPureRenote とよしなにリンク
type Renote = export type Renote =
MiNote & { MiNote & {
renoteId: NonNullable<MiNote['renoteId']> renoteId: NonNullable<MiNote['renoteId']>
}; };
type Quote = export type Quote =
Renote & ({ Renote & ({
text: NonNullable<MiNote['text']> text: NonNullable<MiNote['text']>
} | { } | {
cw: NonNullable<MiNote['cw']> cw: NonNullable<MiNote['cw']>
} | { } | {
replyId: NonNullable<MiNote['replyId']> replyId: NonNullable<MiNote['replyId']>
reply: NonNullable<MiNote['reply']> reply: NonNullable<MiNote['reply']> // TODO this is wrong
} | { } | {
hasPoll: true hasPoll: true
} | {
fileIds: [string, ...string[]]
}); });
type PureRenote = export type PureRenote =
Renote & { Renote & {
text: null, text: null,
cw: null, cw: null,
replyId: null, replyId: null,
hasPoll: false, hasPoll: false,
fileIds: { fileIds: [],
length: 0,
},
}; };
export function isRenote(note: MiNote): note is Renote { export function isRenote(note: MiNote): note is Renote;
export function isRenote(note: NoteEdit): note is RenoteEdit;
export function isRenote(note: MinimalNote): note is MinimalRenote;
export function isRenote(note: MiNote | NoteEdit | MinimalNote): note is Renote | RenoteEdit | MinimalRenote;
export function isRenote(note: MiNote | NoteEdit | MinimalNote): note is Renote | RenoteEdit | MinimalRenote {
return note.renoteId != null; return note.renoteId != null;
} }
export function isQuote(note: Renote): note is Quote { export function isQuote(note: Renote): note is Quote;
export function isQuote(note: RenoteEdit): note is QuoteEdit;
export function isQuote(note: MinimalNote): note is MinimalQuote;
export function isQuote(note: Renote | RenoteEdit | MinimalNote): note is Quote | QuoteEdit | MinimalQuote;
export function isQuote(note: Renote | RenoteEdit | MinimalNote): note is Quote | QuoteEdit | MinimalQuote {
// NOTE: SYNC WITH NoteCreateService.isQuote // NOTE: SYNC WITH NoteCreateService.isQuote
return note.text != null || return note.text != null ||
note.cw != null || note.cw != null ||
@ -49,7 +58,11 @@ export function isQuote(note: Renote): note is Quote {
note.fileIds.length > 0; note.fileIds.length > 0;
} }
export function isPureRenote(note: MiNote): note is PureRenote { export function isPureRenote(note: MiNote): note is PureRenote;
export function isPureRenote(note: NoteEdit): note is PureRenoteEdit;
export function isPureRenote(note: MinimalNote): note is MinimalPureRenote;
export function isPureRenote(note: MiNote | NoteEdit | MinimalNote): note is PureRenote | PureRenoteEdit | MinimalPureRenote;
export function isPureRenote(note: MiNote | NoteEdit | MinimalNote): note is PureRenote | PureRenoteEdit | MinimalPureRenote {
return isRenote(note) && !isQuote(note); return isRenote(note) && !isQuote(note);
} }
@ -68,15 +81,16 @@ type PackedQuote =
} | { } | {
poll: NonNullable<Packed<'Note'>['poll']> poll: NonNullable<Packed<'Note'>['poll']>
} | { } | {
fileIds: NonNullable<Packed<'Note'>['fileIds']> fileIds: [string, ...string[]]
}); });
type PackedPureRenote = PackedRenote & { type PackedPureRenote = PackedRenote & {
text: NonNullable<Packed<'Note'>['text']>; text: null;
cw: NonNullable<Packed<'Note'>['cw']>; cw: null;
replyId: NonNullable<Packed<'Note'>['replyId']>; replyId: null;
poll: NonNullable<Packed<'Note'>['poll']>; reply: null;
fileIds: NonNullable<Packed<'Note'>['fileIds']>; poll: null;
fileIds: [];
}; };
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
@ -94,3 +108,58 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote {
export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote { export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote {
return isRenotePacked(note) && !isQuotePacked(note); return isRenotePacked(note) && !isQuotePacked(note);
} }
export type RenoteEdit =
NoteEdit & {
renoteId: NonNullable<NoteEdit['renoteId']>
};
export type QuoteEdit =
RenoteEdit & ({
text: NonNullable<NoteEdit['text']>
} | {
cw: NonNullable<NoteEdit['cw']>
} | {
replyId: NonNullable<NoteEdit['replyId']>
} | {
hasPoll: true
} | {
fileIds: [string, ...string[]],
});
export type PureRenoteEdit =
RenoteEdit & {
text: null,
cw: null,
replyId: null,
reply: null,
hasPoll: false,
fileIds: [],
};
export type MinimalNote = Pick<MiNote, 'id' | 'visibility' | 'userId' | 'replyId' | 'renoteId' | 'text' | 'cw' | 'hasPoll' | 'fileIds'>;
export type MinimalRenote = MinimalNote & {
renoteId: string;
};
export type MinimalQuote = MinimalRenote & ({
text: NonNullable<MinimalNote['text']>
} | {
cw: NonNullable<MinimalNote['cw']>
} | {
replyId: NonNullable<MinimalNote['replyId']>
} | {
hasPoll: true
} | {
fileIds: [string, ...string[]],
});
export type MinimalPureRenote = MinimalRenote & {
text: null,
cw: null,
replyId: null,
reply: null,
hasPoll: false,
fileIds: [],
};

Some files were not shown because too many files have changed in this diff Show more