Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Server
- Enhance: Docker・systemd の起動時に pnpm を常駐させないようにしてメモリ使用量を削減
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
- Enhance: ActivityPub の featured 応答・JSON-LD エラー処理・フェデレーション集計の負荷を軽減


## 2026.5.4
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { SignupService } from './SignupService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
import { FeaturedCollectionCacheService } from './FeaturedCollectionCacheService.js';
import { UserService } from './UserService.js';
import { UserFollowingService } from './UserFollowingService.js';
import { UserKeypairService } from './UserKeypairService.js';
Expand Down Expand Up @@ -215,6 +216,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $FeaturedCollectionCacheService: Provider = { provide: 'FeaturedCollectionCacheService', useExisting: FeaturedCollectionCacheService };
const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
Expand Down Expand Up @@ -377,6 +379,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
FeaturedCollectionCacheService,
UserService,
UserFollowingService,
UserKeypairService,
Expand Down Expand Up @@ -535,6 +538,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebAuthnService,
$UserBlockingService,
$CacheService,
$FeaturedCollectionCacheService,
$UserService,
$UserFollowingService,
$UserKeypairService,
Expand Down Expand Up @@ -695,6 +699,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebAuthnService,
UserBlockingService,
CacheService,
FeaturedCollectionCacheService,
UserService,
UserFollowingService,
UserKeypairService,
Expand Down
242 changes: 242 additions & 0 deletions packages/backend/src/core/FeaturedCollectionCacheService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { IOrderedCollection } from '@/core/activitypub/type.js';

export const FEATURED_COLLECTION_CACHE_TTL_SECONDS = 180;
const FEATURED_COLLECTION_VERSION_TTL_SECONDS = 24 * 60 * 60;
const FEATURED_COLLECTION_LOCK_TTL_MS = 30_000;
const FEATURED_COLLECTION_LOCK_POLL_INTERVAL_MS = 100;
const FEATURED_COLLECTION_LOCK_WAIT_TIMEOUT_MS = 5_000;

export type FeaturedCollection = IOrderedCollection & {
'@context': unknown;
};

type InFlightRender = {
promise: Promise<FeaturedCollection>;
token: {
invalidated: boolean;
};
};

/**
* Short-lived cache for the local ActivityPub featured collection.
*
* The collection is public and the endpoint already advertises a 180 second
* cache lifetime. Redis is used as the shared cache so all backend workers can
* reuse a rendered collection, while the in-flight map prevents a thundering
* herd inside one worker when a key expires.
*/
@Injectable()
export class FeaturedCollectionCacheService {
private readonly inFlight = new Map<string, InFlightRender>();

constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
}

private cacheKey(userId: string): string {
return `kvcache:activityPubFeatured:${userId}`;
}

private versionKey(userId: string): string {
return `${this.cacheKey(userId)}:version`;
}

private lockKey(userId: string): string {
return `${this.cacheKey(userId)}:lock`;
}

public async fetch(userId: string, loader: () => Promise<FeaturedCollection>): Promise<FeaturedCollection> {
const pending = this.inFlight.get(userId);
if (pending != null) return pending.promise;

const token = { invalidated: false };
const promise = this.fetchAndStore(userId, token, loader);
const render = { promise, token };
this.inFlight.set(userId, render);

try {
return await promise;
} finally {
if (this.inFlight.get(userId) === render) this.inFlight.delete(userId);
}
}

private async fetchAndStore(
userId: string,
token: InFlightRender['token'],
loader: () => Promise<FeaturedCollection>,
): Promise<FeaturedCollection> {
const cached = await this.get(userId);
if (cached != null) return cached;

const lockToken = randomUUID();
const lockState = await this.tryAcquireLock(userId, lockToken);
if (lockState === 'acquired') {
return this.fetchWithLock(userId, lockToken, token, loader);
}
if (lockState === 'unavailable') {
return this.renderAndStore(userId, token, loader);
}

// Another worker is rendering this user. Poll the shared cache instead of
// rendering the same collection again. A bounded fallback preserves
// availability if the lock holder crashed or Redis lost the lock state.
const deadline = Date.now() + FEATURED_COLLECTION_LOCK_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
await new Promise<void>(resolve => setTimeout(resolve, FEATURED_COLLECTION_LOCK_POLL_INTERVAL_MS));
const cached = await this.get(userId);
if (cached != null) return cached;

const nextLockState = await this.tryAcquireLock(userId, lockToken);
if (nextLockState === 'acquired') {
return this.fetchWithLock(userId, lockToken, token, loader);
}
if (nextLockState === 'unavailable') {
return this.renderAndStore(userId, token, loader);
}
}

return this.renderAndStore(userId, token, loader);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private async fetchWithLock(
userId: string,
lockToken: string,
token: InFlightRender['token'],
loader: () => Promise<FeaturedCollection>,
): Promise<FeaturedCollection> {
try {
// The lock holder may have been queued behind another worker that
// populated the value just before this lock was acquired.
const cached = await this.get(userId);
if (cached != null) return cached;
return await this.renderAndStore(userId, token, loader);
} finally {
await this.releaseLock(userId, lockToken);
}
}

