Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## Unreleased

### General
-
- Feat: お知らせに自動アーカイブ日時を設定できるように

### Client
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
Expand Down
3 changes: 3 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1654,6 +1654,9 @@ _announcement:
forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。"
needConfirmationToRead: "既読にするのに確認が必要"
needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。"
autoArchiveAt: "自動アーカイブ日時"
autoArchiveAtDescription: "指定した日時を過ぎると、このお知らせは自動的にアーカイブされます。空欄の場合は自動的にアーカイブされません。"
autoArchiveAtMustBeInFuture: "自動アーカイブ日時には現在より後の日時を指定してください。"
end: "お知らせを終了"
tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。"
readConfirmTitle: "既読にしますか?"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AutoArchiveAnnouncements1783913268595 {
name = 'AutoArchiveAnnouncements1783913268595';

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD "autoArchiveAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`);
}

async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "autoArchiveAt"`);
}
}
84 changes: 75 additions & 9 deletions packages/backend/src/core/AnnouncementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable } from '@nestjs/common';
import { Brackets, EntityNotFoundError } from 'typeorm';
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
import { Brackets, EntityNotFoundError, IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
Expand All @@ -14,9 +14,10 @@ import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueueService } from '@/core/QueueService.js';

@Injectable()
export class AnnouncementService {
export class AnnouncementService implements OnModuleInit {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
Expand All @@ -31,9 +32,23 @@ export class AnnouncementService {
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
private queueService: QueueService,
) {
}

@bindThis
public async onModuleInit(): Promise<void> {
// アップデート前に作成されたお知らせやRedisの再構築後にも予約を復元する
const announcements = await this.announcementsRepository.findBy({
isActive: true,
autoArchiveAt: Not(IsNull()),
});

await Promise.all(announcements.map(announcement =>
this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!),
));
}

Comment on lines +35 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Promise.all in onModuleInit can abort bootstrap on a single failure.

If scheduleAnnouncementArchive rejects for any one announcement (e.g. transient Redis issue), Promise.all rejects the whole batch, and since this runs in OnModuleInit, it can fail application bootstrap for an unrelated transient error, risking a crash-loop/outage on deploy or restart.

🔧 Suggested fix: isolate per-item failures
 		await Promise.all(announcements.map(announcement =>
-			this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!),
+			this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!).catch(err => {
+				// ログを出し、他の予約処理をブロックしないようにする
+				console.error(`Failed to reschedule archive for announcement ${announcement.id}`, err);
+			}),
 		));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private queueService: QueueService,
) {
}
@bindThis
public async onModuleInit(): Promise<void> {
// アップデート前に作成されたお知らせやRedisの再構築後にも予約を復元する
const announcements = await this.announcementsRepository.findBy({
isActive: true,
autoArchiveAt: Not(IsNull()),
});
await Promise.all(announcements.map(announcement =>
this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!),
));
}
private queueService: QueueService,
) {
}
`@bindThis`
public async onModuleInit(): Promise<void> {
// アップデート前に作成されたお知らせやRedisの再構築後にも予約を復元する
const announcements = await this.announcementsRepository.findBy({
isActive: true,
autoArchiveAt: Not(IsNull()),
});
await Promise.all(announcements.map(announcement =>
this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!).catch(err => {
// ログを出し、他の予約処理をブロックしないようにする
console.error(`Failed to reschedule archive for announcement ${announcement.id}`, err);
}),
));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/core/AnnouncementService.ts` around lines 35 - 51,
Update onModuleInit so a failure from scheduling one announcement does not
reject the entire initialization flow: isolate each scheduleAnnouncementArchive
call and handle its rejection per announcement, while allowing the remaining
announcements to be scheduled and bootstrap to complete.

@bindThis
public async getReads(userId: MiUser['id']): Promise<MiAnnouncementRead[]> {
return this.announcementReadsRepository.findBy({
Expand All @@ -43,12 +58,17 @@ export class AnnouncementService {

@bindThis
public async getUnreadAnnouncements(user: MiUser): Promise<MiAnnouncement[]> {
const now = new Date();
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
.select('read.announcementId')
.where('read.userId = :userId', { userId: user.id });

const q = this.announcementsRepository.createQueryBuilder('announcement')
.where('announcement.isActive = true')
.andWhere(new Brackets(qb => {
qb.where('announcement.autoArchiveAt IS NULL');
qb.orWhere('announcement.autoArchiveAt > :now', { now });
}))
.andWhere('announcement.silence = false')
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.userId = :userId', { userId: user.id });
Expand Down Expand Up @@ -79,14 +99,23 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
userId: values.userId,
autoArchiveAt: values.autoArchiveAt ?? null,
isActive: values.isActive ?? (values.autoArchiveAt == null || values.autoArchiveAt > new Date()),
});

const packed = await this.announcementEntityService.pack(announcement);

if (announcement.isActive && announcement.autoArchiveAt != null) {
await this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt);
}

// 作成処理中にautoArchiveAtを過ぎる可能性があるため、insert完了時点で非アクティブなお知らせはイベント配信しない
if (values.userId) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
announcement: packed,
});
if (announcement.isActive) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
announcement: packed,
});
}

