| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596 |
- 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<void> {
- 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<string>('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<void> {
- // 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<string, string> = {};
- 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})`,
- );
- }
- }
|