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
#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
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
# relashionshipJobConcurrency: 16
# What's relashionshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
#deliverJobConcurrency: 128
#inboxJobConcurrency: 16
#relationshipJobConcurrency: 16
#backgroundJobConcurrency: 32
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 32
# relashionshipJobPerSec: 64
#deliverJobPerSec: 128
#inboxJobPerSec: 32
#relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
#deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests
#outgoingAddress: 127.0.0.1

View file

@ -223,17 +223,42 @@ id: 'aidx'
# Number of worker processes
#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
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
#deliverJobConcurrency: 128
#inboxJobConcurrency: 16
#relationshipJobConcurrency: 16
#backgroundJobConcurrency: 32
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 32
#deliverJobPerSec: 128
#inboxJobPerSec: 32
#relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
#deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4

View file

@ -307,21 +307,42 @@ id: 'aidx'
# Number of worker processes
#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
#deliverJobConcurrency: 128
#inboxJobConcurrency: 16
#relationshipJobConcurrency: 16
# What's relationshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
#backgroundJobConcurrency: 32
# Job rate limiter
#deliverJobPerSec: 128
#inboxJobPerSec: 32
#relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts
#deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests
#outgoingAddress: 127.0.0.1

View file

@ -310,21 +310,42 @@ id: 'aidx'
# Number of worker processes
#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
#deliverJobConcurrency: 128
#inboxJobConcurrency: 16
#relationshipJobConcurrency: 16
# What's relationshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
#backgroundJobConcurrency: 32
# Job rate limiter
#deliverJobPerSec: 128
#inboxJobPerSec: 32
#relationshipJobPerSec: 64
#backgroundJobPerSec: 256
# Job attempts
#deliverJobMaxAttempts: 12
#inboxJobMaxAttempts: 8
#backgroundJobMaxAttempts: 8
# Local address used for outgoing requests
#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?
*/
"restartMigrationConfirm": string;
/**
* Background queue
*/
"backgroundQueue": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -4,6 +4,8 @@
*/
export class FixIDXInstanceHostKey1748990662839 {
name = 'FixIDXInstanceHostKey1748990662839';
async up(queryRunner) {
// 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"`);

View file

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

View file

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

View file

@ -4,6 +4,8 @@
*/
export class CreateStatistics1748992128683 {
name = 'CreateStatistics1748992128683';
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_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);

View file

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

View file

@ -4,6 +4,8 @@
*/
export class RemoveIDXInstanceHostFilters1749267016885 {
name = 'RemoveIDXInstanceHostFilters1749267016885';
async up(queryRunner) {
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;
inboxJobConcurrency?: number;
relationshipJobConcurrency?: number;
backgroundJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
relationshipJobPerSec?: number;
backgroundJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;
backgroundJobMaxAttempts?: number;
mediaDirectory?: string;
mediaProxy?: string;
@ -272,11 +275,14 @@ export type Config = {
deliverJobConcurrency: number | undefined;
inboxJobConcurrency: number | undefined;
relationshipJobConcurrency: number | undefined;
backgroundJobConcurrency: number | undefined;
deliverJobPerSec: number | undefined;
inboxJobPerSec: number | undefined;
relationshipJobPerSec: number | undefined;
backgroundJobPerSec: number | undefined;
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
backgroundJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined;
customMOTD: string[] | undefined;
signToActivityPubGet: boolean;
@ -475,11 +481,14 @@ export function loadConfig(loggerService: LoggerService): Config {
deliverJobConcurrency: config.deliverJobConcurrency,
inboxJobConcurrency: config.inboxJobConcurrency,
relationshipJobConcurrency: config.relationshipJobConcurrency,
backgroundJobConcurrency: config.backgroundJobConcurrency,
deliverJobPerSec: config.deliverJobPerSec,
inboxJobPerSec: config.inboxJobPerSec,
relationshipJobPerSec: config.relationshipJobPerSec,
backgroundJobPerSec: config.backgroundJobPerSec,
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
backgroundJobMaxAttempts: config.backgroundJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles,
customMOTD: config.customMOTD,
signToActivityPubGet: config.signToActivityPubGet ?? true,

View file

@ -218,9 +218,9 @@ export class AnnouncementService {
announcementId: announcement.id,
userId: me.id,
});
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
return await this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
} 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 { MiNote } from '@/models/Note.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 type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class AntennaService implements OnApplicationShutdown {
// TODO implement QuantumSingleCache then replace this
private antennasFetched: boolean;
private antennas: MiAntenna[];
private antennas: Map<string, MiAntenna>;
constructor(
@Inject(DI.redisForTimelines)
@ -43,9 +46,10 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService,
private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService,
private readonly internalEventService: InternalEventService,
) {
this.antennasFetched = false;
this.antennas = [];
this.antennas = new Map();
this.redisForSub.on('message', this.onRedisMessage);
}
@ -58,35 +62,16 @@ export class AntennaService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'antennaCreated':
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
case 'antennaUpdated':
this.antennas.set(body.id, { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
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':
this.antennas = this.antennas.filter(a => a.id !== body.id);
this.antennas.delete(body.id);
break;
default:
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
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
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 redisPipeline = this.redisForTimelines.pipeline();
@ -107,7 +109,7 @@ export class AntennaService implements OnApplicationShutdown {
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
}
redisPipeline.exec();
await redisPipeline.exec();
}
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@ -212,13 +214,14 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis
public async getAntennas() {
if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.findBy({
const allAntennas = await this.antennasRepository.findBy({
isActive: true,
});
this.antennas = new Map(allAntennas.map(a => [a.id, a]));
this.antennasFetched = true;
}
return this.antennas;
return Array.from(this.antennas.values());
}
@bindThis

View file

@ -14,7 +14,9 @@ import { JsonValue } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js';
import { TimeService } from '@/global/TimeService.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()
export class ApLogService {
@ -23,7 +25,7 @@ export class ApLogService {
private readonly config: Config,
@Inject(DI.apContextsRepository)
private apContextsRepository: ApContextsRepository,
private readonly apContextsRepository: ApContextsRepository,
@Inject(DI.apInboxLogsRepository)
private readonly apInboxLogsRepository: ApInboxLogsRepository,
@ -34,6 +36,7 @@ export class ApLogService {
private readonly utilityService: UtilityService,
private readonly idService: IdService,
private readonly timeService: TimeService,
private readonly queueService: QueueService,
) {}
/**
@ -123,6 +126,16 @@ export class ApLogService {
.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
* @param objectUris URIs / AP IDs of the objects to delete

View file

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

View file

@ -605,12 +605,12 @@ export class ChatService {
@bindThis
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
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

View file

@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import type { MiLocalUser } from '@/models/User.js';
import { TimeService } from '@/global/TimeService.js';
@ -35,6 +36,7 @@ export class ClipService {
private roleService: RoleService,
private idService: IdService,
private readonly timeService: TimeService,
private readonly collapsedQueueService: CollapsedQueueService,
) {
}
@ -130,7 +132,7 @@ export class ClipService {
lastClippedAt: this.timeService.date,
});
this.notesRepository.increment({ id: noteId }, 'clippedCount', 1);
await this.collapsedQueueService.updateNoteQueue.enqueue(noteId, { clippedCountDelta: 1 });
}
@bindThis
@ -155,6 +157,6 @@ export class ClipService {
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 { ApUtilityService } from '@/core/activitypub/ApUtilityService.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 { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { AccountMoveService } from './AccountMoveService.js';
@ -218,7 +218,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
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 $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
@ -377,7 +377,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
UserSearchService,
UserSuspendService,
UserAuthService,
UpdateInstanceQueue,
CollapsedQueueService,
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
@ -531,7 +531,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$UserSearchService,
$UserSuspendService,
$UserAuthService,
$UpdateInstanceQueue,
$CollapsedQueueService,
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,
@ -686,7 +686,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
UserSearchService,
UserSuspendService,
UserAuthService,
UpdateInstanceQueue,
CollapsedQueueService,
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
@ -839,7 +839,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$UserSearchService,
$UserSuspendService,
$UserAuthService,
$UpdateInstanceQueue,
$CollapsedQueueService,
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,

View file

@ -23,6 +23,7 @@ import { DriveService } from '@/core/DriveService.js';
import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js';
import { TimeService } from '@/global/TimeService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { promiseMap } from '@/misc/promise-map.js';
import { isRetryableSymbol } from '@/misc/is-retryable-error.js';
import type Logger from '@/logger.js';
import { KeyNotFoundError } from '@/misc/errors/KeyNotFoundError.js';
@ -577,7 +578,7 @@ export class CustomEmojiService {
*/
@bindThis
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>;
for (let i = 0; i < emojiNames.length; i++) {
const resolvedEmoji = emojis[i];

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ export class ImageProcessingService {
*/
@bindThis
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
@ -100,7 +100,7 @@ export class ImageProcessingService {
*/
@bindThis
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
@ -142,7 +142,7 @@ export class ImageProcessingService {
*/
@bindThis
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

View file

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

View file

@ -59,6 +59,8 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
import { TimeService } from '@/global/TimeService.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';
@ -154,7 +156,6 @@ export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } |
@Injectable()
export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor(
@Inject(DI.config)
@ -226,8 +227,8 @@ export class NoteCreateService implements OnApplicationShutdown {
private latestNoteService: LatestNoteService,
private readonly timeService: TimeService,
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
@ -458,10 +459,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
await this.queueService.createPostNoteJob(note.id, silent, 'create');
return note;
}
@ -474,7 +472,7 @@ export class NoteCreateService implements OnApplicationShutdown {
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option): Promise<MiNote> {
return this.create(user, data, true);
return await this.create(user, data, true);
}
@bindThis
@ -577,13 +575,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private async postNoteCreated(note: MiNote, user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
public async postNoteCreated(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
this.notesChart.update(note, true);
if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
this.perUserNotesChart.update(user, note, true);
@ -594,7 +586,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
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) {
this.instanceChart.updateNote(i.host, note, true);
@ -606,26 +598,26 @@ export class NoteCreateService implements OnApplicationShutdown {
// ハッシュタグ更新
if (data.visibility === 'public' || data.visibility === 'home') {
if (!user.isBot || this.meta.enableBotTrending) {
this.hashtagService.updateHashtags(user, tags);
await this.queueService.createUpdateNoteTagsJob(note.id);
}
}
if (!this.isRenote(note) || this.isQuote(note)) {
// Increment notes count (user)
this.incNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: 1 });
}
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,
channel: data.channel ?? null,
}, user);
if (data.reply) {
this.saveReply(data.reply, note);
await this.collapsedQueueService.updateNoteQueue.enqueue(data.reply.id, { repliesCountDelta: 1 });
}
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) {
this.incRenoteCount(data.renote, user);
if (this.isPureRenote(data)) {
await this.collapsedQueueService.updateNoteQueue.enqueue(data.renote.id, { renoteCountDelta: 1 });
await this.incRenoteCount(data.renote, user);
}
if (data.poll && data.poll.expiresAt) {
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,
}, {
jobId: `pollEnd_${note.id}`,
@ -683,9 +676,9 @@ export class NoteCreateService implements OnApplicationShutdown {
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);
@ -714,7 +707,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!isThreadMuted && !muted) {
nm.push(data.reply.userId, 'reply');
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
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
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
if (!data.localOnly && isLocalUser(user)) {
trackTask(async () => {
await trackTask(async () => {
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
@ -790,12 +783,12 @@ export class NoteCreateService implements OnApplicationShutdown {
}
if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, {
await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
await this.channelsRepository.update(data.channel.id, {
lastNotedAt: this.timeService.date,
});
this.notesRepository.countBy({
await this.notesRepository.countBy({
userId: user.id,
channelId: data.channel.id,
}).then(count => {
@ -808,10 +801,10 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Update the Latest Note index / following feed
this.latestNoteService.handleCreatedNoteBG(note);
await this.latestNoteService.handleCreatedNoteDeferred(note);
// 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;
// Note: does not increment the count! used only for featured rankings.
@bindThis
private async incRenoteCount(renote: MiNote, user: MiUser) {
await this.notesRepository.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
})
.where('id = :id', { id: renote.id })
.execute();
// Moved down from the containing block
if (renote.userId === user.id || user.isBot) return;
// 30%の確率、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 (renote.channelId != null) {
if (renote.replyId == null) {
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
await this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
}
} else {
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
this.featuredService.updateGlobalNotesRanking(renote, 5);
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
await this.featuredService.updateGlobalNotesRanking(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.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
await this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
// Create notification
nm.push(u.id, 'mention');
@ -891,43 +881,23 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private saveReply(reply: MiNote, note: MiNote) {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
@bindThis
private index(note: MiNote) {
private async index(note: MiNote) {
if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note);
await this.searchService.indexNote(note);
}
@bindThis
private incNotesCountOfUser(user: { id: MiUser['id']; }) {
this.usersRepository.createQueryBuilder().update()
.set({
updatedAt: this.timeService.date,
notesCount: () => '"notesCount" + 1',
})
.where('id = :id', { id: user.id })
.execute();
}
public async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
if (tokens == null || tokens.length === 0) return [];
@bindThis
private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise<MiUser[]> {
if (tokens == null) return [];
const allMentions = extractMentions(tokens);
const mentions = new Map(allMentions.map(m => [`${m.username.toLowerCase()}@${m.host?.toLowerCase()}`, m]));
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);
const allMentionedUsers = await promiseMap(mentions.values(), async m => await this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), { limit: 2 });
const mentionedUsers = new Map(allMentionedUsers.filter(u => u != null).map(u => [u.id, u]));
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
i === self.findIndex(u2 => u.id === u2.id),
);
return mentionedUsers;
return Array.from(mentionedUsers.values());
}
@bindThis
@ -1040,7 +1010,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// checkHibernation moved to HibernateUsersProcessorService
}
r.exec();
await r.exec();
}
// checkHibernation moved to HibernateUsersProcessorService
@ -1062,20 +1032,11 @@ export class NoteCreateService implements OnApplicationShutdown {
return false;
}
@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);
}
// collapseNotesCount moved to CollapsedQueueService
@bindThis
public async dispose(): Promise<void> {
this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
}
@bindThis
@ -1100,8 +1061,8 @@ export class NoteCreateService implements OnApplicationShutdown {
// Instance cannot quote
if (user.host) {
const instance = await this.federatedInstanceService.fetch(user.host);
if (instance?.rejectQuotes) {
const instance = await this.federatedInstanceService.fetchOrRegister(user.host);
if (instance.rejectQuotes) {
(data as Option).renote = null;
(data.processErrors ??= []).push('quoteUnavailable');
}

View file

@ -22,18 +22,15 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.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 { ApLogService } from '@/core/ApLogService.js';
import type Logger from '@/logger.js';
import { TimeService } from '@/global/TimeService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { LoggerService } from '@/core/LoggerService.js';
import { trackTask } from '@/misc/promise-tracker.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
@Injectable()
export class NoteDeleteService {
private readonly logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@ -63,53 +60,56 @@ export class NoteDeleteService {
private latestNoteService: LatestNoteService,
private readonly apLogService: ApLogService,
private readonly timeService: TimeService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('note-delete-service');
}
private readonly collapsedQueueService: CollapsedQueueService,
) {}
/**
* 稿
* @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 cascadingNotes = await this.findCascadingNotes(note);
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)) {
await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => {
if (!renote) return;
if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1);
});
for (const cascade of cascadingNotes) {
if (cascade.replyId) {
await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.replyId, { repliesCountDelta: -1 });
} else if (isPureRenote(cascade)) {
await this.collapsedQueueService.updateNoteQueue.enqueue(cascade.renoteId, { renoteCountDelta: -1 });
}
}
if (!quiet) {
this.globalEventService.publishNoteStream(note.id, 'deleted', {
// Braces preserved to avoid merge conflicts
{
promises.push(this.globalEventService.publishNoteStream(note.id, 'deleted', {
deletedAt: deletedAt,
});
}));
for (const cascade of cascadingNotes) {
promises.push(this.globalEventService.publishNoteStream(cascade.id, 'deleted', {
deletedAt: deletedAt,
}));
}
//#region ローカルの投稿なら削除アクティビティを配送
if (isLocalUser(user) && !note.localOnly) {
let renote: MiNote | null = null;
// if deleted note is renote
if (isRenote(note) && !isQuote(note)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});
}
const renote = isPureRenote(note)
? await this.notesRepository.findOneBy({ id: note.renoteId })
: null;
const content = this.apRendererService.addContext(renote
? 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));
trackPromise(this.deliverToConcerned(user, note, content));
promises.push(this.deliverToConcerned(user, note, content));
}
// also deliver delete activity to cascaded notes
@ -118,7 +118,7 @@ export class NoteDeleteService {
if (!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));
trackPromise(this.deliverToConcerned(cascadingNote.user, cascadingNote, content));
promises.push(this.deliverToConcerned(cascadingNote.user, cascadingNote, content));
}
//#endregion
@ -127,90 +127,142 @@ export class NoteDeleteService {
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)
this.decNotesCountOfUser(user);
} else {
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { notesCountDelta: -1 });
}
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 (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (note.renoteId && note.text || !note.renoteId) {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if (!isPureRenote(note)) {
const i = await this.federatedInstanceService.fetchOrRegister(user.host);
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) {
this.instanceChart.updateNote(i.host, note, false);
this.instanceChart.updateNote(cascade.user.host, cascade, false);
}
});
}
}
}
}
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({
id: note.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)) {
const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
this.moderationLogService.log(deleter, 'deleteNote', {
if (deleter && (user.id !== deleter.id)) {
promises.push(this.moderationLogService.log(deleter, 'deleteNote', {
noteId: note.id,
noteUserId: note.userId,
noteUserUsername: user.username,
noteUserHost: user.host,
});
}));
}
const deletedUris = [note, ...cascadingNotes]
.map(n => n.uri)
.filter((u): u is string => u != null);
if (deletedUris.length > 0) {
this.apLogService.deleteObjectLogs(deletedUris)
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
promises.push(immediate
? 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
private decNotesCountOfUser(user: { id: MiUser['id']; }) {
this.usersRepository.createQueryBuilder().update()
.set({
updatedAt: this.timeService.date,
notesCount: () => '"notesCount" - 1',
})
.where('id = :id', { id: user.id })
.execute();
}
private async findCascadingNotes(note: MiNote): Promise<(MiNote & { user: MiUser })[]> {
const cascadingNotes: MiNote[] = [];
@bindThis
private async findCascadingNotes(note: MiNote): Promise<MiNote[]> {
const recursive = async (noteId: string): Promise<MiNote[]> => {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
/**
* Finds all replies, quotes, and renotes of the given list of notes.
* These are the notes that will be CASCADE deleted when the origin note is deleted.
*
* This works by operating in "layers" that radiate out from the origin note like a web.
* The process is roughly like this:
* 1. Find all immediate replies and renotes of the origin.
* 2. Find all immediate replies and renotes of the results from step one.
* 3. Repeat until step 2 returns no new results.
* 4. Collect all the step 2 results; those are the set of all cascading notes.
*/
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 [
replies,
...await Promise.all(replies.map(reply => recursive(reply.id))),
].flat();
// Stop when we reach the end of all threads
if (refs.length === 0) return;
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

View file

@ -3,18 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { DataSource, In, IsNull, LessThan } from 'typeorm';
import { DataSource, In } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } 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 { MiApp } from '@/models/App.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 { IdentifiableError } from '@/misc/identifiable-error.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { TimeService } from '@/global/TimeService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
@ -150,7 +148,6 @@ export type Option = {
@Injectable()
export class NoteEditService implements OnApplicationShutdown {
#shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor(
@Inject(DI.config)
@ -195,8 +192,8 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.noteEditRepository)
private noteEditRepository: NoteEditRepository,
@Inject(DI.noteEditsRepository)
private noteEditsRepository: NoteEditsRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@ -224,8 +221,8 @@ export class NoteEditService implements OnApplicationShutdown {
private noteCreateService: NoteCreateService,
private readonly timeService: TimeService,
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
@ -234,29 +231,29 @@ export class NoteEditService implements OnApplicationShutdown {
throw new UnrecoverableError('edit failed: missing editid');
}
const oldnote = await this.notesRepository.findOneBy({
const oldNote = await this.notesRepository.findOneBy({
id: editid,
});
if (oldnote == null) {
if (oldNote == null) {
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`);
}
// we never want to change the replyId, so fetch the original "parent"
if (oldnote.replyId) {
data.reply = await this.notesRepository.findOneBy({ id: oldnote.replyId });
if (oldNote.replyId) {
data.reply = await this.notesRepository.findOneBy({ id: oldNote.replyId });
} else {
data.reply = undefined;
}
// changing visibility on an edit is ill-defined, let's try to
// keep the same visibility as the original note
data.visibility = oldnote.visibility;
data.localOnly = oldnote.localOnly;
data.visibility = oldNote.visibility;
data.localOnly = oldNote.localOnly;
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
@ -354,12 +351,12 @@ export class NoteEditService implements OnApplicationShutdown {
}
// Check for recursion
if (data.renote.id === oldnote.id) {
throw new IdentifiableError('33510210-8452-094c-6227-4a6c05d99f02', `edit failed for ${oldnote.id}: note cannot quote itself`);
if (data.renote.id === oldNote.id) {
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;) {
if (nextRenoteId === oldnote.id) {
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: note cannot quote a quote of itself`);
if (nextRenoteId === oldNote.id) {
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
@ -432,7 +429,7 @@ export class NoteEditService implements OnApplicationShutdown {
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
@ -463,46 +460,52 @@ export class NoteEditService implements OnApplicationShutdown {
}
const update: Partial<MiNote> = {};
if (data.text !== undefined && data.text !== oldnote.text) {
if (data.text !== undefined && data.text !== oldNote.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;
}
if (data.poll !== undefined && oldnote.hasPoll !== !!data.poll) {
if (data.poll !== undefined && oldNote.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;
}
// 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 = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
const pollChanged = data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll);
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 newPollData = data.poll ? { choices: data.poll.choices, multiple: data.poll.multiple, expiresAt: data.poll.expiresAt ?? null } : null;
const pollChanged = data.poll !== undefined && JSON.stringify(oldPollData) !== JSON.stringify(newPollData);
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(),
noteId: oldnote.id,
oldText: oldnote.text || undefined,
userId: oldNote.userId,
noteId: oldNote.id,
renoteId: oldNote.renoteId,
replyId: oldNote.replyId,
visibility: oldNote.visibility,
text: oldNote.text || undefined,
newText: update.text || undefined,
cw: update.cw || undefined,
fileIds: undefined,
oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date,
cw: oldNote.cw || undefined,
newCw: update.cw || undefined,
fileIds: oldNote.fileIds,
oldDate: exists ? oldNote.updatedAt as Date : this.idService.parse(oldNote.id).date,
updatedAt: this.timeService.date,
hasPoll: oldPoll != null,
});
const note = new MiNote({
id: oldnote.id,
id: oldNote.id,
updatedAt: data.updatedAt ? data.updatedAt : this.timeService.date,
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: oldnote.replyId,
replyId: oldNote.replyId,
renoteId: data.renote ? data.renote.id : null,
channelId: data.channel ? data.channel.id : null,
threadId: data.reply
@ -516,7 +519,7 @@ export class NoteEditService implements OnApplicationShutdown {
cw: data.cw ?? null,
tags: tags.map(tag => normalizeForSearch(tag)),
emojis,
reactions: oldnote.reactions,
reactions: oldNote.reactions,
userId: user.id,
localOnly: data.localOnly!,
reactionAcceptance: data.reactionAcceptance,
@ -535,7 +538,7 @@ export class NoteEditService implements OnApplicationShutdown {
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
reactionAndUserPairCache: oldnote.reactionAndUserPairCache,
reactionAndUserPairCache: oldNote.reactionAndUserPairCache,
mandatoryCW: data.mandatoryCW,
});
@ -561,58 +564,55 @@ export class NoteEditService implements OnApplicationShutdown {
if (pollChanged) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.update(MiNote, oldnote.id, note);
await transactionalEntityManager.update(MiNote, oldNote.id, note);
const poll = new MiPoll({
noteId: note.id,
choices: data.poll!.choices,
expiresAt: data.poll!.expiresAt,
multiple: data.poll!.multiple,
votes: new Array(data.poll!.choices.length).fill(0),
noteVisibility: note.visibility,
userId: user.id,
userHost: user.host,
channelId: data.channel?.id ?? null,
});
// Insert or update poll
if (data.poll) {
const poll = new MiPoll({
noteId: note.id,
choices: data.poll.choices,
expiresAt: data.poll.expiresAt,
multiple: data.poll.multiple,
votes: new Array(data.poll.choices.length).fill(0),
noteVisibility: note.visibility,
userId: user.id,
userHost: user.host,
channelId: data.channel?.id ?? null,
});
if (!oldnote.hasPoll) {
await transactionalEntityManager.insert(MiPoll, poll);
} else {
await transactionalEntityManager.update(MiPoll, oldnote.id, poll);
if (oldPoll) {
await transactionalEntityManager.update(MiPoll, { noteId: oldPoll.noteId }, poll);
} else {
await transactionalEntityManager.insert(MiPoll, poll);
}
// Delete poll
} else if (oldPoll) {
await transactionalEntityManager.delete(MiPoll, { noteId: oldPoll.noteId });
}
});
} 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.
const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
() => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
await this.queueService.createPostNoteJob(note.id, silent, 'edit');
return edited;
} else {
return oldnote;
return oldNote;
}
}
@bindThis
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
public async postNoteEdited(note: MiNote, user: MiUser, data: MiNote & { poll: MiPoll | null }, silent: boolean, mentionedUsers: MinimumUser[]) {
// Register host
if (this.meta.enableStatsForFederatedInstances) {
if (isRemoteUser(user)) {
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
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) {
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) {
const delay = data.poll.expiresAt.getTime() - this.timeService.now;
this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
this.queueService.endedPollNotificationQueue.add(note.id, {
await this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
await this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id,
}, {
jobId: `pollEnd_${note.id}`,
@ -648,9 +648,9 @@ export class NoteEditService implements OnApplicationShutdown {
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);
@ -673,16 +673,16 @@ export class NoteEditService implements OnApplicationShutdown {
if (!isThreadMuted && !muted) {
nm.push(data.reply.userId, 'edited');
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
if (!data.localOnly && isLocalUser(user)) {
trackTask(async () => {
await trackTask(async () => {
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
@ -737,8 +737,8 @@ export class NoteEditService implements OnApplicationShutdown {
}
if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, {
await this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
await this.channelsRepository.update(data.channel.id, {
lastNotedAt: this.timeService.date,
});
@ -755,10 +755,10 @@ export class NoteEditService implements OnApplicationShutdown {
}
// Update the Latest Note index / following feed
this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
await this.latestNoteService.handleUpdatedNoteDeferred(note);
// Register to search database
if (!user.noindex) this.index(note);
if (!user.noindex) await this.index(note);
}
@bindThis
@ -779,27 +779,10 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
private index(note: MiNote) {
private async index(note: MiNote) {
if (note.text == null && note.cw == null) return;
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;
await this.searchService.indexNote(note);
}
@bindThis
@ -912,25 +895,14 @@ export class NoteEditService implements OnApplicationShutdown {
// checkHibernation moved to HibernateUsersProcessorService
}
r.exec();
await r.exec();
}
// 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
public async dispose(): Promise<void> {
this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
}
@bindThis

View file

@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import type { DataSource } from 'typeorm';
@Injectable()
@ -84,7 +85,7 @@ export class NotePiningService {
// Deliver to remote followers
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
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
private postReadAllNotifications(userId: MiUser['id']) {
private async postReadAllNotifications(userId: MiUser['id']) {
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
await this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
}
@bindThis

View file

@ -92,7 +92,7 @@ export class PollService {
public async deliverQuestionUpdate(note: MiNote) {
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 (isLocalUser(user)) {

View file

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

View file

@ -19,8 +19,10 @@ import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { TimeService } from '@/global/TimeService.js';
import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js';
import type { MiNote } from '@/models/Note.js';
import type { MinimalNote } from '@/misc/is-renote.js';
import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
BackgroundTaskJobData,
DbJobData,
DeliverJobData,
RelationshipJobData,
@ -39,6 +41,7 @@ import type {
SystemWebhookDeliverQueue,
UserWebhookDeliverQueue,
ScheduleNotePostQueue,
BackgroundTaskQueue,
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
@ -54,6 +57,7 @@ export const QUEUE_TYPES = [
'userWebhookDeliver',
'systemWebhookDeliver',
'scheduleNotePost',
'backgroundTask',
] as const;
@Injectable()
@ -72,6 +76,7 @@ export class QueueService implements OnModuleInit {
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
@Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue,
@Inject('queue:backgroundTask') public readonly backgroundTaskQueue: BackgroundTaskQueue,
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 UserWebhookDeliverProcessorService
@ -927,6 +1033,7 @@ export class QueueService implements OnModuleInit {
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
case 'scheduleNotePost': return this.ScheduleNotePostQueue;
case 'backgroundTask': return this.backgroundTaskQueue;
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 { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { TimeService } from '@/global/TimeService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import type { DataSource } from 'typeorm';
const FALLBACK = '\u2764';
@ -110,6 +111,7 @@ export class ReactionService implements OnModuleInit {
private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService,
private readonly timeService: TimeService,
private readonly collapsedQueueService: CollapsedQueueService,
) {
}
@ -119,7 +121,7 @@ export class ReactionService implements OnModuleInit {
}
@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
if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -224,7 +226,7 @@ export class ReactionService implements OnModuleInit {
.execute();
}
this.usersRepository.update({ id: user.id }, { updatedAt: this.timeService.date });
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { updatedAt: this.timeService.date });
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if (
@ -289,16 +291,18 @@ export class ReactionService implements OnModuleInit {
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
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);
}
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
} else if (note.visibility === 'specified') {
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
for (const u of visibleUsers.filter(u => u && isRemoteUser(u))) {
dm.addDirectRecipe(u as MiRemoteUser);
const visibleUsers = await this.cacheService.findUsersById(note.visibleUserIds);
for (const u of visibleUsers.values()) {
if (isRemoteUser(u)) {
dm.addDirectRecipe(u as MiRemoteUser);
}
}
}
@ -308,7 +312,7 @@ export class ReactionService implements OnModuleInit {
}
@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
exist ??= await this.noteReactionsRepository.findOneBy({
noteId: note.id,
@ -340,7 +344,7 @@ export class ReactionService implements OnModuleInit {
.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', {
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 dm = this.apDeliverManagerService.createDeliverManager(user, content);
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.addFollowersRecipe();

View file

@ -8,7 +8,7 @@ import chalk from 'chalk';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.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 Logger from '@/logger.js';
import { UtilityService } from '@/core/UtilityService.js';
@ -59,7 +59,7 @@ export class RemoteUserResolveService {
const acct = Acct.toString({ username, host }); // username+host -> acct (handle)
// 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
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,
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : 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,
instance: null,
userProfile: null,
@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
updatedAt: parsed.user2.updatedAt != null ? new Date(parsed.user2.updatedAt) : null,
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : 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,
instance: null,
userProfile: null,

View file

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

View file

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

View file

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

View file

@ -61,6 +61,7 @@ export class SponsorsService {
}
try {
// TODO use HTTP service
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
@ -76,6 +77,7 @@ export class SponsorsService {
@bindThis
private async fetchSharkeySponsors(): Promise<Sponsor[]> {
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 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
public async instanceSponsors(forceUpdate: boolean) {
if (forceUpdate) await this.cache.refresh('instance');
return this.cache.fetch('instance');
return await this.cache.fetch('instance');
}
@bindThis
public async sharkeySponsors(forceUpdate: boolean) {
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 { LoggerService } from '@/core/LoggerService.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';
type Local = MiLocalUser | {
@ -88,6 +90,7 @@ export class UserFollowingService implements OnModuleInit {
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
private readonly internalEventService: InternalEventService,
private readonly collapsedQueueService: CollapsedQueueService,
loggerService: LoggerService,
) {
@ -102,7 +105,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
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
@ -152,7 +155,7 @@ export class UserFollowingService implements OnModuleInit {
// すでにフォロー関係が存在している場合
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
// リモート → ローカル: acceptを送り返しておしまい
this.deliverAccept(follower, followee, requestId);
trackPromise(this.deliverAccept(follower, followee, requestId));
return;
}
if (this.userEntityService.isLocalUser(follower)) {
@ -206,7 +209,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, silent, withReplies);
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.
if (!followeeUser.movedToUri && !followerUser.movedToUri) {
//#region Increment counts
await Promise.all([
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
]);
await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: 1 });
await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: 1 });
//#endregion
//#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
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) {
this.instanceChart.updateFollowing(i.host, true);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
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) {
this.instanceChart.updateFollowers(i.host, true);
}
@ -397,24 +398,22 @@ export class UserFollowingService implements OnModuleInit {
// Neither followee nor follower has moved.
if (!follower.movedToUri && !followee.movedToUri) {
//#region Decrement following / followers counts
await Promise.all([
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
]);
await this.collapsedQueueService.updateUserQueue.enqueue(follower.id, { followingCountDelta: -1 });
await this.collapsedQueueService.updateUserQueue.enqueue(followee.id, { followersCountDelta: -1 });
//#endregion
//#region Update instance stats
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
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) {
this.instanceChart.updateFollowing(i.host, false);
}
});
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
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) {
this.instanceChart.updateFollowers(i.host, false);
}
@ -581,7 +580,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
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, {
@ -595,14 +594,13 @@ export class UserFollowingService implements OnModuleInit {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'];
},
): Promise<void> {
const requests = await this.followRequestsRepository.findBy({
const requests = await this.followRequestsRepository.find({ where: {
followeeId: user.id,
});
}, relations: {
follower: true,
} });
for (const request of requests) {
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
this.acceptFollowRequest(user, follower);
}
await Promise.all(requests.map(request => this.acceptFollowRequest(user, request.follower as MiUser)));
}
/**
@ -611,7 +609,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower);
trackPromise(this.deliverReject(user, follower));
}
await this.removeFollowRequest(user, follower);
@ -627,7 +625,7 @@ export class UserFollowingService implements OnModuleInit {
@bindThis
public async rejectFollow(user: Local, follower: Both): Promise<void> {
if (this.userEntityService.isRemoteUser(follower)) {
this.deliverReject(user, follower);
trackPromise(this.deliverReject(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));
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
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
return this.cacheService.isFollowing(followerId, followeeId);
return await this.cacheService.isFollowing(followerId, followeeId);
}
@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),
me,
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' },

View file

@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js';
import { CollapsedQueueService } from '@/core/CollapsedQueueService.js';
import { TimeService } from '@/global/TimeService.js';
@Injectable()
@ -22,43 +22,14 @@ export class UserService {
private followingsRepository: FollowingsRepository,
private systemWebhookService: SystemWebhookService,
private userEntityService: UserEntityService,
private readonly cacheService: CacheService,
private readonly collapsedQueueService: CollapsedQueueService,
private readonly timeService: TimeService,
) {
}
@bindThis
public async updateLastActiveDate(user: MiUser): Promise<void> {
if (user.isHibernated) {
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,
});
}
await this.collapsedQueueService.updateUserQueue.enqueue(user.id, { lastActiveDate: this.timeService.date });
}
/**
@ -70,6 +41,6 @@ export class UserService {
@bindThis
public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
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 { isSystemAccount } from '@/misc/is-system-account.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';
@Injectable()
export class UserSuspendService {
private readonly logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -47,11 +41,7 @@ export class UserSuspendService {
private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
private readonly internalEventService: InternalEventService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('user-suspend');
}
) {}
@bindThis
public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
@ -69,10 +59,7 @@ export class UserSuspendService {
userHost: user.host,
});
trackPromise((async () => {
await this.postSuspend(user);
await this.freezeAll(user);
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
await this.queueService.createPostSuspendJob(user.id);
}
@bindThis
@ -89,14 +76,11 @@ export class UserSuspendService {
userHost: user.host,
});
trackPromise((async () => {
await this.postUnsuspend(user);
await this.unFreezeAll(user);
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
await this.queueService.createPostUnsuspendJob(user.id);
}
@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 });
/*
@ -132,10 +116,12 @@ export class UserSuspendService {
await this.queueService.deliverMany(user, content, queue);
}
await this.freezeAll(user);
}
@bindThis
private async postUnsuspend(user: MiUser): Promise<void> {
public async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
@ -162,6 +148,8 @@ export class UserSuspendService {
await this.queueService.deliverMany(user, content, queue);
}
await this.unFreezeAll(user);
}
@bindThis

View file

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

View file

@ -92,10 +92,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
key: MiUserPublickey;
} | null> {
const key = await this.apPersonService.findPublicKeyByKeyId(keyId);
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.isDeleted) return null;

View file

@ -33,10 +33,6 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { fromTuple } from '@/misc/from-tuple.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import FederationChart from '@/core/chart/charts/federation.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { TimeService } from '@/global/TimeService.js';
@ -97,10 +93,6 @@ export class ApInboxService {
private queueService: QueueService,
private globalEventService: GlobalEventService,
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 noteVisibilityService: NoteVisibilityService,
private readonly timeService: TimeService,
@ -115,7 +107,7 @@ export class ApInboxService {
const results = [] as [string, string | void][];
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++) {
const act = items[i];
if (act.id != null) {
@ -153,11 +145,10 @@ export class ApInboxService {
// ついでにリモートユーザーの情報が古かったら更新しておく
if (actor.uri) {
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
{
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri)
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
});
await this.apPersonService.updatePersonLazy(actor);
}
}
}
return result;
@ -424,42 +415,14 @@ export class ApInboxService {
}
// Update stats (adapted from InboxProcessorService)
this.federationChart.inbox(actor.host).then();
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();
});
await this.queueService.createPostInboxJob(actor.host);
// Process it!
return await this.performOneActivity(actor, activity, resolver)
.finally(() => {
// Update user (adapted from performActivity)
if (actor.lastFetchedAt == null || this.timeService.now - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
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)}`));
});
}
});
try {
return await this.performOneActivity(actor, activity, resolver);
} finally {
await this.apPersonService.updatePersonLazy(actor);
}
}
@bindThis

View file

@ -5,7 +5,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import promiseLimit from 'promise-limit';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.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 { isPureRenote } from '@/misc/is-renote.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 { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
@ -68,27 +70,21 @@ export class Resolver {
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
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
const collection = typeof value === 'string'
? sentFromUri
? await this.secureResolve(value, sentFromUri, allowAnonymous)
: await this.resolve(value, allowAnonymous)
: value; // TODO try and remove this eventually, as it's a major security foot-gun
const collection = sentFromUri
? await this.secureResolve(value, sentFromUri, allowAnonymous)
: allowAnonymous
? await this.resolveAnonymous(value)
: await this.resolve(value, allowAnonymous);
if (isCollectionOrOrderedCollection(collection)) {
return collection;
} 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.
* 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.
* @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 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 ignoreErrors If true (default), inaccessible items will be skipped instead of causing an exception. Inaccessible collections will still throw.
*/
@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[] = [];
// This is pulled up to avoid code duplication below
@ -108,11 +106,10 @@ export class Resolver {
const sentFrom = current.id;
const itemArr = toArray(items);
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
const allowAnonymous = allowAnonymousItems ?? false;
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems, ignoreErrors);
};
let current: AnyCollection | null = await this.resolveCollection(collection);
let current: AnyCollection | null = await this.resolveCollection(collection, allowAnonymous, sentFromUri);
do {
// Iterate all items in the current page
if (current.items) {
@ -130,10 +127,10 @@ export class Resolver {
current = null;
} else if (isCollection(current) || isOrderedCollection(current)) {
// 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)) {
// 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 {
// Stop in all other conditions
current = null;
@ -143,17 +140,12 @@ export class Resolver {
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, 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> {
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[], ignoreErrors?: boolean): Promise<void> {
const recursionLimit = this.recursionLimit - this.history.size;
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
const limiter = promiseLimit<IObject>(concurrency);
const batch = await Promise.all(source
.slice(0, batchLimit)
.map(item => limiter(async () => {
const batch = await promiseMap(source.slice(0, batchLimit), async item => {
try {
if (sentFrom) {
// Use secureResolve to avoid re-fetching items that were included inline.
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
@ -164,9 +156,22 @@ export class Resolver {
const id = getApId(item);
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);
// Save or finalize asynchronously
this.apLogService.saveFetchLog(log)
.catch(err => this.logger.error('Failed to record AP object fetch:', err));
trackPromise(this.apLogService.saveFetchLog(log)
.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();
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
// 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,
});
}
@ -142,7 +142,7 @@ export class JsonLdService {
@bindThis
public async normalize(data: Document): Promise<string> {
const customLoader = this.getLoader();
return (await import('jsonld')).default.normalize(data, {
return await (await import('jsonld')).default.normalize(data, {
documentLoader: customLoader,
});
}

View file

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

View file

@ -6,7 +6,6 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import promiseLimit from 'promise-limit';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.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 { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.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 { CustomEmojiService, encodeEmojiKey, isValidEmojiName } from '@/core/CustomEmojiService.js';
import { TimeService } from '@/global/TimeService.js';
@ -277,7 +277,7 @@ export class ApNoteService implements OnModuleInit {
return x;
})
.catch(async err => {
.catch(err => {
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);
})
@ -456,7 +456,7 @@ export class ApNoteService implements OnModuleInit {
return x;
})
.catch(async err => {
.catch(err => {
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);
})
@ -583,8 +583,8 @@ export class ApNoteService implements OnModuleInit {
const emojiKeys = eomjiTags.map(tag => encodeEmojiKey({ name: tag.name, host }));
const existingEmojis = await this.customEmojiService.emojisByKeyCache.fetchMany(emojiKeys);
return await Promise.all(eomjiTags.map(async tag => {
const name = tag.name;
return await promiseMap(eomjiTags, async tag => {
const name = tag.name.replaceAll(':', '');
tag.icon = toSingle(tag.icon);
const exists = existingEmojis.values.find(x => x.name === name);
@ -627,7 +627,9 @@ export class ApNoteService implements OnModuleInit {
// _misskey_license が存在しなければ `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
const quote = results.find(r => typeof(r) === 'object');
@ -753,14 +755,10 @@ export class ApNoteService implements OnModuleInit {
// Resolve all files w/ concurrency 2.
// This prevents one big file from blocking the others.
const limiter = promiseLimit<MiDriveFile | null>(2);
const results = await Promise
.all(Array
.from(attachments.values())
.map(attach => limiter(async () => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
})));
const results = await promiseMap(attachments.values(), async attach => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
}, { limit: 2 });
// Process results
let hasFileError = false;

View file

@ -24,7 +24,6 @@ import type { MiNote } from '@/models/Note.js';
import { IdService } from '@/core/IdService.js';
import type { MfmService } from '@/core/MfmService.js';
import { toArray } from '@/misc/prelude/array.js';
import type { GlobalEventService } from '@/core/GlobalEventService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.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 { isRetryableError } from '@/misc/is-retryable-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 { 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 { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@ -72,7 +74,6 @@ export class ApPersonService implements OnModuleInit {
private readonly publicKeyByUserIdCache: ManagedQuantumKVCache<MiUserPublickey>;
private driveFileEntityService: DriveFileEntityService;
private globalEventService: GlobalEventService;
private federatedInstanceService: FederatedInstanceService;
private fetchInstanceMetadataService: FetchInstanceMetadataService;
private cacheService: CacheService;
@ -86,6 +87,7 @@ export class ApPersonService implements OnModuleInit {
private instanceChart: InstanceChart;
private accountMoveService: AccountMoveService;
private logger: Logger;
private idService: IdService;
constructor(
private moduleRef: ModuleRef,
@ -120,9 +122,10 @@ export class ApPersonService implements OnModuleInit {
private readonly cacheManagementService: CacheManagementService,
private readonly utilityService: UtilityService,
private readonly apUtilityService: ApUtilityService,
private readonly idService: IdService,
private readonly timeService: TimeService,
private readonly queueService: QueueService,
private readonly collapsedQueueService: CollapsedQueueService,
private readonly internalEventService: InternalEventService,
apLoggerService: ApLoggerService,
) {
@ -181,7 +184,6 @@ export class ApPersonService implements OnModuleInit {
@bindThis
onModuleInit(): void {
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.globalEventService = this.moduleRef.get('GlobalEventService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
this.cacheService = this.moduleRef.get('CacheService');
@ -194,6 +196,7 @@ export class ApPersonService implements OnModuleInit {
this.usersChart = this.moduleRef.get('UsersChart');
this.instanceChart = this.moduleRef.get('InstanceChart');
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,
};
let userId;
let userId: string | null | undefined;
// Resolve URI -> User ID
const parsed = this.utilityService.parseUri(uri);
if (parsed.local) {
userId = parsed.type === 'users' ? parsed.id : null;
} else {
userId = await this.uriPersonCache.fetch(uri).catch(() => null);
userId = await this.uriPersonCache.fetchMaybe(uri);
}
// No match
@ -316,8 +319,7 @@ export class ApPersonService implements OnModuleInit {
return null;
}
const user = await this.cacheService.findUserById(userId)
.catch(() => null) as MiLocalUser | MiRemoteUser | null;
const user = await this.cacheService.findOptionalUserById(userId) as MiLocalUser | MiRemoteUser | null;
if (user?.isDeleted && !_opts.withDeleted) {
return null;
@ -329,8 +331,9 @@ export class ApPersonService implements OnModuleInit {
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'>>> {
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
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
if (Array.isArray(img)) {
@ -343,7 +346,7 @@ export class ApPersonService implements OnModuleInit {
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))
@ -574,28 +577,23 @@ export class ApPersonService implements OnModuleInit {
// Register host
if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.federatedInstanceService.fetchOrRegister(host).then(async i => {
await this.collapsedQueueService.updateInstanceQueue.enqueue(i.id, { usersCountDelta: 1 });
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
await this.fetchInstanceMetadataService.fetchInstanceMetadataLazy(i);
});
}
this.usersChart.update(user, true);
// ハッシュタグ更新
this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ
try {
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image, person.backgroundUrl);
await this.usersRepository.update(user.id, updates);
await this.internalEventService.emit('remoteUserUpdated', { id: user.id });
user = { ...user, ...updates };
// Register to the cache
await this.uriPersonCache.set(user.uri, user.id);
} catch (err) {
// Permanent error implies hidden or inaccessible, which is a normal thing.
if (isRetryableError(err)) {
@ -604,16 +602,29 @@ export class ApPersonService implements OnModuleInit {
}
//#endregion
await this.updateFeatured(user.id, resolver).catch(err => {
// Permanent error implies hidden or inaccessible, which is a normal thing.
if (isRetryableError(err)) {
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
}
});
// ハッシュタグ更新
await this.queueService.createUpdateUserTagsJob(user.id);
await this.updateFeaturedLazy(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の情報を更新します
* Misskeyに対象のPersonが登録されていなければ無視します
@ -688,13 +699,16 @@ export class ApPersonService implements OnModuleInit {
const profileUrls = url ? [url, person.id] : [person.id];
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
const featuredUri = person.featured ? getApId(person.featured) : undefined;
const updates = {
lastFetchedAt: this.timeService.date,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
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,
name: truncate(person.name, nameLength),
tags,
@ -751,9 +765,15 @@ export class ApPersonService implements OnModuleInit {
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) {
const publicKey = new MiUserPublickey({
userId: exist.id,
userId: updated.id,
keyId: person.publicKey.id,
keyPem: person.publicKey.publicKeyPem,
});
@ -767,7 +787,7 @@ export class ApPersonService implements OnModuleInit {
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey),
]);
} else {
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: exist.id });
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: updated.id });
if (existingPublicKey) {
// Delete key
await Promise.all([
@ -786,7 +806,7 @@ export class ApPersonService implements OnModuleInit {
_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,
fields,
verifiedLinks,
@ -798,33 +818,25 @@ export class ApPersonService implements OnModuleInit {
location: person['vcard:Address'] ?? null,
listenbrainz: person.listenbrainz ?? null,
});
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id });
// ハッシュタグ更新
this.hashtagService.updateUsertags(exist, tags);
await this.cacheService.userProfileCache.delete(updated.id);
// 該当ユーザーが既にフォロワーになっていた場合は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(
{ followerId: exist.id },
{ followerId: updated.id },
{
followerInbox: person.inbox,
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.
if (isRetryableError(err)) {
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
}
});
// ハッシュタグ更新
await this.queueService.createUpdateUserTagsJob(updated.id);
const updated = { ...exist, ...updates };
await this.updateFeaturedLazy(updated);
// 移行処理を行う
if (updated.movedAt && (
@ -902,43 +914,71 @@ export class ApPersonService implements OnModuleInit {
return fields;
}
/**
* Schedules a deferred update on the background task worker.
* Duplicate updates are automatically skipped.
*/
@bindThis
public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise<void> {
const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false });
if (!isRemoteUser(user)) return;
if (!user.featured) return;
public async updateFeaturedLazy(userOrId: MiRemoteUser | MiUser['id']): Promise<void> {
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
const user = typeof(userOrId) === 'object' ? userOrId : await this.cacheService.findRemoteUserById(userId);
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
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)}`);
}
await this.queueService.createUpdateFeaturedJob(userId);
}
return null;
}) : null;
if (!collection) return;
@bindThis
public async updateFeatured(userOrId: MiRemoteUser | MiUser['id'], resolver?: Resolver): Promise<void> {
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
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
this.logger.info(`Updating featured notes for: ${user.uri}`);
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
const limit = promiseLimit<MiNote | null>(2);
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
.slice(0, maxPinned)
.map(item => limit(() => this.apNoteService.resolveNote(item, {
resolver: _resolver,
sentFrom: user.uri,
}))));
const items = await resolver.resolveCollectionItems(user.featured, true, user.uri, maxPinned, 2);
const featuredNotes = await promiseMap(items, async item => {
const itemId = getNullableApId(item);
if (itemId && isPost(item)) {
try {
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 transactionalEntityManager.delete(MiUserNotePining, { userId: user.id });
@ -947,7 +987,7 @@ export class ApPersonService implements OnModuleInit {
let td = 0;
for (const note of featuredNotes.filter(x => x != null)) {
td -= 1000;
transactionalEntityManager.insert(MiUserNotePining, {
await transactionalEntityManager.insert(MiUserNotePining, {
id: this.idService.gen(this.timeService.now + td),
userId: user.id,
noteId: note.id,
@ -971,6 +1011,7 @@ export class ApPersonService implements OnModuleInit {
let dst = await this.fetchPerson(src.movedToUri);
if (dst && isLocalUser(dst)) {
// TODO this branch should not be possible
// targetがローカルユーザーだった場合データベースから引っ張ってくる
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser;
} else if (dst) {

View file

@ -50,9 +50,9 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
}
@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;
await this.commit({
this.commit({
'read': [user.id],
'registeredWithinWeek': (this.timeService.now - createdAt.getTime() < week) ? [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
public async write(user: { id: MiUser['id'], host: null }): Promise<void> {
await this.commit({
public write(user: { id: MiUser['id'], host: null }): void {
this.commit({
'write': [user.id],
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,7 +70,7 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
}
@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 prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';

View file

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

View file

@ -47,7 +47,7 @@ export default class PerUserReactionsChart extends Chart<typeof schema> { // esl
}
@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';
this.commit({
[`${prefix}.count`]: 1,

View file

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

View file

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

View file

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

View file

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

View file

@ -61,10 +61,10 @@ export default class UsersChart extends Chart<typeof schema> { // eslint-disable
}
@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';
await this.commit({
this.commit({
[`${prefix}.total`]: isAdditional ? 1 : -1,
[`${prefix}.inc`]: isAdditional ? 1 : 0,
[`${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 { bindThis } from '@/decorators.js';
import { MiRepository, miRepository } from '@/models/_.js';
import { promiseMap } from '@/misc/promise-map.js';
import type { DataSource, Repository } from 'typeorm';
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));
await Promise.all(
groups.map(group =>
Promise.all([
this.claimCurrentLog(group, 'hour'),
this.claimCurrentLog(group, 'day'),
]).then(([logHour, logDay]) =>
update(logHour, logDay))));
await promiseMap(groups, async group => {
const logHour = await this.claimCurrentLog(group, 'hour');
const logDay = await this.claimCurrentLog(group, 'day');
await update(logHour, logDay);
}, {
limit: 2,
});
}
@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, 'day'),
]).then(([logHour, logDay]) =>

View file

@ -35,6 +35,7 @@ export class BlockingEntityService {
): Promise<Packed<'Blocking'>> {
const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({
id: blocking.id,
createdAt: this.idService.parse(blocking.id).date.toISOString(),
@ -53,6 +54,6 @@ export class BlockingEntityService {
const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId);
const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' })
.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]))),
]);
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
@ -165,7 +165,7 @@ export class ChatEntityService {
.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
@ -228,7 +228,7 @@ export class ChatEntityService {
.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
@ -289,7 +289,7 @@ export class ChatEntityService {
}).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
@ -322,7 +322,7 @@ export class ChatEntityService {
) {
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
@ -371,6 +371,6 @@ export class ChatEntityService {
.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 clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({
id: clip.id,
createdAt: this.idService.parse(clip.id).date.toISOString(),
@ -65,7 +66,7 @@ export class ClipEntityService {
const _users = clips.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.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 });
// noinspection ES6MissingAwait
return await awaitAll<Packed<'DriveFile'>>({
id: file.id,
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 });
if (file == null) return null;
// noinspection ES6MissingAwait
return await awaitAll<Packed<'DriveFile'>>({
id: file.id,
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]));
}
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 }>()
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
: [];
return Promise.all(
return await Promise.all(
flashes.map(flash => this.pack(flash, me, {
packedUser: _userMap.get(flash.userId),
likedFlashIds: _likedFlashIds,

View file

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

View file

@ -139,6 +139,7 @@ export class FollowingEntityService {
if (opts == null) opts = {};
// noinspection ES6MissingAwait
return await awaitAll({
id: following.id,
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 _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' })
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(
return await Promise.all(
followings.map(following => {
const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : 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 post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({
id: post.id,
createdAt: this.idService.parse(post.id).date.toISOString(),
@ -68,7 +69,7 @@ export class GalleryPostEntityService {
const _users = posts.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.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 _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(
return await Promise.all(
tickets.map(ticket => {
const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : 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 });
// noinspection ES6MissingAwait
return await awaitAll({
id: log.id,
createdAt: this.idService.parse(log.id).date.toISOString(),
@ -53,7 +54,7 @@ export class ModerationLogEntityService {
const _users = reports.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' })
.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'>> {
const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({
id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(),
@ -55,7 +56,7 @@ export class MutingEntityService {
const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' })
.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;
// noinspection ES6MissingAwait
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
threadId,

View file

@ -5,12 +5,13 @@
import { Inject, Injectable } from '@nestjs/common';
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 { MiUser } from '@/models/User.js';
import type { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from './NoteEntityService.js';
@Injectable()
@ -28,6 +29,7 @@ export class NoteFavoriteEntityService {
public async pack(
src: MiNoteFavorite['id'] | MiNoteFavorite,
me?: { id: MiUser['id'] } | null | undefined,
notes?: Map<string, Packed<'Note'>>,
) {
const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src });
@ -35,15 +37,18 @@ export class NoteFavoriteEntityService {
id: favorite.id,
createdAt: this.idService.parse(favorite.id).date.toISOString(),
noteId: favorite.noteId,
note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
note: notes?.get(favorite.noteId) ?? await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me),
};
}
@bindThis
public packMany(
favorites: any[],
public async packMany(
favorites: (MiNoteFavorite & { note: MiNote })[],
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 _userMap = await this.userEntityService.packMany(_users, me)
.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);
if (migrated) {
this.pagesRepository.update(page.id, {
await this.pagesRepository.update(page.id, {
content: page.content,
});
}
// noinspection ES6MissingAwait
return await awaitAll({
id: page.id,
createdAt: this.idService.parse(page.id).date.toISOString(),
@ -104,10 +105,13 @@ export class PageEntityService {
font: page.font,
script: page.script,
eyeCatchingImageId: page.eyeCatchingImageId,
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(x => x != null)),
eyeCatchingImage: page.eyeCatchingImageId ? this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: Promise
.all(attachedFiles)
.then(fs => fs.filter(x => x != null))
.then(fs => this.driveFileEntityService.packMany(fs)),
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 _userMap = await this.userEntityService.packMany(_users, me)
.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'>> {
const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({
id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(),
@ -54,7 +55,7 @@ export class RenoteMutingEntityService {
const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' })
.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) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
this.usersRepository.update(user.id, {
await this.usersRepository.update(user.id, {
avatarUrl: user.avatarUrl,
avatarBlurhash: avatar.blurhash,
});
@ -493,7 +493,7 @@ export class UserEntityService implements OnModuleInit {
if (user.bannerId != null && user.bannerUrl === null) {
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
this.usersRepository.update(user.id, {
await this.usersRepository.update(user.id, {
bannerUrl: user.bannerUrl,
bannerBlurhash: banner.blurhash,
});
@ -501,7 +501,7 @@ export class UserEntityService implements OnModuleInit {
if (user.backgroundId != null && user.backgroundUrl === null) {
const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId });
user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background);
this.usersRepository.update(user.id, {
await this.usersRepository.update(user.id, {
backgroundUrl: user.backgroundUrl,
backgroundBlurhash: background.blurhash,
});
@ -581,6 +581,7 @@ export class UserEntityService implements OnModuleInit {
const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false);
// noinspection ES6MissingAwait
const packed = {
id: user.id,
name: user.name,
@ -644,6 +645,7 @@ export class UserEntityService implements OnModuleInit {
...(isDetailed ? {
url: profile!.url,
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,
movedToUri: user.movedToUri,
// alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy
@ -894,7 +896,7 @@ export class UserEntityService implements OnModuleInit {
myFollowingsPromise,
]);
return Promise.all(
return await Promise.all(
_users.map(u => this.pack(
u,
me,

View file

@ -67,7 +67,7 @@ export class UserListEntityService {
const _users = memberships.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users)
.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,
createdAt: this.idService.parse(x.id).date.toISOString(),
userId: x.userId,

View file

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

View file

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

View file

@ -3,45 +3,169 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import promiseLimit from 'promise-limit';
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> = {
value: V;
timer: TimerHandle;
};
// TODO: redis使えるようにする
export class CollapsedQueue<K, V> {
private jobs: Map<K, Job<V>> = new Map();
// TODO document IPC sync process
// 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(
protected readonly timeService: TimeService,
private timeout: number,
private collapse: (oldValue: V, newValue: V) => V,
private perform: (key: K, value: V) => Promise<void>,
) {}
enqueue(key: K, value: V) {
if (this.jobs.has(key)) {
const old = this.jobs.get(key)!;
const merged = this.collapse(old.value, value);
this.jobs.set(key, { ...old, value: merged });
} else {
const timer = this.timeService.startTimer(() => {
const job = this.jobs.get(key)!;
this.jobs.delete(key);
this.perform(key, job.value);
}, this.timeout);
this.jobs.set(key, { value, timer });
private readonly internalEventService: InternalEventService,
private readonly timeService: TimeService,
public readonly name: string,
private readonly timeout: number,
private readonly collapse: (oldValue: V, newValue: V) => V,
private readonly perform: (key: string, value: V) => Promise<void | unknown>,
private readonly opts?: {
onError?: (queue: CollapsedQueue<V>, error: unknown) => void | Promise<void>,
concurrency?: number,
redisParser?: (data: Serialized<V>) => V,
},
) {
if (opts?.concurrency) {
this.limiter = promiseLimit<void>(opts.concurrency);
}
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() {
const entries = [...this.jobs.entries()];
this.jobs.clear();
for (const [_key, job] of entries) {
for (const job of this.jobs.values()) {
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;
}
}
/**
* 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 { Packed } from '@/misc/json-schema.js';
import type { NoteEdit } from '@/models/NoteEdit.js';
// NoteEntityService.isPureRenote とよしなにリンク
type Renote =
export type Renote =
MiNote & {
renoteId: NonNullable<MiNote['renoteId']>
};
type Quote =
export type Quote =
Renote & ({
text: NonNullable<MiNote['text']>
} | {
cw: NonNullable<MiNote['cw']>
} | {
replyId: NonNullable<MiNote['replyId']>
reply: NonNullable<MiNote['reply']>
reply: NonNullable<MiNote['reply']> // TODO this is wrong
} | {
hasPoll: true
} | {
fileIds: [string, ...string[]]
});
type PureRenote =
export type PureRenote =
Renote & {
text: null,
cw: null,
replyId: null,
hasPoll: false,
fileIds: {
length: 0,
},
fileIds: [],
};
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;
}
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
return note.text != null ||
note.cw != null ||
@ -49,7 +58,11 @@ export function isQuote(note: Renote): note is Quote {
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);
}
@ -68,15 +81,16 @@ type PackedQuote =
} | {
poll: NonNullable<Packed<'Note'>['poll']>
} | {
fileIds: NonNullable<Packed<'Note'>['fileIds']>
fileIds: [string, ...string[]]
});
type PackedPureRenote = PackedRenote & {
text: NonNullable<Packed<'Note'>['text']>;
cw: NonNullable<Packed<'Note'>['cw']>;
replyId: NonNullable<Packed<'Note'>['replyId']>;
poll: NonNullable<Packed<'Note'>['poll']>;
fileIds: NonNullable<Packed<'Note'>['fileIds']>;
text: null;
cw: null;
replyId: null;
reply: null;
poll: null;
fileIds: [];
};
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 {
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