if (moderator) {
const user = await this.usersRepository.findOneByOrFail({ id: values.userId });
Expand All @@ -99,9 +128,11 @@ export class AnnouncementService {
});
}
} else {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
});
if (announcement.isActive) {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
});
}

if (moderator) {
this.moderationLogService.log(moderator, 'createGlobalAnnouncement', {
Expand Down Expand Up @@ -131,10 +162,21 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
isActive: values.isActive,
autoArchiveAt: values.autoArchiveAt,
});

const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id });

if (announcement.autoArchiveAt?.getTime() !== after.autoArchiveAt?.getTime() || announcement.isActive !== after.isActive) {
if (announcement.autoArchiveAt != null) {
await this.queueService.clearAnnouncementArchive(announcement.id, announcement.autoArchiveAt);
}

if (after.isActive && after.autoArchiveAt != null) {
await this.queueService.scheduleAnnouncementArchive(after.id, after.autoArchiveAt);
}
}

if (moderator) {
if (announcement.userId) {
const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId });
Expand All @@ -156,10 +198,34 @@ export class AnnouncementService {
}
}

@bindThis
public async archiveAnnouncement(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise<boolean> {
const now = new Date();

// 日時も照合し、日時変更前に予約された古いジョブがお知らせをアーカイブしないようにする
const result = await this.announcementsRepository.createQueryBuilder()
.update()
.set({
isActive: false,
updatedAt: now,
})
.where('id = :announcementId', { announcementId })
.andWhere('isActive = true')
.andWhere('autoArchiveAt = :autoArchiveAt', { autoArchiveAt })
.andWhere('autoArchiveAt <= :now', { now })
.execute();

return (result.affected ?? 0) > 0;
}

@bindThis
public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise<void> {
await this.announcementsRepository.delete(announcement.id);

if (announcement.autoArchiveAt != null) {
await this.queueService.clearAnnouncementArchive(announcement.id, announcement.autoArchiveAt);
}

if (moderator) {
if (announcement.userId) {
const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId });
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/core/QueueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { MetricsTime, type JobType } from 'bullmq';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiAnnouncement } from '@/models/Announcement.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import type { Config } from '@/config.js';
Expand All @@ -20,6 +21,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
DbJobData,
ArchiveAnnouncementJobData,
DeliverJobData,
RelationshipJobData,
SystemWebhookDeliverJobData,
Expand Down Expand Up @@ -247,6 +249,44 @@ export class QueueService {
});
}

private announcementArchiveJobId(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): string {
return `archiveAnnouncement-${announcementId}-${autoArchiveAt.getTime()}`;
}