private async renderAndStore(
userId: string,
token: InFlightRender['token'],
loader: () => Promise<FeaturedCollection>,
): Promise<FeaturedCollection> {
const version = await this.getVersion(userId);
const value = await loader();

// A pin update may have happened while rendering. Do not repopulate Redis
// with the now-stale result after invalidate() marked this render.
if (!token.invalidated) await this.set(userId, version, value);

return value;
}

private async tryAcquireLock(userId: string, token: string): Promise<'acquired' | 'busy' | 'unavailable'> {
try {
const result = await this.redisClient.set(
this.lockKey(userId),
token,
'PX', FEATURED_COLLECTION_LOCK_TTL_MS,
'NX',
);
return result === 'OK' ? 'acquired' : 'busy';
} catch {
return 'unavailable';
}
}

private async releaseLock(userId: string, token: string): Promise<void> {
try {
await this.redisClient.eval(
`if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0`,
1,
this.lockKey(userId),
token,
);
} catch {
// The lock has a bounded TTL and will expire automatically.
}
}

private async get(userId: string): Promise<FeaturedCollection | undefined> {
try {
const value = await this.redisClient.get(this.cacheKey(userId));
return value == null ? undefined : JSON.parse(value) as FeaturedCollection;
} catch {
// The cache is an optimization. Redis failures must not make AP
// responses fail; the caller will render from the database instead.
return undefined;
}
}

private async getVersion(userId: string): Promise<string> {
try {
return await this.redisClient.get(this.versionKey(userId)) ?? '0';
} catch {
return '0';
}
}

private async set(userId: string, version: string, value: FeaturedCollection): Promise<void> {
try {
// Check the invalidation version and write the value atomically. This
// prevents another worker's in-flight render from repopulating Redis
// after a pin update has invalidated the key.
await this.redisClient.eval(
`local current = redis.call('get', KEYS[1])
if (current == false and ARGV[1] == '0') or current == ARGV[1] then
return redis.call('set', KEYS[2], ARGV[2], 'EX', ARGV[3])
end
return nil`,
2,
this.versionKey(userId),
this.cacheKey(userId),
version,
JSON.stringify(value),
FEATURED_COLLECTION_CACHE_TTL_SECONDS,
);
} catch {
// Best-effort cache only; keep serving the freshly rendered value.
}
}

public async invalidate(userId: string): Promise<void> {
// Let a new request start a fresh render immediately. The old promise is
// still allowed to finish, but its token prevents stale writes.
const render = this.inFlight.get(userId);
if (render != null) render.token.invalidated = true;
this.inFlight.delete(userId);

try {
// Replacing the version with a non-repeating token and deleting the
// value atomically closes the race with a render running in another
// backend worker. A random token remains safe even if the version key
// expired between the render and this invalidation.
await this.redisClient.eval(
`redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
return redis.call('del', KEYS[2])`,
2,
this.versionKey(userId),
this.cacheKey(userId),
randomUUID(),
FEATURED_COLLECTION_VERSION_TTL_SECONDS,
);
} catch {
// Best-effort invalidation; TTL remains the safety net.
}
}
}
7 changes: 7 additions & 0 deletions packages/backend/src/core/NoteDeleteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { FeaturedCollectionCacheService } from '@/core/FeaturedCollectionCacheService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';

@Injectable()
Expand Down Expand Up @@ -53,6 +54,7 @@ export class NoteDeleteService {
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart,
private featuredCollectionCacheService: FeaturedCollectionCacheService,
) {}

/**
Expand All @@ -62,6 +64,7 @@ export class NoteDeleteService {
*/
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
const deletedAt = new Date();
const invalidatesFeaturedCollection = this.userEntityService.isLocalUser(user);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (note.replyId) {
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
Expand Down Expand Up @@ -115,6 +118,10 @@ export class NoteDeleteService {
userId: user.id,
});

if (invalidatesFeaturedCollection) {
await this.featuredCollectionCacheService.invalidate(user.id);
}

if (deleter && (note.userId !== deleter.id)) {
const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
this.moderationLogService.log(deleter, 'deleteNote', {
Expand Down
10 changes: 9 additions & 1 deletion packages/backend/src/core/NotePiningService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { FeaturedCollectionCacheService } from '@/core/FeaturedCollectionCacheService.js';

@Injectable()
export class NotePiningService {
Expand All @@ -40,6 +41,7 @@ export class NotePiningService {
private relayService: RelayService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
private featuredCollectionCacheService: FeaturedCollectionCacheService,
) {
}

Expand Down Expand Up @@ -75,6 +77,9 @@ export class NotePiningService {
userId: user.id,
noteId: note.id,
} as MiUserNotePining);
if (this.userEntityService.isLocalUser(user)) {
await this.featuredCollectionCacheService.invalidate(user.id);
}

// Deliver to remote followers
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
Expand All @@ -99,10 +104,13 @@ export class NotePiningService {
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
}

this.userNotePiningsRepository.delete({
await this.userNotePiningsRepository.delete({
userId: user.id,
noteId: note.id,
});
if (this.userEntityService.isLocalUser(user)) {
await this.featuredCollectionCacheService.invalidate(user.id);
}

// Deliver to remote followers
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
Expand Down
Loading
Loading