merge: Remove infinite caches to prevent memory leak (!587)

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

Closes #600 and #601

Approved-by: Amelia Yukii <amelia.yukii@shourai.de>
Approved-by: Marie <marie@kaifa.ch>
This commit is contained in:
dakkar 2024-08-06 16:51:18 +00:00
commit 4e7df7a5f2
12 changed files with 94 additions and 87 deletions

View file

@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View file

@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity); this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity); this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity); this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity); this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown {
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);
} }
} }
} else { } else {
this.userByIdCache.set(user.id, user); this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === user.id) { if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user); this.uriPersonCache.set(k, user);
} }

View file

@ -26,7 +26,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService implements OnApplicationShutdown { export class CustomEmojiService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiEmoji | null>; private emojisCache: MemoryKVCache<MiEmoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
constructor( constructor(
@ -49,7 +49,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private driveService: DriveService, private driveService: DriveService,
) { ) {
this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -364,7 +364,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
host: newHost ?? IsNull(), host: newHost ?? IsNull(),
})) ?? null; })) ?? null;
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
@ -391,7 +391,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map(e => e.host)); const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) { for (const host of hosts) {
@ -406,7 +406,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : []; }) : [];
for (const emoji of _emojis) { for (const emoji of _emojis) {
this.cache.set(`${emoji.name} ${emoji.host}`, emoji); this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji);
} }
} }
@ -431,7 +431,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.emojisCache.dispose();
} }
@bindThis @bindThis

View file

@ -35,7 +35,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
} }
@bindThis @bindThis

View file

@ -129,10 +129,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
//this.onMessage = this.onMessage.bind(this); this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View file

@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity, memoryCacheLifetime: 1000 * 60 * 60, // 1h
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),

View file

@ -54,8 +54,8 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
} }
@bindThis @bindThis

View file

@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> { export class RedisKVCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemoryKVCache<T>;
private lifetime: number; private readonly fetcher: (key: string) => Promise<T>;
private memoryCache: MemoryKVCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: (key: string) => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { constructor(
lifetime: RedisKVCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisKVCache<T>['fetcher']; opts: {
toRedisConverter: RedisKVCache<T>['toRedisConverter']; lifetime: RedisKVCache<T>['lifetime'];
fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisKVCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisKVCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -55,7 +55,13 @@ export class RedisKVCache<T> {
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(key, value);
}
return value;
} }
@bindThis @bindThis
@ -77,14 +83,14 @@ export class RedisKVCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
return value; return value;
} }
@bindThis @bindThis
public async refresh(key: string) { public async refresh(key: string) {
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -101,23 +107,23 @@ export class RedisKVCache<T> {
} }
export class RedisSingleCache<T> { export class RedisSingleCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemorySingleCache<T>;
private lifetime: number; private readonly fetcher: () => Promise<T>;
private memoryCache: MemorySingleCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: () => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { constructor(
lifetime: RedisSingleCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisSingleCache<T>['fetcher']; opts: {
toRedisConverter: RedisSingleCache<T>['toRedisConverter']; lifetime: number;
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisSingleCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -149,7 +155,13 @@ export class RedisSingleCache<T> {
const cached = await this.redisClient.get(`singlecache:${this.name}`); const cached = await this.redisClient.get(`singlecache:${this.name}`);
if (cached == null) return undefined; if (cached == null) return undefined;
return this.fromRedisConverter(cached);
const value = this.fromRedisConverter(cached);
if (value !== undefined) {
this.memoryCache.set(value);
}
return value;
} }
@bindThis @bindThis
@ -171,14 +183,14 @@ export class RedisSingleCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
return value; return value;
} }
@bindThis @bindThis
public async refresh() { public async refresh() {
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -187,22 +199,12 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { export class MemoryKVCache<T> {
/** private readonly cache = new Map<string, { date: number; value: T; }>();
* private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
* @deprecated
*/
public cache: Map<string, { date: number; value: T; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout;
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(
this.cache = new Map(); private readonly lifetime: number,
this.lifetime = lifetime; ) {}
this.gcIntervalHandle = setInterval(() => {
this.gc();
}, 1000 * 60 * 3);
}
@bindThis @bindThis
/** /**
@ -287,10 +289,14 @@ export class MemoryKVCache<T> {
@bindThis @bindThis
public gc(): void { public gc(): void {
const now = Date.now(); const now = Date.now();
for (const [key, { date }] of this.cache.entries()) { for (const [key, { date }] of this.cache.entries()) {
if ((now - date) > this.lifetime) { // The map is ordered from oldest to youngest.
this.cache.delete(key); // We can stop once we find an entry that's still active, because all following entries must *also* be active.
} const age = now - date;
if (age < this.lifetime) break;
this.cache.delete(key);
} }
} }
@ -298,16 +304,19 @@ export class MemoryKVCache<T> {
public dispose(): void { public dispose(): void {
clearInterval(this.gcIntervalHandle); clearInterval(this.gcIntervalHandle);
} }
public get entries() {
return this.cache.entries();
}
} }
export class MemorySingleCache<T> { export class MemorySingleCache<T> {
private cachedAt: number | null = null; private cachedAt: number | null = null;
private value: T | undefined; private value: T | undefined;
private lifetime: number;
constructor(lifetime: MemorySingleCache<never>['lifetime']) { constructor(
this.lifetime = lifetime; private lifetime: number,
} ) {}
@bindThis @bindThis
public set(value: T): void { public set(value: T): void {

View file

@ -45,7 +45,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h
} }
@bindThis @bindThis

View file

@ -135,7 +135,7 @@ export class NodeinfoServerService {
return document; return document;
}; };
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m
fastify.get(nodeinfo2_1path, async (request, reply) => { fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(21)); const base = await cache.fetch(() => nodeinfo2(21));

View file

@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
) { ) {
this.appCache = new MemoryKVCache<MiApp>(Infinity); this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w
} }
@bindThis @bindThis

View file

@ -38,8 +38,8 @@ export class UrlPreviewService {
) { ) {
this.logger = this.loggerService.getLogger('url-preview'); this.logger = this.loggerService.getLogger('url-preview');
this.previewCache = new RedisKVCache<SummalyResult>(this.redisClient, 'summaly', { this.previewCache = new RedisKVCache<SummalyResult>(this.redisClient, 'summaly', {
lifetime: 1000 * 86400, lifetime: 1000 * 60 * 60 * 24, // 1d
memoryCacheLifetime: 1000 * 10 * 60, memoryCacheLifetime: 1000 * 60 * 10, // 10m
fetcher: (key: string) => { throw new Error('the UrlPreview cache should never fetch'); }, fetcher: (key: string) => { throw new Error('the UrlPreview cache should never fetch'); },
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),