@bindThis
public async scheduleAnnouncementArchive(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise<void> {
const jobId = this.announcementArchiveJobId(announcementId, autoArchiveAt);
const existingJob = await this.systemQueue.getJob(jobId);
if (existingJob != null) {
const state = await existingJob.getState();
if (state !== 'completed' && state !== 'failed') return;
await this.systemQueue.remove(jobId);
}

const data: ArchiveAnnouncementJobData = {
announcementId,
autoArchiveAt: autoArchiveAt.getTime(),
};

await this.systemQueue.add('archiveAnnouncement', data, {
jobId,
delay: Math.max(0, autoArchiveAt.getTime() - Date.now()),
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
Comment on lines +256 to +282

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate system-queue defaults and archive processor behavior.
rg -n -C 4 'queue:system|defaultJobOptions|attempts:|archiveAnnouncement' packages/backend

# Locate coverage for scheduling and failed-job recovery.
rg -n -C 3 'scheduleAnnouncementArchive|ArchiveAnnouncement|autoArchiveAt' \
  packages/backend/test packages/backend/src

Repository: misskey-dev/misskey

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== QueueModule.ts outline ==\n'
ast-grep outline packages/backend/src/core/QueueModule.ts --view expanded || true

printf '\n== QueueModule.ts relevant lines ==\n'
sed -n '1,140p' packages/backend/src/core/QueueModule.ts | cat -n

printf '\n== search baseQueueOptions / defaultJobOptions ==\n'
rg -n -C 4 'function baseQueueOptions|const baseQueueOptions|defaultJobOptions|attempts' packages/backend/src/core packages/backend/src/queue

printf '\n== base queue helper file candidates ==\n'
fd -a 'QueueModule.ts|queue.*ts|baseQueueOptions' packages/backend/src | head -n 50

Repository: misskey-dev/misskey

Length of output: 17366


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== packages/backend/src/queue/const.ts ==\n'
sed -n '1,220p' packages/backend/src/queue/const.ts | cat -n

printf '\n== search for defaultJobOptions in repository ==\n'
rg -n -C 3 'defaultJobOptions|attempts\s*[:=]' packages/backend/src packages/backend/test

printf '\n== search for system queue add sites ==\n'
rg -n -C 2 "systemQueue\.add\(" packages/backend/src

Repository: misskey-dev/misskey

Length of output: 7389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== QueueProcessorService worker setup ==\n'
sed -n '1,260p' packages/backend/src/queue/QueueProcessorService.ts | cat -n

printf '\n== search for Worker creation / retry-related options ==\n'
rg -n -C 4 'new Bull\.Worker|Worker\(|attempts|backoff|autorun|concurrency|useWorker' packages/backend/src/queue packages/backend/src/core

printf '\n== bullmq version and docs references in repo ==\n'
rg -n -C 2 'bullmq|retrying-failing-jobs|defaultJobOptions' package.json pnpm-lock.yaml packages/backend/package.json README.md packages/backend/src

Repository: misskey-dev/misskey

Length of output: 50375


🌐 Web query:

BullMQ default attempts value if attempts option is omitted retry failing jobs documentation

💡 Result:

In BullMQ, the default value for the attempts option is 1 [1][2][3]. This means that if the attempts option is omitted, the job will be executed once and will not be retried upon failure [4][3]. To enable automatic retries, you must explicitly set the attempts option to a value greater than 1 [4].

Citations:


Add retries for announcement archive jobs. BullMQ defaults attempts to 1, so a transient failure here won’t retry and the announcement can stay active until something else reschedules it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/core/QueueService.ts` around lines 256 - 282, Update
scheduleAnnouncementArchive to configure retries in the options passed to
systemQueue.add for archiveAnnouncement jobs. Set an attempts value greater than
one, preserving the existing job ID, delay, and cleanup behavior.

}

@bindThis
public async clearAnnouncementArchive(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise<void> {
await this.systemQueue.remove(this.announcementArchiveJobId(announcementId, autoArchiveAt));
}

@bindThis
public createDeleteDriveFilesJob(user: ThinUser) {
return this.dbQueue.add('deleteDriveFiles', {
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/models/Announcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export class MiAnnouncement {
})
public isActive: boolean;

@Column('timestamp with time zone', {
comment: 'The date after which the Announcement is automatically archived.',
nullable: true,
})
public autoArchiveAt: Date | null;

@Index()
@Column('boolean', {
default: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/queue/QueueProcessorModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { ArchiveAnnouncementProcessorService } from './processors/ArchiveAnnouncementProcessorService.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
Expand Down Expand Up @@ -55,6 +56,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ResyncChartsProcessorService,
CleanChartsProcessorService,
CheckExpiredMutingsProcessorService,
ArchiveAnnouncementProcessorService,
BakeBufferedReactionsProcessorService,
CleanProcessorService,
DeleteDriveFilesProcessorService,
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/queue/QueueProcessorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { ArchiveAnnouncementProcessorService } from './processors/ArchiveAnnouncementProcessorService.js';
import type { ArchiveAnnouncementJobData } from './types.js';
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
Expand Down Expand Up @@ -126,6 +128,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private archiveAnnouncementProcessorService: ArchiveAnnouncementProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService,
Expand Down Expand Up @@ -169,6 +172,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'cleanCharts': return this.cleanChartsProcessorService.process();
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'archiveAnnouncement': return this.archiveAnnouncementProcessorService.process(job as Bull.Job<ArchiveAnnouncementJobData>);
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Injectable } from '@nestjs/common';
import type * as Bull from 'bullmq';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import type { ArchiveAnnouncementJobData } from '../types.js';
import { QueueLoggerService } from '../QueueLoggerService.js';

@Injectable()
export class ArchiveAnnouncementProcessorService {
private logger: Logger;

constructor(
private announcementService: AnnouncementService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('archive-announcement');
}

@bindThis
public async process(job: Bull.Job<ArchiveAnnouncementJobData>): Promise<void> {
const archived = await this.announcementService.archiveAnnouncement(
job.data.announcementId,
new Date(job.data.autoArchiveAt),
);

if (archived) {
this.logger.succ(`Archived announcement ${job.data.announcementId}.`);
} else {
this.logger.debug(`Announcement ${job.data.announcementId} no longer matches the scheduled archive job.`);
}
}
}
6 changes: 6 additions & 0 deletions packages/backend/src/queue/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiAnnouncement } from '@/models/Announcement.js';
import type { MiNote } from '@/models/Note.js';
import type { SystemWebhookEventType } from '@/models/SystemWebhook.js';
import type { MiUser } from '@/models/User.js';
Expand Down Expand Up @@ -113,6 +114,11 @@ export type PostScheduledNoteJobData = {
noteDraftId: string;
};

export type ArchiveAnnouncementJobData = {
announcementId: MiAnnouncement['id'];
autoArchiveAt: number;
};

export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = {
type: T;
content: SystemWebhookPayload<T>;
Expand Down
Loading
Loading