|
|
@@ -1,10 +1,18 @@
|
|
|
// apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
|
+import { Cron, CronExpression } from '@nestjs/schedule';
|
|
|
import { Prisma as MysqlPrisma, CacheSyncAction } from '@prisma/mysql/client';
|
|
|
import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service';
|
|
|
import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
import { RedisService } from '@box/db/redis/redis.service';
|
|
|
-import { CacheKeys } from '@box/common/cache/cache-keys'; // 👈 new import
|
|
|
+import { CacheKeys } from '@box/common/cache/cache-keys';
|
|
|
+import { ADTYPE_POOLS } from '@box/common/ads/ad-pool-config';
|
|
|
+import type {
|
|
|
+ AdType,
|
|
|
+ AdPoolEntry,
|
|
|
+ AdScene,
|
|
|
+ AdSlot,
|
|
|
+} from '@box/common/ads/ad-types';
|
|
|
|
|
|
import {
|
|
|
CacheEntityType,
|
|
|
@@ -13,59 +21,26 @@ import {
|
|
|
CachePayload,
|
|
|
} from './cache-sync.types';
|
|
|
|
|
|
-interface AdPoolPlacement {
|
|
|
- scene: string;
|
|
|
- slot: string;
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Mapping from adType (AdsModule.adType) → one or more pool placements.
|
|
|
- * Each placement becomes a Redis key:
|
|
|
- * app:adpool:<scene>:<slot>:<adType>
|
|
|
- *
|
|
|
- * Adjust these mappings later if your design changes.
|
|
|
- */
|
|
|
-const ADTYPE_POOLS: Record<string, AdPoolPlacement[]> = {
|
|
|
- // 启动页广告
|
|
|
- STARTUP: [{ scene: 'home', slot: 'startup' }],
|
|
|
-
|
|
|
- // 首页轮播
|
|
|
- CAROUSEL: [{ scene: 'home', slot: 'carousel' }],
|
|
|
-
|
|
|
- // 弹窗类(详情页)
|
|
|
- POPUP_ICON: [{ scene: 'detail', slot: 'popup' }],
|
|
|
- POPUP_IMAGE: [{ scene: 'detail', slot: 'popup' }],
|
|
|
- POPUP_OFFICIAL: [{ scene: 'detail', slot: 'popup' }],
|
|
|
-
|
|
|
- // 瀑布流(首页)
|
|
|
- WATERFALL_ICON: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
- WATERFALL_TEXT: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
- WATERFALL_VIDEO: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
-
|
|
|
- // 悬浮(全局)
|
|
|
- FLOATING_BOTTOM: [{ scene: 'global', slot: 'floating_bottom' }],
|
|
|
- FLOATING_EDGE: [{ scene: 'global', slot: 'floating_edge' }],
|
|
|
-
|
|
|
- // 顶部 banner
|
|
|
- BANNER: [{ scene: 'home', slot: 'top' }],
|
|
|
-
|
|
|
- // 片头(前贴片)
|
|
|
- PREROLL: [{ scene: 'player', slot: 'preroll' }],
|
|
|
-
|
|
|
- // 暂停广告
|
|
|
- PAUSE: [{ scene: 'player', slot: 'pause' }],
|
|
|
-};
|
|
|
-
|
|
|
-interface AdPoolEntry {
|
|
|
- id: string;
|
|
|
- // You can later change this to real weight if you add a weight field.
|
|
|
- weight: number;
|
|
|
-}
|
|
|
+// Cache TTL (seconds)
|
|
|
+const CHANNEL_CACHE_TTL = 900; // 15 min
|
|
|
+const CATEGORY_CACHE_TTL = 900; // 15 min
|
|
|
+const AD_CACHE_TTL = 300; // 5 min (more dynamic)
|
|
|
+const AD_POOL_TTL = 300; // 5 min
|
|
|
|
|
|
@Injectable()
|
|
|
export class CacheSyncService {
|
|
|
private readonly logger = new Logger(CacheSyncService.name);
|
|
|
|
|
|
+ private readonly actionHandlers: Partial<
|
|
|
+ Record<CacheEntityType, (action: CacheSyncAction) => Promise<void>>
|
|
|
+ > = {
|
|
|
+ [CacheEntityType.CHANNEL]: this.handleChannelAction.bind(this),
|
|
|
+ [CacheEntityType.CATEGORY]: this.handleCategoryAction.bind(this),
|
|
|
+ [CacheEntityType.AD]: this.handleAdAction.bind(this),
|
|
|
+ [CacheEntityType.AD_POOL]: this.handleAdPoolAction.bind(this),
|
|
|
+ [CacheEntityType.VIDEO_LIST]: this.handleVideoListAction.bind(this),
|
|
|
+ };
|
|
|
+
|
|
|
constructor(
|
|
|
// MySQL: durable queue of actions
|
|
|
private readonly mysqlPrisma: MysqlPrismaService,
|
|
|
@@ -94,6 +69,27 @@ export class CacheSyncService {
|
|
|
const now = this.nowBigInt();
|
|
|
const nextAttemptAt = delayMs && delayMs > 0 ? now + BigInt(delayMs) : now;
|
|
|
|
|
|
+ // Basic dedupe: avoid piling up identical PENDING actions.
|
|
|
+ const existing = await this.mysqlPrisma.cacheSyncAction.findFirst({
|
|
|
+ where: {
|
|
|
+ entityType,
|
|
|
+ operation,
|
|
|
+ status: CacheStatus.PENDING,
|
|
|
+ entityId: entityId != null ? BigInt(entityId) : null,
|
|
|
+ // If payload carries a 'type' or 'adId', match them when present.
|
|
|
+ // Prisma can't query inside JSON easily across drivers; we keep it simple:
|
|
|
+ // only dedupe when entityId matches or when entityId is null (for pools),
|
|
|
+ // and rely on operation granularity.
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (existing) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Deduped CacheSyncAction: entityType=${entityType}, operation=${operation}, entityId=${entityId ?? 'null'}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
await this.mysqlPrisma.cacheSyncAction.create({
|
|
|
data: {
|
|
|
entityType,
|
|
|
@@ -162,10 +158,17 @@ export class CacheSyncService {
|
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
- // Minimal processing loop (single batch).
|
|
|
- // Later you can move this into a @Cron job.
|
|
|
+ // Queue processing: cron + manual
|
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
+ /**
|
|
|
+ * Cron job: process pending actions every 10 seconds.
|
|
|
+ */
|
|
|
+ @Cron(CronExpression.EVERY_10_SECONDS)
|
|
|
+ async processQueueCron() {
|
|
|
+ await this.processPendingOnce(50);
|
|
|
+ }
|
|
|
+
|
|
|
async processPendingOnce(limit = 20): Promise<void> {
|
|
|
const now = this.nowBigInt();
|
|
|
|
|
|
@@ -201,6 +204,8 @@ export class CacheSyncService {
|
|
|
const attempts = action.attempts + 1;
|
|
|
const maxAttempts = 5;
|
|
|
const backoffMs = Math.min(60000, 5000 * attempts); // up to 60s
|
|
|
+ const updateTime = this.nowBigInt();
|
|
|
+ const nextAttemptAt = updateTime + BigInt(backoffMs);
|
|
|
|
|
|
await this.mysqlPrisma.cacheSyncAction.update({
|
|
|
where: { id: action.id },
|
|
|
@@ -211,8 +216,8 @@ export class CacheSyncService {
|
|
|
: CacheStatus.PENDING,
|
|
|
attempts,
|
|
|
lastError: message,
|
|
|
- nextAttemptAt: this.nowBigInt() + BigInt(backoffMs),
|
|
|
- updatedAt: this.nowBigInt(),
|
|
|
+ nextAttemptAt,
|
|
|
+ updatedAt: updateTime,
|
|
|
},
|
|
|
});
|
|
|
}
|
|
|
@@ -223,34 +228,24 @@ export class CacheSyncService {
|
|
|
* Main dispatcher: decide what to do for each action.
|
|
|
*/
|
|
|
private async handleSingleAction(action: CacheSyncAction): Promise<void> {
|
|
|
- switch (action.entityType as CacheEntityType) {
|
|
|
- case CacheEntityType.CHANNEL:
|
|
|
- await this.handleChannelAction(action);
|
|
|
- break;
|
|
|
-
|
|
|
- case CacheEntityType.CATEGORY:
|
|
|
- await this.handleCategoryAction(action);
|
|
|
- break;
|
|
|
+ const handler = this.actionHandlers[action.entityType as CacheEntityType];
|
|
|
|
|
|
- case CacheEntityType.AD:
|
|
|
- await this.handleAdAction(action);
|
|
|
- break;
|
|
|
+ if (!handler) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Unknown entityType for CacheSyncAction id=${action.id}: ${action.entityType}`,
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ await handler(action);
|
|
|
+ }
|
|
|
|
|
|
- case CacheEntityType.AD_POOL:
|
|
|
- await this.handleAdPoolAction(action);
|
|
|
- break;
|
|
|
+ await this.markActionSuccess(action);
|
|
|
|
|
|
- case CacheEntityType.VIDEO_LIST:
|
|
|
- await this.handleVideoListAction(action);
|
|
|
- break;
|
|
|
-
|
|
|
- default:
|
|
|
- this.logger.warn(
|
|
|
- `Unknown entityType for CacheSyncAction id=${action.id}: ${action.entityType}`,
|
|
|
- );
|
|
|
- break;
|
|
|
- }
|
|
|
+ this.logger.debug(
|
|
|
+ `Processed CacheSyncAction id=${action.id}, entityType=${action.entityType}, operation=${action.operation}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
|
|
|
+ private async markActionSuccess(action: CacheSyncAction): Promise<void> {
|
|
|
await this.mysqlPrisma.cacheSyncAction.update({
|
|
|
where: { id: action.id },
|
|
|
data: {
|
|
|
@@ -260,10 +255,6 @@ export class CacheSyncService {
|
|
|
updatedAt: this.nowBigInt(),
|
|
|
},
|
|
|
});
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `Processed CacheSyncAction id=${action.id}, entityType=${action.entityType}, operation=${action.operation}`,
|
|
|
- );
|
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
@@ -275,6 +266,20 @@ export class CacheSyncService {
|
|
|
case CacheOperation.REFRESH_ALL:
|
|
|
await this.rebuildChannelsAll();
|
|
|
break;
|
|
|
+ case CacheOperation.INVALIDATE: {
|
|
|
+ const payload = action.payload as CachePayload | null;
|
|
|
+ const id = (payload as any)?.id as string | number | undefined;
|
|
|
+ if (id != null) {
|
|
|
+ await this.redis.del(CacheKeys.appChannelById(id));
|
|
|
+ this.logger.log(
|
|
|
+ `Invalidated channel by id key=${CacheKeys.appChannelById(id)}`,
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ await this.redis.del(CacheKeys.appChannelAll);
|
|
|
+ this.logger.log(`Invalidated ${CacheKeys.appChannelAll}`);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
default:
|
|
|
this.logger.warn(
|
|
|
`Unsupported CHANNEL operation for action id=${action.id}: ${action.operation}`,
|
|
|
@@ -292,10 +297,34 @@ export class CacheSyncService {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- await this.redis.setJson(CacheKeys.appChannelAll, channels);
|
|
|
+ const sanitized = channels.map((c) => ({
|
|
|
+ id: c.id,
|
|
|
+ name: c.name,
|
|
|
+ landingUrl: c.landingUrl,
|
|
|
+ videoCdn: c.videoCdn ?? null,
|
|
|
+ coverCdn: c.coverCdn ?? null,
|
|
|
+ clientName: c.clientName ?? null,
|
|
|
+ clientNotice: c.clientNotice ?? null,
|
|
|
+ remark: c.remark ?? null,
|
|
|
+ createAt:
|
|
|
+ typeof c.createAt === 'bigint'
|
|
|
+ ? Number(c.createAt)
|
|
|
+ : (c as any).createAt,
|
|
|
+ updateAt:
|
|
|
+ typeof c.updateAt === 'bigint'
|
|
|
+ ? Number(c.updateAt)
|
|
|
+ : (c as any).updateAt,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const start = Date.now();
|
|
|
+ await this.redis.setJson(
|
|
|
+ CacheKeys.appChannelAll,
|
|
|
+ sanitized,
|
|
|
+ CHANNEL_CACHE_TTL,
|
|
|
+ );
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Rebuilt ${CacheKeys.appChannelAll} with ${channels.length} item(s).`,
|
|
|
+ `Rebuilt ${CacheKeys.appChannelAll} with ${channels.length} item(s), ${Date.now() - start}ms`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -308,6 +337,20 @@ export class CacheSyncService {
|
|
|
case CacheOperation.REFRESH_ALL:
|
|
|
await this.rebuildCategoriesAll();
|
|
|
break;
|
|
|
+ case CacheOperation.INVALIDATE: {
|
|
|
+ const payload = action.payload as CachePayload | null;
|
|
|
+ const id = (payload as any)?.id as string | number | undefined;
|
|
|
+ if (id != null) {
|
|
|
+ await this.redis.del(CacheKeys.appCategoryById(id));
|
|
|
+ this.logger.log(
|
|
|
+ `Invalidated category by id key=${CacheKeys.appCategoryById(id)}`,
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ await this.redis.del(CacheKeys.appCategoryAll);
|
|
|
+ this.logger.log(`Invalidated ${CacheKeys.appCategoryAll}`);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
default:
|
|
|
this.logger.warn(
|
|
|
`Unsupported CATEGORY operation for action id=${action.id}: ${action.operation}`,
|
|
|
@@ -325,10 +368,32 @@ export class CacheSyncService {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- await this.redis.setJson(CacheKeys.appCategoryAll, categories);
|
|
|
+ const sanitized = categories.map((c) => ({
|
|
|
+ id: c.id,
|
|
|
+ name: c.name,
|
|
|
+ subtitle: c.subtitle ?? null,
|
|
|
+ channelId: c.channelId,
|
|
|
+ seq: c.seq,
|
|
|
+ status: c.status,
|
|
|
+ createAt:
|
|
|
+ typeof c.createAt === 'bigint'
|
|
|
+ ? Number(c.createAt)
|
|
|
+ : (c as any).createAt,
|
|
|
+ updateAt:
|
|
|
+ typeof c.updateAt === 'bigint'
|
|
|
+ ? Number(c.updateAt)
|
|
|
+ : (c as any).updateAt,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const start = Date.now();
|
|
|
+ await this.redis.setJson(
|
|
|
+ CacheKeys.appCategoryAll,
|
|
|
+ sanitized,
|
|
|
+ CATEGORY_CACHE_TTL,
|
|
|
+ );
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Rebuilt ${CacheKeys.appCategoryAll} with ${categories.length} item(s).`,
|
|
|
+ `Rebuilt ${CacheKeys.appCategoryAll} with ${categories.length} item(s), ${Date.now() - start}ms`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -349,7 +414,18 @@ export class CacheSyncService {
|
|
|
const adId = payload.adId;
|
|
|
const adType = payload.type;
|
|
|
|
|
|
- await this.rebuildSingleAdCache(adId, adType);
|
|
|
+ switch (action.operation as CacheOperation) {
|
|
|
+ case CacheOperation.INVALIDATE: {
|
|
|
+ const key = CacheKeys.appAdById(adId);
|
|
|
+ await this.redis.del(key);
|
|
|
+ this.logger.log(`Invalidated per-ad cache key=${key}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case CacheOperation.REFRESH:
|
|
|
+ default:
|
|
|
+ await this.rebuildSingleAdCache(adId, adType);
|
|
|
+ break;
|
|
|
+ }
|
|
|
|
|
|
this.logger.debug(
|
|
|
`handleAdAction: rebuilt per-ad cache for adId=${adId}, adType=${adType ?? 'N/A'}, action id=${action.id}`,
|
|
|
@@ -403,24 +479,15 @@ export class CacheSyncService {
|
|
|
// For now, let’s store the full ad + its module's adType.
|
|
|
const cachedAd = {
|
|
|
id: ad.id,
|
|
|
- channelId: ad.channelId,
|
|
|
- adsModuleId: ad.adsModuleId,
|
|
|
advertiser: ad.advertiser,
|
|
|
title: ad.title,
|
|
|
- adsContent: ad.adsContent,
|
|
|
- adsCoverImg: ad.adsCoverImg,
|
|
|
- adsUrl: ad.adsUrl,
|
|
|
- startDt: ad.startDt,
|
|
|
- expiryDt: ad.expiryDt,
|
|
|
- seq: ad.seq,
|
|
|
- status: ad.status,
|
|
|
- createAt: ad.createAt,
|
|
|
- updateAt: ad.updateAt,
|
|
|
- // include adType from AdsModule so app-api can know its type
|
|
|
+ adsContent: ad.adsContent ?? null,
|
|
|
+ adsCoverImg: ad.adsCoverImg ?? null,
|
|
|
+ adsUrl: ad.adsUrl ?? null,
|
|
|
adType: ad.adsModule?.adType ?? adType ?? null,
|
|
|
};
|
|
|
|
|
|
- await this.redis.setJson(cacheKey, cachedAd);
|
|
|
+ await this.redis.setJson(cacheKey, cachedAd, AD_CACHE_TTL);
|
|
|
|
|
|
this.logger.log(
|
|
|
`rebuildSingleAdCache: updated per-ad cache for adId=${adId}, key=${cacheKey}`,
|
|
|
@@ -429,7 +496,7 @@ export class CacheSyncService {
|
|
|
|
|
|
private async handleAdPoolAction(action: CacheSyncAction): Promise<void> {
|
|
|
const payload = action.payload as CachePayload | null;
|
|
|
- const adType = payload?.type;
|
|
|
+ const adType = payload?.type as AdType | undefined;
|
|
|
|
|
|
if (!adType) {
|
|
|
this.logger.warn(
|
|
|
@@ -438,7 +505,7 @@ export class CacheSyncService {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- const placements = ADTYPE_POOLS[adType];
|
|
|
+ const placements = ADTYPE_POOLS[adType] ?? [];
|
|
|
|
|
|
if (!placements || placements.length === 0) {
|
|
|
this.logger.warn(
|
|
|
@@ -447,64 +514,67 @@ export class CacheSyncService {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- this.logger.log(
|
|
|
- `handleAdPoolAction: rebuilding ${placements.length} pool(s) for adType=${adType}, action id=${action.id}`,
|
|
|
- );
|
|
|
-
|
|
|
- for (const placement of placements) {
|
|
|
- await this.rebuildAdPoolForPlacement(
|
|
|
- adType,
|
|
|
- placement.scene,
|
|
|
- placement.slot,
|
|
|
- );
|
|
|
+ switch (action.operation as CacheOperation) {
|
|
|
+ case CacheOperation.INVALIDATE: {
|
|
|
+ // remove all pools for this adType
|
|
|
+ const pattern = `app:adpool:*:*:${adType}`;
|
|
|
+ const deleted = await this.redis.deleteByPattern(pattern);
|
|
|
+ this.logger.log(
|
|
|
+ `Invalidated ${deleted} pool key(s) for adType=${adType} using pattern=${pattern}`,
|
|
|
+ );
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case CacheOperation.REBUILD_POOL:
|
|
|
+ default: {
|
|
|
+ this.logger.log(
|
|
|
+ `handleAdPoolAction: rebuilding ${placements.length} pool(s) for adType=${adType}, action id=${action.id}`,
|
|
|
+ );
|
|
|
+ for (const placement of placements) {
|
|
|
+ await this.rebuildAdPoolForPlacement(
|
|
|
+ adType,
|
|
|
+ placement.scene,
|
|
|
+ placement.slot,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private async rebuildAdPoolForPlacement(
|
|
|
- adType: string,
|
|
|
- scene: string,
|
|
|
- slot: string,
|
|
|
+ adType: AdType,
|
|
|
+ scene: AdScene,
|
|
|
+ slot: AdSlot,
|
|
|
): Promise<void> {
|
|
|
const now = this.nowBigInt();
|
|
|
|
|
|
- // NOTE:
|
|
|
- // - status: 1 means enabled
|
|
|
- // - startDt <= now
|
|
|
- // - expiryDt == 0 (no expiry) OR expiryDt >= now
|
|
|
- //
|
|
|
- // Adjust the expiry logic if your business rule is different.
|
|
|
const ads = await this.mongoPrisma.ads.findMany({
|
|
|
where: {
|
|
|
status: 1,
|
|
|
- startDt: {
|
|
|
- lte: now,
|
|
|
- },
|
|
|
- OR: [
|
|
|
- { expiryDt: BigInt(0) }, // "no expiry" if you use 0 as sentinel
|
|
|
- { expiryDt: { gte: now } },
|
|
|
- ],
|
|
|
+ startDt: { lte: now },
|
|
|
+ OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
|
|
|
adsModule: {
|
|
|
- adType, // join AdsModule on adType
|
|
|
+ is: { adType },
|
|
|
},
|
|
|
},
|
|
|
- orderBy: {
|
|
|
- seq: 'asc', // IMPORTANT: you said Ads list must be ordered by seq
|
|
|
- },
|
|
|
+ orderBy: { seq: 'asc' },
|
|
|
});
|
|
|
|
|
|
const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
|
|
|
id: ad.id,
|
|
|
- // For now use a flat weight.
|
|
|
- // Later you can map weight from seq or a dedicated weight field.
|
|
|
weight: 1,
|
|
|
}));
|
|
|
|
|
|
const key = CacheKeys.appAdPool(scene, slot, adType);
|
|
|
|
|
|
- await this.redis.setJson(key, poolEntries);
|
|
|
+ // Atomic swap to avoid partial-read windows
|
|
|
+ const start = Date.now();
|
|
|
+ await this.redis.atomicSwapJson([
|
|
|
+ { key, value: poolEntries, ttlSeconds: AD_POOL_TTL },
|
|
|
+ ]);
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Rebuilt ad pool ${key} with ${poolEntries.length} ad(s) for adType=${adType}, scene=${scene}, slot=${slot}.`,
|
|
|
+ `Rebuilt ad pool ${key} with ${poolEntries.length} ad(s) for adType=${adType}, scene=${scene}, slot=${slot}, ${Date.now() - start}ms`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -519,4 +589,35 @@ export class CacheSyncService {
|
|
|
`handleVideoListAction placeholder, operation=${action.operation}`,
|
|
|
);
|
|
|
}
|
|
|
+
|
|
|
+ // ─────────────────────────────────────────────
|
|
|
+ // Cache warming
|
|
|
+ // ─────────────────────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Pre-warm critical cache keys on startup or on-demand.
|
|
|
+ * Call this from app startup or via admin endpoint.
|
|
|
+ */
|
|
|
+ async warmCache(): Promise<void> {
|
|
|
+ this.logger.log('Cache warming started');
|
|
|
+ const start = Date.now();
|
|
|
+
|
|
|
+ await Promise.all([
|
|
|
+ this.rebuildChannelsAll(),
|
|
|
+ this.rebuildCategoriesAll(),
|
|
|
+ this.warmAdPools(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ this.logger.log(`Cache warming complete in ${Date.now() - start}ms`);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async warmAdPools(): Promise<void> {
|
|
|
+ const allAdTypes = Object.keys(ADTYPE_POOLS) as AdType[];
|
|
|
+ for (const adType of allAdTypes) {
|
|
|
+ const placements = ADTYPE_POOLS[adType];
|
|
|
+ for (const { scene, slot } of placements) {
|
|
|
+ await this.rebuildAdPoolForPlacement(adType, scene, slot);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|