import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Redis } from 'ioredis'; import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; import { REDIS_CLIENT } from '@box/db/redis/redis.constants'; @Injectable() export class StatsAdsCacheService implements OnModuleInit { private readonly logger = new Logger(StatsAdsCacheService.name); private readonly cacheKey = 'box:stats:ads'; private readonly placeholderField = '__stats_ads_empty__'; constructor( private readonly configService: ConfigService, private readonly mongoPrisma: MongoPrismaService, @Inject(REDIS_CLIENT) private readonly redisClient: Redis, ) {} async onModuleInit(): Promise { try { await this.rebuildCache(); } catch (error) { this.logger.error( 'Failed to build box:stats:ads cache on startup', error instanceof Error ? error.stack : String(error), ); throw error; } } private getTtlSeconds(): number { const raw = this.configService.get('STATS_ADS_HASH_TTL_SECONDS'); if (!raw) return 0; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { return 0; } return Math.floor(parsed); } private async rebuildCache(): Promise { // This hash exists so the read-only box-stats-api pipeline can resolve adType/adId without any writes or rebuild triggers. const ads = await this.mongoPrisma.ads.findMany({ select: { id: true, adId: true, adType: true, }, }); const ttlSeconds = this.getTtlSeconds(); const payloads: Record = {}; for (const ad of ads) { payloads[ad.id] = JSON.stringify({ adType: ad.adType, adId: ad.adId, }); } const tempKey = `${this.cacheKey}:tmp:${Date.now()}:${Math.random() .toString(36) .slice(2)}`; // StatsEventsConsumer is intentionally read-only (see docs/stats-consumer-enrichment-flow.md) so we own every write to this key. const pipeline = this.redisClient.pipeline(); pipeline.del(tempKey); if (Object.keys(payloads).length > 0) { pipeline.hset(tempKey, payloads); } else { pipeline.hset(tempKey, this.placeholderField, '1'); } pipeline.rename(tempKey, this.cacheKey); pipeline.hdel(this.cacheKey, this.placeholderField); if (ttlSeconds > 0) { pipeline.expire(this.cacheKey, ttlSeconds); } const results = await pipeline.exec(); const firstError = results.find(([error]) => error instanceof Error) as | [Error, unknown] | undefined; if (firstError) { throw firstError[0]; } this.logger.log( `Rebuilt ${this.cacheKey} hash with ${ads.length} entries (ttl=${ttlSeconds})`, ); } }