stats-ads-cache.service.ts 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
  2. import { ConfigService } from '@nestjs/config';
  3. import { Redis } from 'ioredis';
  4. import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
  5. import { REDIS_CLIENT } from '@box/db/redis/redis.constants';
  6. @Injectable()
  7. export class StatsAdsCacheService implements OnModuleInit {
  8. private readonly logger = new Logger(StatsAdsCacheService.name);
  9. private readonly cacheKey = 'box:stats:ads';
  10. private readonly placeholderField = '__stats_ads_empty__';
  11. constructor(
  12. private readonly configService: ConfigService,
  13. private readonly mongoPrisma: MongoPrismaService,
  14. @Inject(REDIS_CLIENT)
  15. private readonly redisClient: Redis,
  16. ) {}
  17. async onModuleInit(): Promise<void> {
  18. try {
  19. await this.rebuildCache();
  20. } catch (error) {
  21. this.logger.error(
  22. 'Failed to build box:stats:ads cache on startup',
  23. error instanceof Error ? error.stack : String(error),
  24. );
  25. throw error;
  26. }
  27. }
  28. private getTtlSeconds(): number {
  29. const raw = this.configService.get<string>('STATS_ADS_HASH_TTL_SECONDS');
  30. if (!raw) return 0;
  31. const parsed = Number(raw);
  32. if (!Number.isFinite(parsed) || parsed <= 0) {
  33. return 0;
  34. }
  35. return Math.floor(parsed);
  36. }
  37. private async rebuildCache(): Promise<void> {
  38. // This hash exists so the read-only box-stats-api pipeline can resolve adType/adId without any writes or rebuild triggers.
  39. const ads = await this.mongoPrisma.ads.findMany({
  40. select: {
  41. id: true,
  42. adId: true,
  43. adType: true,
  44. },
  45. });
  46. const ttlSeconds = this.getTtlSeconds();
  47. const payloads: Record<string, string> = {};
  48. for (const ad of ads) {
  49. payloads[ad.id] = JSON.stringify({
  50. adType: ad.adType,
  51. adId: ad.adId,
  52. });
  53. }
  54. const tempKey = `${this.cacheKey}:tmp:${Date.now()}:${Math.random()
  55. .toString(36)
  56. .slice(2)}`;
  57. // StatsEventsConsumer is intentionally read-only (see docs/stats-consumer-enrichment-flow.md) so we own every write to this key.
  58. const pipeline = this.redisClient.pipeline();
  59. pipeline.del(tempKey);
  60. if (Object.keys(payloads).length > 0) {
  61. pipeline.hset(tempKey, payloads);
  62. } else {
  63. pipeline.hset(tempKey, this.placeholderField, '1');
  64. }
  65. pipeline.rename(tempKey, this.cacheKey);
  66. pipeline.hdel(this.cacheKey, this.placeholderField);
  67. if (ttlSeconds > 0) {
  68. pipeline.expire(this.cacheKey, ttlSeconds);
  69. }
  70. const results = await pipeline.exec();
  71. const firstError = results.find(([error]) => error instanceof Error) as
  72. | [Error, unknown]
  73. | undefined;
  74. if (firstError) {
  75. throw firstError[0];
  76. }
  77. this.logger.log(
  78. `Rebuilt ${this.cacheKey} hash with ${ads.length} entries (ttl=${ttlSeconds})`,
  79. );
  80. }
  81. }