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:
commit
8c196d5cb2
229 changed files with 3209 additions and 1384 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
locales/index.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
export class CreateIDXNoteForTimelines1748991828473 {
|
||||
name = 'CreateIDXNoteForTimelines1748991828473';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`
|
||||
create index "IDX_note_for_timelines"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
export class CreateIDXInstanceHostFilters1748992017688 {
|
||||
name = 'CreateIDXInstanceHostFilters1748992017688';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`
|
||||
create index "IDX_instance_host_filters"
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
export class FixIDXNoteForTimeline1749097536193 {
|
||||
name = 'FixIDXNoteForTimeline1749097536193';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||
await queryRunner.query(`
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
export class RemoveIDXInstanceHostFilters1749267016885 {
|
||||
name = 'RemoveIDXInstanceHostFilters1749267016885';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
383
packages/backend/src/core/CollapsedQueueService.ts
Normal file
383
packages/backend/src/core/CollapsedQueueService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -844,7 +844,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
await redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]) =>
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue