manage GC timers in CacheManagementService

This commit is contained in:
Hazelnoot 2025-10-02 15:35:27 -04:00
parent acf29ff40b
commit 2f4270b8f7
3 changed files with 168 additions and 45 deletions

View file

@ -16,7 +16,7 @@ import {
import { QuantumKVCache, type QuantumKVOpts } from '@/misc/QuantumKVCache.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import { TimeService } from '@/core/TimeService.js';
import { TimeService, type TimerHandle } from '@/core/TimeService.js';
import { InternalEventService } from '@/core/InternalEventService.js';
// This is the one place that's *supposed* to new() up caches.
@ -28,8 +28,10 @@ export type ManagedRedisKVCache<T> = Managed<RedisKVCache<T>>;
export type ManagedRedisSingleCache<T> = Managed<RedisSingleCache<T>>;
export type ManagedQuantumKVCache<T> = Managed<QuantumKVCache<T>>;
export type Managed<T> = Omit<T, 'dispose' | 'onApplicationShutdown'>;
export type Manager = { dispose(): void, clear(): void };
export type Managed<T> = Omit<T, 'dispose' | 'onApplicationShutdown' | 'gc'>;
export type Manager = { dispose(): void, clear(): void, gc(): void };
export const GC_INTERVAL = 1000 * 60 * 3; // 3m
/**
* Creates and "manages" instances of any standard cache type.
@ -38,6 +40,7 @@ export type Manager = { dispose(): void, clear(): void };
@Injectable()
export class CacheManagementService implements OnApplicationShutdown {
private readonly managedCaches = new Set<Manager>();
private gcTimer?: TimerHandle | null;
constructor(
@Inject(DI.redis)
@ -87,18 +90,32 @@ export class CacheManagementService implements OnApplicationShutdown {
protected manageCache<T extends Manager>(cache: T): Managed<T> {
this.managedCaches.add(cache);
this.startGcTimer();
return cache;
}
@bindThis
public gc(): void {
this.resetGcTimer(() => {
// TODO callAll()
for (const manager of this.managedCaches) {
manager.gc();
}
});
}
@bindThis
public clear(): void {
for (const manager of this.managedCaches) {
manager.clear();
}
this.resetGcTimer(() => {
for (const manager of this.managedCaches) {
manager.clear();
}
});
}
@bindThis
public async dispose(): Promise<void> {
this.stopGcTimer();
for (const manager of this.managedCaches) {
manager.dispose();
}
@ -109,4 +126,32 @@ export class CacheManagementService implements OnApplicationShutdown {
public async onApplicationShutdown(): Promise<void> {
await this.dispose();
}
@bindThis
private startGcTimer() {
// Only start it once, and don't *re* start since this gets called repeatedly.
this.gcTimer ??= this.timeService.startTimer(this.gc, GC_INTERVAL, { repeated: true });
}
@bindThis
private stopGcTimer() {
// Only stop it once, then clear the value so it can be restarted later.
if (this.gcTimer != null) {
this.timeService.stopTimer(this.gcTimer);
this.gcTimer = null;
}
}
@bindThis
private resetGcTimer(onBlank?: () => void): void {
this.stopGcTimer();
try {
if (onBlank) {
onBlank();
}
} finally {
this.startGcTimer();
}
}
}

View file

@ -5,10 +5,7 @@
import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js';
import { TimeService, NativeTimeService } from '@/core/TimeService.js';
// This matches the default DI implementation, but as a shared instance to avoid wasting memory.
const defaultTimeService: TimeService = new NativeTimeService();
import { TimeService } from '@/core/TimeService.js';
export interface RedisCacheServices extends MemoryCacheServices {
readonly redisClient: Redis.Redis
@ -194,6 +191,11 @@ export class RedisSingleCache<T> {
return value;
}
@bindThis
public gc(): void {
this.memoryCache.gc();
}
@bindThis
public async delete(): Promise<void> {
this.memoryCache.delete();
@ -242,22 +244,20 @@ export class RedisSingleCache<T> {
}
export interface MemoryCacheServices {
readonly timeService?: TimeService;
readonly timeService: TimeService;
}
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> {
private readonly cache = new Map<string, { date: number; value: T; }>();
private readonly gcIntervalHandle: symbol;
private readonly timeService: TimeService;
constructor(
private readonly lifetime: number,
services?: MemoryCacheServices,
services: MemoryCacheServices,
) {
this.timeService = services?.timeService ?? defaultTimeService;
this.gcIntervalHandle = this.timeService.startTimer(() => this.gc(), 1000 * 60 * 3, { repeated: true }); // 3m
this.timeService = services.timeService;
}
@bindThis
@ -375,7 +375,6 @@ export class MemoryKVCache<T> {
@bindThis
public dispose(): void {
this.clear();
this.timeService.stopTimer(this.gcIntervalHandle);
}
public get size() {
@ -394,9 +393,9 @@ export class MemorySingleCache<T> {
constructor(
private lifetime: number,
services?: MemoryCacheServices,
services: MemoryCacheServices,
) {
this.timeService = services?.timeService ?? defaultTimeService;
this.timeService = services.timeService;
}
@bindThis
@ -406,13 +405,24 @@ export class MemorySingleCache<T> {
}
@bindThis
public get(): T | undefined {
if (this.cachedAt == null) return undefined;
if ((this.timeService.now - this.cachedAt) > this.lifetime) {
this.value = undefined;
this.cachedAt = null;
return undefined;
public gc(): void {
// Check if we have a valid, non-expired value.
// This is a little convoluted but protects against edge cases and invalid states.
if (this.value !== undefined && this.cachedAt != null) {
const age = this.timeService.now - this.cachedAt;
if (Number.isSafeInteger(age) && age <= this.lifetime) {
return;
}
}
// If we get here, then it's expired or otherwise invalid.
// Whatever the case, we should clear everything back to zeros.
this.delete();
}
@bindThis
public get(): T | undefined {
this.gc();
return this.value;
}