redis pt 1
This commit is contained in:
12
src/common/cache/cache.module.ts
vendored
Normal file
12
src/common/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisModule } from '../../database/redis.module';
|
||||
import { CacheService } from './cache.service';
|
||||
import { RedisThrottlerStorage } from './redis-throttler.storage';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
providers: [CacheService, RedisThrottlerStorage],
|
||||
exports: [CacheService, RedisThrottlerStorage],
|
||||
})
|
||||
export class CacheModule {}
|
||||
185
src/common/cache/cache.service.ts
vendored
Normal file
185
src/common/cache/cache.service.ts
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS_CLIENT } from '../../database/redis.module';
|
||||
import { parsePositiveInteger } from '../config/env.utils';
|
||||
|
||||
const DEFAULT_CACHE_KEY_PREFIX = 'friendolls';
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 60;
|
||||
const DEFAULT_CACHE_MAX_TTL_SECONDS = 86_400;
|
||||
const DEFAULT_CACHE_METRICS_LOG_INTERVAL_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class CacheService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(CacheService.name);
|
||||
private readonly keyPrefix: string;
|
||||
private readonly defaultTtlSeconds: number;
|
||||
private readonly maxTtlSeconds: number;
|
||||
private readonly metricsLogIntervalMs: number;
|
||||
|
||||
private readonly metrics = {
|
||||
getHits: 0,
|
||||
getMisses: 0,
|
||||
sets: 0,
|
||||
deletes: 0,
|
||||
errors: 0,
|
||||
unavailable: 0,
|
||||
};
|
||||
|
||||
private metricsTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
|
||||
) {
|
||||
this.keyPrefix =
|
||||
this.configService.get<string>('CACHE_KEY_PREFIX') ||
|
||||
DEFAULT_CACHE_KEY_PREFIX;
|
||||
this.defaultTtlSeconds = parsePositiveInteger(
|
||||
this.configService.get<string>('CACHE_DEFAULT_TTL_SECONDS'),
|
||||
DEFAULT_CACHE_TTL_SECONDS,
|
||||
);
|
||||
this.maxTtlSeconds = parsePositiveInteger(
|
||||
this.configService.get<string>('CACHE_MAX_TTL_SECONDS'),
|
||||
DEFAULT_CACHE_MAX_TTL_SECONDS,
|
||||
);
|
||||
this.metricsLogIntervalMs = parsePositiveInteger(
|
||||
this.configService.get<string>('CACHE_METRICS_LOG_INTERVAL_MS'),
|
||||
DEFAULT_CACHE_METRICS_LOG_INTERVAL_MS,
|
||||
);
|
||||
|
||||
if (this.metricsLogIntervalMs > 0) {
|
||||
this.metricsTimer = setInterval(() => {
|
||||
this.flushMetrics();
|
||||
}, this.metricsLogIntervalMs);
|
||||
this.metricsTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.metricsTimer) {
|
||||
clearInterval(this.metricsTimer);
|
||||
this.metricsTimer = null;
|
||||
}
|
||||
|
||||
this.flushMetrics();
|
||||
}
|
||||
|
||||
getNamespacedKey(namespace: string, key: string): string {
|
||||
return `${this.keyPrefix}:${namespace}:${key}`;
|
||||
}
|
||||
|
||||
resolveTtlSeconds(ttlSeconds?: number): number {
|
||||
if (ttlSeconds === undefined) {
|
||||
return this.defaultTtlSeconds;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
|
||||
return this.defaultTtlSeconds;
|
||||
}
|
||||
|
||||
return Math.min(Math.floor(ttlSeconds), this.maxTtlSeconds);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
if (!this.redisClient) {
|
||||
this.metrics.unavailable += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.redisClient.get(key);
|
||||
if (value === null) {
|
||||
this.metrics.getMisses += 1;
|
||||
} else {
|
||||
this.metrics.getHits += 1;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.metrics.errors += 1;
|
||||
this.logger.warn(`Cache get failed for key ${key}`, error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||
if (!this.redisClient) {
|
||||
this.metrics.unavailable += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const ttl = this.resolveTtlSeconds(ttlSeconds);
|
||||
await this.redisClient.set(key, value, 'EX', ttl);
|
||||
this.metrics.sets += 1;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.metrics.errors += 1;
|
||||
this.logger.warn(`Cache set failed for key ${key}`, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<boolean> {
|
||||
if (!this.redisClient) {
|
||||
this.metrics.unavailable += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.redisClient.del(key);
|
||||
this.metrics.deletes += 1;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.metrics.errors += 1;
|
||||
this.logger.warn(`Cache delete failed for key ${key}`, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getRedisClient(): Redis | null {
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
recordError(operation: string, key: string, error: unknown): void {
|
||||
this.metrics.errors += 1;
|
||||
this.logger.warn(
|
||||
`Cache ${operation} failed for key ${key}`,
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
|
||||
recordUnavailable(): void {
|
||||
this.metrics.unavailable += 1;
|
||||
}
|
||||
|
||||
private flushMetrics(): void {
|
||||
const totalReads = this.metrics.getHits + this.metrics.getMisses;
|
||||
|
||||
if (
|
||||
totalReads === 0 &&
|
||||
this.metrics.sets === 0 &&
|
||||
this.metrics.deletes === 0 &&
|
||||
this.metrics.errors === 0 &&
|
||||
this.metrics.unavailable === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hitRate =
|
||||
totalReads === 0
|
||||
? '0.00'
|
||||
: ((this.metrics.getHits / totalReads) * 100).toFixed(2);
|
||||
|
||||
this.logger.log(
|
||||
`metrics reads=${totalReads} hits=${this.metrics.getHits} misses=${this.metrics.getMisses} hitRate=${hitRate}% sets=${this.metrics.sets} deletes=${this.metrics.deletes} errors=${this.metrics.errors} unavailable=${this.metrics.unavailable}`,
|
||||
);
|
||||
|
||||
this.metrics.getHits = 0;
|
||||
this.metrics.getMisses = 0;
|
||||
this.metrics.sets = 0;
|
||||
this.metrics.deletes = 0;
|
||||
this.metrics.errors = 0;
|
||||
this.metrics.unavailable = 0;
|
||||
}
|
||||
}
|
||||
3
src/common/cache/index.ts
vendored
Normal file
3
src/common/cache/index.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CacheModule } from './cache.module';
|
||||
export { CacheService } from './cache.service';
|
||||
export { RedisThrottlerStorage } from './redis-throttler.storage';
|
||||
239
src/common/cache/redis-throttler.storage.ts
vendored
Normal file
239
src/common/cache/redis-throttler.storage.ts
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
interface RedisThrottlerStorageRecord {
|
||||
totalHits: number;
|
||||
timeToExpire: number;
|
||||
isBlocked: boolean;
|
||||
timeToBlockExpire: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedisThrottlerStorage implements ThrottlerStorage {
|
||||
private static readonly IN_MEMORY_CLEANUP_INTERVAL = 500;
|
||||
private readonly inMemoryStorage = new Map<
|
||||
string,
|
||||
{ totalHits: number; expiresAt: number; blockExpiresAt: number }
|
||||
>();
|
||||
private inMemoryOperationCount = 0;
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
async increment(
|
||||
key: string,
|
||||
ttl: number,
|
||||
limit: number,
|
||||
blockDuration: number,
|
||||
throttlerName: string,
|
||||
): Promise<RedisThrottlerStorageRecord> {
|
||||
const safeLimit = Math.max(0, Math.floor(limit));
|
||||
const ttlMilliseconds = this.normalizeDurationMs(ttl);
|
||||
const blockDurationMilliseconds = this.normalizeDurationMs(blockDuration);
|
||||
const counterKey = this.cacheService.getNamespacedKey(
|
||||
'throttle:counter',
|
||||
`${throttlerName}:${key}`,
|
||||
);
|
||||
const blockKey = this.cacheService.getNamespacedKey(
|
||||
'throttle:block',
|
||||
`${throttlerName}:${key}`,
|
||||
);
|
||||
|
||||
const redisClient = this.cacheService.getRedisClient();
|
||||
if (!redisClient) {
|
||||
this.cacheService.recordUnavailable();
|
||||
return this.incrementInMemory(
|
||||
counterKey,
|
||||
ttlMilliseconds,
|
||||
safeLimit,
|
||||
blockDurationMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const initialized = await redisClient.set(
|
||||
counterKey,
|
||||
'1',
|
||||
'PX',
|
||||
ttlMilliseconds,
|
||||
'NX',
|
||||
);
|
||||
|
||||
if (initialized === 'OK') {
|
||||
const existingBlockTtlRemainingMs = await redisClient.pttl(blockKey);
|
||||
return {
|
||||
totalHits: 1,
|
||||
timeToExpire: Math.ceil(ttlMilliseconds / 1000),
|
||||
isBlocked: existingBlockTtlRemainingMs > 0,
|
||||
timeToBlockExpire:
|
||||
existingBlockTtlRemainingMs > 0
|
||||
? this.toSecondsFromPttl(
|
||||
existingBlockTtlRemainingMs,
|
||||
blockDurationMilliseconds,
|
||||
)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [
|
||||
existingBlockTtlRemainingMs,
|
||||
ttlRemainingBeforeHitMs,
|
||||
currentCount,
|
||||
] = await Promise.all([
|
||||
redisClient.pttl(blockKey),
|
||||
redisClient.pttl(counterKey),
|
||||
redisClient.get(counterKey),
|
||||
]);
|
||||
|
||||
if (existingBlockTtlRemainingMs > 0) {
|
||||
const totalHits = Number(currentCount ?? '0');
|
||||
return {
|
||||
totalHits: Number.isFinite(totalHits) ? totalHits : 0,
|
||||
timeToExpire: this.toSecondsFromPttl(
|
||||
ttlRemainingBeforeHitMs,
|
||||
ttlMilliseconds,
|
||||
),
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: this.toSecondsFromPttl(
|
||||
existingBlockTtlRemainingMs,
|
||||
blockDurationMilliseconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const count = await redisClient.incr(counterKey);
|
||||
if (count === 1) {
|
||||
await redisClient.pexpire(counterKey, ttlMilliseconds);
|
||||
}
|
||||
|
||||
const [ttlRemainingMs, blockTtlRemainingMs] = await Promise.all([
|
||||
redisClient.pttl(counterKey),
|
||||
redisClient.pttl(blockKey),
|
||||
]);
|
||||
|
||||
let isBlocked = blockTtlRemainingMs > 0;
|
||||
|
||||
if (!isBlocked && safeLimit > 0 && count > safeLimit) {
|
||||
await redisClient.set(blockKey, '1', 'PX', blockDurationMilliseconds);
|
||||
isBlocked = true;
|
||||
}
|
||||
|
||||
const refreshedBlockTtlRemainingMs = isBlocked
|
||||
? await redisClient.pttl(blockKey)
|
||||
: -1;
|
||||
|
||||
return {
|
||||
totalHits: count,
|
||||
timeToExpire: this.toSecondsFromPttl(ttlRemainingMs, ttlMilliseconds),
|
||||
isBlocked,
|
||||
timeToBlockExpire: isBlocked
|
||||
? this.toSecondsFromPttl(
|
||||
refreshedBlockTtlRemainingMs,
|
||||
blockDurationMilliseconds,
|
||||
)
|
||||
: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
this.cacheService.recordError('throttler increment', counterKey, error);
|
||||
|
||||
return this.incrementInMemory(
|
||||
counterKey,
|
||||
ttlMilliseconds,
|
||||
safeLimit,
|
||||
blockDurationMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private incrementInMemory(
|
||||
key: string,
|
||||
ttlMilliseconds: number,
|
||||
limit: number,
|
||||
blockDurationMilliseconds: number,
|
||||
): RedisThrottlerStorageRecord {
|
||||
const now = Date.now();
|
||||
this.inMemoryOperationCount += 1;
|
||||
if (
|
||||
this.inMemoryOperationCount %
|
||||
RedisThrottlerStorage.IN_MEMORY_CLEANUP_INTERVAL ===
|
||||
0
|
||||
) {
|
||||
this.cleanupExpiredInMemory(now);
|
||||
}
|
||||
|
||||
const existing = this.inMemoryStorage.get(key);
|
||||
|
||||
if (existing && existing.blockExpiresAt > now) {
|
||||
return {
|
||||
totalHits: existing.totalHits,
|
||||
timeToExpire: Math.max(1, Math.ceil((existing.expiresAt - now) / 1000)),
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: Math.max(
|
||||
1,
|
||||
Math.ceil((existing.blockExpiresAt - now) / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let totalHits = 1;
|
||||
let expiresAt = now + ttlMilliseconds;
|
||||
let blockExpiresAt = 0;
|
||||
|
||||
if (existing && existing.expiresAt > now) {
|
||||
totalHits = existing.totalHits + 1;
|
||||
expiresAt = existing.expiresAt;
|
||||
}
|
||||
|
||||
if (blockExpiresAt <= now) {
|
||||
blockExpiresAt = 0;
|
||||
}
|
||||
|
||||
let isBlocked = blockExpiresAt > now;
|
||||
if (!isBlocked && limit > 0 && totalHits > limit) {
|
||||
blockExpiresAt = now + blockDurationMilliseconds;
|
||||
isBlocked = true;
|
||||
}
|
||||
|
||||
this.inMemoryStorage.set(key, {
|
||||
totalHits,
|
||||
expiresAt,
|
||||
blockExpiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
totalHits,
|
||||
timeToExpire: Math.max(1, Math.ceil((expiresAt - now) / 1000)),
|
||||
isBlocked,
|
||||
timeToBlockExpire: isBlocked
|
||||
? Math.max(1, Math.ceil((blockExpiresAt - now) / 1000))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeDurationMs(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
private toSecondsFromPttl(pttlMs: number, fallbackMs: number): number {
|
||||
if (pttlMs > 0) {
|
||||
return Math.max(1, Math.ceil(pttlMs / 1000));
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(fallbackMs / 1000));
|
||||
}
|
||||
|
||||
private cleanupExpiredInMemory(now: number): void {
|
||||
for (const [mapKey, value] of this.inMemoryStorage) {
|
||||
const counterExpired = value.expiresAt <= now;
|
||||
const blockExpired = value.blockExpiresAt <= now;
|
||||
|
||||
if (counterExpired && blockExpired) {
|
||||
this.inMemoryStorage.delete(mapKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user