|
|
@@ -35,27 +35,24 @@ export class StatsAggregationService {
|
|
|
private readonly redis: RedisService,
|
|
|
private readonly mainMongo: MongoPrismaService,
|
|
|
) {
|
|
|
- // Load CTR smoothing parameters (Laplace smoothing)
|
|
|
- this.ctrAlpha = parseFloat(
|
|
|
- this.configService.get<string>('STATS_CTR_ALPHA') ?? '1',
|
|
|
- );
|
|
|
- this.ctrBeta = parseFloat(
|
|
|
- this.configService.get<string>('STATS_CTR_BETA') ?? '2',
|
|
|
- );
|
|
|
+ this.ctrAlpha = this.readFiniteNumber('STATS_CTR_ALPHA', 1);
|
|
|
+ this.ctrBeta = this.readFiniteNumber('STATS_CTR_BETA', 2);
|
|
|
|
|
|
- // Load scoring weights (must sum to meaningful proportion, normalize if needed)
|
|
|
- this.weightPopularity = parseFloat(
|
|
|
- this.configService.get<string>('STATS_WEIGHT_POPULARITY') ?? '0.5',
|
|
|
- );
|
|
|
- this.weightCtr = parseFloat(
|
|
|
- this.configService.get<string>('STATS_WEIGHT_CTR') ?? '0.3',
|
|
|
- );
|
|
|
- this.weightRecency = parseFloat(
|
|
|
- this.configService.get<string>('STATS_WEIGHT_RECENCY') ?? '0.2',
|
|
|
- );
|
|
|
+ const wPop = this.readFiniteNumber('STATS_WEIGHT_POPULARITY', 0.5);
|
|
|
+ const wCtr = this.readFiniteNumber('STATS_WEIGHT_CTR', 0.3);
|
|
|
+ const wRec = this.readFiniteNumber('STATS_WEIGHT_RECENCY', 0.2);
|
|
|
+
|
|
|
+ const normalized = this.normalizeWeights(wPop, wCtr, wRec);
|
|
|
+
|
|
|
+ this.weightPopularity = normalized.popularity;
|
|
|
+ this.weightCtr = normalized.ctr;
|
|
|
+ this.weightRecency = normalized.recency;
|
|
|
|
|
|
this.logger.log(
|
|
|
- `📊 Scoring config: CTR(α=${this.ctrAlpha}, β=${this.ctrBeta}), Weights(pop=${this.weightPopularity}, ctr=${this.weightCtr}, rec=${this.weightRecency})`,
|
|
|
+ `📊 Scoring config loaded: CTR(α=${this.ctrAlpha}, β=${this.ctrBeta}), ` +
|
|
|
+ `Weights(pop=${this.weightPopularity.toFixed(4)}, ctr=${this.weightCtr.toFixed(
|
|
|
+ 4,
|
|
|
+ )}, rec=${this.weightRecency.toFixed(4)})`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -66,57 +63,84 @@ export class StatsAggregationService {
|
|
|
async aggregateAdsStats(
|
|
|
options: AggregationOptions = {},
|
|
|
): Promise<AggregationResult> {
|
|
|
- const client = this.prisma as any;
|
|
|
+ const runId = this.newRunId('ads');
|
|
|
+ const startedAtMs = Date.now();
|
|
|
+
|
|
|
const { windowDays } = options;
|
|
|
+ const cutoffTime = this.computeCutoffTime(windowDays);
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Starting ads stats aggregation (window: ${windowDays ?? 'all time'})`,
|
|
|
+ `[${runId}] Starting ads stats aggregation (window=${windowDays ?? 'all time'}, cutoff=${cutoffTime.toString()})`,
|
|
|
);
|
|
|
|
|
|
- const cutoffTime =
|
|
|
- windowDays !== undefined
|
|
|
- ? nowEpochMsBigInt() - BigInt(windowDays * 24 * 60 * 60 * 1000)
|
|
|
- : BigInt(0);
|
|
|
-
|
|
|
- // Get all unique adIds from click and impression events
|
|
|
- const adIds = await this.getUniqueAdIds(cutoffTime);
|
|
|
+ let adIds: string[] = [];
|
|
|
+ try {
|
|
|
+ adIds = await this.getUniqueAdIds(cutoffTime);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `[${runId}] Failed to load unique adIds`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return { totalProcessed: 0, successCount: 0, errorCount: 1 };
|
|
|
+ }
|
|
|
|
|
|
- this.logger.log(`Found ${adIds.length} unique ads to aggregate`);
|
|
|
+ this.logger.log(`[${runId}] Found ${adIds.length} unique ads to aggregate`);
|
|
|
|
|
|
let successCount = 0;
|
|
|
let errorCount = 0;
|
|
|
- const scores: number[] = [];
|
|
|
+
|
|
|
+ // score stats without storing whole array
|
|
|
+ let scoreMin = Number.POSITIVE_INFINITY;
|
|
|
+ let scoreMax = Number.NEGATIVE_INFINITY;
|
|
|
+ let scoreSum = 0;
|
|
|
+ let scoreCount = 0;
|
|
|
let zeroScoreCount = 0;
|
|
|
|
|
|
- for (const adId of adIds) {
|
|
|
+ for (let i = 0; i < adIds.length; i++) {
|
|
|
+ const adId = adIds[i];
|
|
|
+
|
|
|
try {
|
|
|
- const score = await this.aggregateSingleAd(adId, cutoffTime);
|
|
|
- scores.push(score);
|
|
|
+ const score = await this.aggregateSingleAd(adId, cutoffTime, runId);
|
|
|
+
|
|
|
+ scoreCount++;
|
|
|
+ scoreSum += score;
|
|
|
+ if (score < scoreMin) scoreMin = score;
|
|
|
+ if (score > scoreMax) scoreMax = score;
|
|
|
if (score === 0) zeroScoreCount++;
|
|
|
+
|
|
|
successCount++;
|
|
|
- } catch (error: any) {
|
|
|
+ } catch (err) {
|
|
|
errorCount++;
|
|
|
this.logger.error(
|
|
|
- `Failed to aggregate stats for adId=${adId}: ${error?.message ?? error}`,
|
|
|
- error?.stack,
|
|
|
+ `[${runId}] Failed to aggregate adId=${adId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // light progress log every 500 items (helps “silent startup” / long runs)
|
|
|
+ if ((i + 1) % 500 === 0) {
|
|
|
+ this.logger.log(
|
|
|
+ `[${runId}] Progress ${i + 1}/${adIds.length} (ok=${successCount}, err=${errorCount})`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Calculate score statistics
|
|
|
- const scoreStats =
|
|
|
- scores.length > 0
|
|
|
+ const durationMs = Date.now() - startedAtMs;
|
|
|
+
|
|
|
+ const stats =
|
|
|
+ scoreCount > 0
|
|
|
? {
|
|
|
- min: Math.min(...scores),
|
|
|
- max: Math.max(...scores),
|
|
|
- avg: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
|
|
+ min: Number.isFinite(scoreMin) ? scoreMin : 0,
|
|
|
+ max: Number.isFinite(scoreMax) ? scoreMax : 0,
|
|
|
+ avg: scoreSum / scoreCount,
|
|
|
}
|
|
|
: { min: 0, max: 0, avg: 0 };
|
|
|
|
|
|
this.logger.log(
|
|
|
- `📊 Ads aggregation complete: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
- `scores(min=${scoreStats.min.toFixed(4)}, max=${scoreStats.max.toFixed(4)}, avg=${scoreStats.avg.toFixed(4)}), ` +
|
|
|
- `zeroScores=${zeroScoreCount}`,
|
|
|
+ `[${runId}] ✅ Ads aggregation complete in ${durationMs}ms: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
+ `scores(min=${stats.min.toFixed(4)}, max=${stats.max.toFixed(4)}, avg=${stats.avg.toFixed(
|
|
|
+ 4,
|
|
|
+ )}), zeroScores=${zeroScoreCount}`,
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
@@ -132,56 +156,88 @@ export class StatsAggregationService {
|
|
|
async aggregateVideoStats(
|
|
|
options: AggregationOptions = {},
|
|
|
): Promise<AggregationResult> {
|
|
|
- const client = this.prisma as any;
|
|
|
+ const runId = this.newRunId('video');
|
|
|
+ const startedAtMs = Date.now();
|
|
|
+
|
|
|
const { windowDays } = options;
|
|
|
+ const cutoffTime = this.computeCutoffTime(windowDays);
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Starting video stats aggregation (window: ${windowDays ?? 'all time'})`,
|
|
|
+ `[${runId}] Starting video stats aggregation (window=${windowDays ?? 'all time'}, cutoff=${cutoffTime.toString()})`,
|
|
|
);
|
|
|
|
|
|
- const cutoffTime =
|
|
|
- windowDays !== undefined
|
|
|
- ? nowEpochMsBigInt() - BigInt(windowDays * 24 * 60 * 60 * 1000)
|
|
|
- : BigInt(0);
|
|
|
-
|
|
|
- const videoIds = await this.getUniqueVideoIds(cutoffTime);
|
|
|
+ let videoIds: string[] = [];
|
|
|
+ try {
|
|
|
+ videoIds = await this.getUniqueVideoIds(cutoffTime);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `[${runId}] Failed to load unique videoIds`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return { totalProcessed: 0, successCount: 0, errorCount: 1 };
|
|
|
+ }
|
|
|
|
|
|
- this.logger.log(`Found ${videoIds.length} unique videos to aggregate`);
|
|
|
+ this.logger.log(
|
|
|
+ `[${runId}] Found ${videoIds.length} unique videos to aggregate`,
|
|
|
+ );
|
|
|
|
|
|
let successCount = 0;
|
|
|
let errorCount = 0;
|
|
|
- const scores: number[] = [];
|
|
|
+
|
|
|
+ let scoreMin = Number.POSITIVE_INFINITY;
|
|
|
+ let scoreMax = Number.NEGATIVE_INFINITY;
|
|
|
+ let scoreSum = 0;
|
|
|
+ let scoreCount = 0;
|
|
|
let zeroScoreCount = 0;
|
|
|
|
|
|
- for (const videoId of videoIds) {
|
|
|
+ for (let i = 0; i < videoIds.length; i++) {
|
|
|
+ const videoId = videoIds[i];
|
|
|
+
|
|
|
try {
|
|
|
- const score = await this.aggregateSingleVideo(videoId, cutoffTime);
|
|
|
- scores.push(score);
|
|
|
+ const score = await this.aggregateSingleVideo(
|
|
|
+ videoId,
|
|
|
+ cutoffTime,
|
|
|
+ runId,
|
|
|
+ );
|
|
|
+
|
|
|
+ scoreCount++;
|
|
|
+ scoreSum += score;
|
|
|
+ if (score < scoreMin) scoreMin = score;
|
|
|
+ if (score > scoreMax) scoreMax = score;
|
|
|
if (score === 0) zeroScoreCount++;
|
|
|
+
|
|
|
successCount++;
|
|
|
- } catch (error: any) {
|
|
|
+ } catch (err) {
|
|
|
errorCount++;
|
|
|
this.logger.error(
|
|
|
- `Failed to aggregate stats for videoId=${videoId}: ${error?.message ?? error}`,
|
|
|
- error?.stack,
|
|
|
+ `[${runId}] Failed to aggregate videoId=${videoId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((i + 1) % 500 === 0) {
|
|
|
+ this.logger.log(
|
|
|
+ `[${runId}] Progress ${i + 1}/${videoIds.length} (ok=${successCount}, err=${errorCount})`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Calculate score statistics
|
|
|
- const scoreStats =
|
|
|
- scores.length > 0
|
|
|
+ const durationMs = Date.now() - startedAtMs;
|
|
|
+
|
|
|
+ const stats =
|
|
|
+ scoreCount > 0
|
|
|
? {
|
|
|
- min: Math.min(...scores),
|
|
|
- max: Math.max(...scores),
|
|
|
- avg: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
|
|
+ min: Number.isFinite(scoreMin) ? scoreMin : 0,
|
|
|
+ max: Number.isFinite(scoreMax) ? scoreMax : 0,
|
|
|
+ avg: scoreSum / scoreCount,
|
|
|
}
|
|
|
: { min: 0, max: 0, avg: 0 };
|
|
|
|
|
|
this.logger.log(
|
|
|
- `📊 Video aggregation complete: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
- `scores(min=${scoreStats.min.toFixed(4)}, max=${scoreStats.max.toFixed(4)}, avg=${scoreStats.avg.toFixed(4)}), ` +
|
|
|
- `zeroScores=${zeroScoreCount}`,
|
|
|
+ `[${runId}] ✅ Video aggregation complete in ${durationMs}ms: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
+ `scores(min=${stats.min.toFixed(4)}, max=${stats.max.toFixed(4)}, avg=${stats.avg.toFixed(
|
|
|
+ 4,
|
|
|
+ )}), zeroScores=${zeroScoreCount}`,
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
@@ -207,8 +263,10 @@ export class StatsAggregationService {
|
|
|
});
|
|
|
|
|
|
const allAdIds = new Set<string>();
|
|
|
- clickAdIds.forEach((item: any) => allAdIds.add(item.adId));
|
|
|
- impressionAdIds.forEach((item: any) => allAdIds.add(item.adId));
|
|
|
+ clickAdIds.forEach((item: any) => item?.adId && allAdIds.add(item.adId));
|
|
|
+ impressionAdIds.forEach(
|
|
|
+ (item: any) => item?.adId && allAdIds.add(item.adId),
|
|
|
+ );
|
|
|
|
|
|
return Array.from(allAdIds);
|
|
|
}
|
|
|
@@ -222,12 +280,13 @@ export class StatsAggregationService {
|
|
|
distinct: ['videoId'],
|
|
|
});
|
|
|
|
|
|
- return videoIds.map((item: any) => item.videoId);
|
|
|
+ return videoIds.map((item: any) => item.videoId).filter(Boolean);
|
|
|
}
|
|
|
|
|
|
private async aggregateSingleAd(
|
|
|
adId: string,
|
|
|
cutoffTime: bigint,
|
|
|
+ runId: string,
|
|
|
): Promise<number> {
|
|
|
const client = this.prisma as any;
|
|
|
|
|
|
@@ -260,20 +319,18 @@ export class StatsAggregationService {
|
|
|
orderBy: { impressionAt: 'asc' },
|
|
|
});
|
|
|
|
|
|
- const allTimes = [
|
|
|
- ...clickTimes.map((t: any) => t.clickedAt),
|
|
|
- ...impressionTimes.map((t: any) => t.impressionAt),
|
|
|
+ const allTimes: bigint[] = [
|
|
|
+ ...clickTimes.map((t: any) => t.clickedAt).filter((v: any) => v != null),
|
|
|
+ ...impressionTimes
|
|
|
+ .map((t: any) => t.impressionAt)
|
|
|
+ .filter((v: any) => v != null),
|
|
|
];
|
|
|
|
|
|
- if (allTimes.length === 0) {
|
|
|
- // No events, skip
|
|
|
- return 0;
|
|
|
- }
|
|
|
+ if (allTimes.length === 0) return 0;
|
|
|
|
|
|
const firstSeenAt = allTimes.reduce((min, val) => (val < min ? val : min));
|
|
|
const lastSeenAt = allTimes.reduce((max, val) => (val > max ? val : max));
|
|
|
|
|
|
- // Compute metrics using updated formulas
|
|
|
const computedPopularity = this.computePopularity(impressions);
|
|
|
const computedCtr = this.computeCtr(clicks, impressions);
|
|
|
const computedRecency = this.computeRecency(firstSeenAt);
|
|
|
@@ -285,7 +342,6 @@ export class StatsAggregationService {
|
|
|
|
|
|
const now = nowEpochMsBigInt();
|
|
|
|
|
|
- // Upsert into AdsGlobalStats
|
|
|
await client.adsGlobalStats.upsert({
|
|
|
where: { adId },
|
|
|
update: {
|
|
|
@@ -314,11 +370,12 @@ export class StatsAggregationService {
|
|
|
});
|
|
|
|
|
|
this.logger.debug(
|
|
|
- `Aggregated adId=${adId}: impressions=${impressions}, clicks=${clicks}, CTR=${computedCtr.toFixed(4)}, score=${computedScore.toFixed(4)}`,
|
|
|
+ `[${runId}] adId=${adId} imp=${impressions} clk=${clicks} ctr=${computedCtr.toFixed(
|
|
|
+ 4,
|
|
|
+ )} score=${computedScore.toFixed(4)}`,
|
|
|
);
|
|
|
|
|
|
- // Sync score to Redis sorted sets
|
|
|
- await this.syncAdScoreToRedis(adId, computedScore);
|
|
|
+ await this.syncAdScoreToRedis(adId, computedScore, runId);
|
|
|
|
|
|
return computedScore;
|
|
|
}
|
|
|
@@ -326,10 +383,11 @@ export class StatsAggregationService {
|
|
|
private async aggregateSingleVideo(
|
|
|
videoId: string,
|
|
|
cutoffTime: bigint,
|
|
|
+ runId: string,
|
|
|
): Promise<number> {
|
|
|
const client = this.prisma as any;
|
|
|
|
|
|
- // Count clicks (we don't have impressions for videos yet, so just clicks)
|
|
|
+ // Count clicks (no impressions for videos yet)
|
|
|
const clicks = await client.videoClickEvents.count({
|
|
|
where: {
|
|
|
videoId,
|
|
|
@@ -337,9 +395,7 @@ export class StatsAggregationService {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // For videos, we can treat clicks as a proxy for both impressions and clicks
|
|
|
- // Or set impressions = 0 if no separate impression tracking
|
|
|
- const impressions = clicks; // Adjust as needed
|
|
|
+ const impressions = clicks;
|
|
|
|
|
|
const clickTimes = await client.videoClickEvents.findMany({
|
|
|
where: { videoId, clickedAt: { gte: cutoffTime } },
|
|
|
@@ -347,11 +403,14 @@ export class StatsAggregationService {
|
|
|
orderBy: { clickedAt: 'asc' },
|
|
|
});
|
|
|
|
|
|
- if (clickTimes.length === 0) {
|
|
|
- return 0;
|
|
|
- }
|
|
|
+ if (clickTimes.length === 0) return 0;
|
|
|
+
|
|
|
+ const allTimes: bigint[] = clickTimes
|
|
|
+ .map((t: any) => t.clickedAt)
|
|
|
+ .filter((v: any) => v != null);
|
|
|
+
|
|
|
+ if (allTimes.length === 0) return 0;
|
|
|
|
|
|
- const allTimes = clickTimes.map((t: any) => t.clickedAt);
|
|
|
const firstSeenAt = allTimes.reduce((min, val) => (val < min ? val : min));
|
|
|
const lastSeenAt = allTimes.reduce((max, val) => (val > max ? val : max));
|
|
|
|
|
|
@@ -394,11 +453,12 @@ export class StatsAggregationService {
|
|
|
});
|
|
|
|
|
|
this.logger.debug(
|
|
|
- `Aggregated videoId=${videoId}: impressions=${impressions}, clicks=${clicks}, CTR=${computedCtr.toFixed(4)}, score=${computedScore.toFixed(4)}`,
|
|
|
+ `[${runId}] videoId=${videoId} imp=${impressions} clk=${clicks} ctr=${computedCtr.toFixed(
|
|
|
+ 4,
|
|
|
+ )} score=${computedScore.toFixed(4)}`,
|
|
|
);
|
|
|
|
|
|
- // Sync score to Redis sorted sets
|
|
|
- await this.syncVideoScoreToRedis(videoId, computedScore);
|
|
|
+ await this.syncVideoScoreToRedis(videoId, computedScore, runId);
|
|
|
|
|
|
return computedScore;
|
|
|
}
|
|
|
@@ -408,33 +468,44 @@ export class StatsAggregationService {
|
|
|
* Formula: log(1 + impressions)
|
|
|
*/
|
|
|
private computePopularity(impressions: number): number {
|
|
|
- return Math.log(1 + impressions);
|
|
|
+ return Math.log(1 + Math.max(0, impressions));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Smoothed CTR to avoid division by zero and reduce variance on low-volume items.
|
|
|
+ * Smoothed CTR
|
|
|
* Formula: (clicks + alpha) / (impressions + beta)
|
|
|
- * Default: alpha=1, beta=2 (Laplace smoothing)
|
|
|
*/
|
|
|
private computeCtr(clicks: number, impressions: number): number {
|
|
|
- return (clicks + this.ctrAlpha) / (impressions + this.ctrBeta);
|
|
|
+ const c = Math.max(0, clicks);
|
|
|
+ const i = Math.max(0, impressions);
|
|
|
+ const denom = i + this.ctrBeta;
|
|
|
+
|
|
|
+ // denom should never be 0 because beta defaults to 2, but guard anyway
|
|
|
+ if (!Number.isFinite(denom) || denom <= 0) return 0;
|
|
|
+
|
|
|
+ return (c + this.ctrAlpha) / denom;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Recency score based on age since first appearance.
|
|
|
- * More recent = higher score.
|
|
|
* Formula: 1 / (1 + ageDays)
|
|
|
*/
|
|
|
private computeRecency(firstSeenAt: bigint): number {
|
|
|
const now = nowEpochMsBigInt();
|
|
|
- const ageMs = Number(now - firstSeenAt);
|
|
|
+
|
|
|
+ // If clocks/time data is weird, avoid negative ages blowing up score
|
|
|
+ let ageMsBig = now - firstSeenAt;
|
|
|
+ if (ageMsBig < BigInt(0)) ageMsBig = BigInt(0);
|
|
|
+
|
|
|
+ // Convert safely to number for division; clamp if extremely large
|
|
|
+ const ageMs = this.bigIntToSafeNumber(ageMsBig);
|
|
|
+
|
|
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
|
return 1 / (1 + ageDays);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Composite score combining popularity, CTR, and recency.
|
|
|
- * Formula: w1 * popularity + w2 * ctr + w3 * recency
|
|
|
+ * Composite score: w1 * popularity + w2 * ctr + w3 * recency
|
|
|
*/
|
|
|
private computeScore(
|
|
|
popularity: number,
|
|
|
@@ -448,72 +519,152 @@ export class StatsAggregationService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Sync ad score to Redis sorted sets:
|
|
|
- * - ads:global:score (all ads)
|
|
|
- * - ads:tag:<tagId>:score (per tag, if ads have tags in the future)
|
|
|
- */
|
|
|
- private async syncAdScoreToRedis(adId: string, score: number): Promise<void> {
|
|
|
+ private async syncAdScoreToRedis(
|
|
|
+ adId: string,
|
|
|
+ score: number,
|
|
|
+ runId: string,
|
|
|
+ ): Promise<void> {
|
|
|
try {
|
|
|
- // Global sorted set for all ads
|
|
|
const client = (this.redis as any).ensureClient();
|
|
|
await client.zadd('ads:global:score', score, adId);
|
|
|
|
|
|
- // TODO: If ads have tags in the future, fetch tagIds and add to tag-based sets
|
|
|
- // For now, ads don't have tagIds in the schema, so skip tag-based syncing
|
|
|
-
|
|
|
this.logger.debug(
|
|
|
- `Synced adId=${adId} score=${score.toFixed(4)} to Redis`,
|
|
|
+ `[${runId}] Redis sync adId=${adId} score=${score.toFixed(4)} ok`,
|
|
|
);
|
|
|
- } catch (error: any) {
|
|
|
+ } catch (err) {
|
|
|
this.logger.error(
|
|
|
- `Failed to sync ad score to Redis for adId=${adId}: ${error?.message ?? error}`,
|
|
|
- error?.stack,
|
|
|
+ `[${runId}] Redis sync FAILED for adId=${adId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
- // Don't fail the whole aggregation job
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Sync video score to Redis sorted sets:
|
|
|
- * - video:global:score (all videos)
|
|
|
- * - video:tag:<tagId>:score (per tag)
|
|
|
- */
|
|
|
private async syncVideoScoreToRedis(
|
|
|
videoId: string,
|
|
|
score: number,
|
|
|
+ runId: string,
|
|
|
): Promise<void> {
|
|
|
try {
|
|
|
const client = (this.redis as any).ensureClient();
|
|
|
|
|
|
- // Global sorted set for all videos
|
|
|
await client.zadd('video:global:score', score, videoId);
|
|
|
|
|
|
- // Fetch tagIds from main Mongo DB
|
|
|
const video = await this.mainMongo.videoMedia.findUnique({
|
|
|
where: { id: videoId },
|
|
|
select: { tagIds: true },
|
|
|
});
|
|
|
|
|
|
- if (video && video.tagIds && video.tagIds.length > 0) {
|
|
|
- // Add to tag-based sorted sets
|
|
|
- for (const tagId of video.tagIds) {
|
|
|
+ const tagIds = video?.tagIds ?? [];
|
|
|
+ if (Array.isArray(tagIds) && tagIds.length > 0) {
|
|
|
+ for (const tagId of tagIds) {
|
|
|
await client.zadd(`video:tag:${tagId}:score`, score, videoId);
|
|
|
}
|
|
|
+
|
|
|
this.logger.debug(
|
|
|
- `Synced videoId=${videoId} score=${score.toFixed(4)} to Redis (${video.tagIds.length} tags)`,
|
|
|
+ `[${runId}] Redis sync videoId=${videoId} score=${score.toFixed(
|
|
|
+ 4,
|
|
|
+ )} ok (tags=${tagIds.length})`,
|
|
|
);
|
|
|
} else {
|
|
|
this.logger.debug(
|
|
|
- `Synced videoId=${videoId} score=${score.toFixed(4)} to Redis (no tags)`,
|
|
|
+ `[${runId}] Redis sync videoId=${videoId} score=${score.toFixed(
|
|
|
+ 4,
|
|
|
+ )} ok (no tags)`,
|
|
|
);
|
|
|
}
|
|
|
- } catch (error: any) {
|
|
|
+ } catch (err) {
|
|
|
this.logger.error(
|
|
|
- `Failed to sync video score to Redis for videoId=${videoId}: ${error?.message ?? error}`,
|
|
|
- error?.stack,
|
|
|
+ `[${runId}] Redis sync FAILED for videoId=${videoId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
- // Don't fail the whole aggregation job
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // -------------------------
|
|
|
+ // helpers (config + safety)
|
|
|
+ // -------------------------
|
|
|
+
|
|
|
+ private computeCutoffTime(windowDays?: number): bigint {
|
|
|
+ if (windowDays === undefined) return BigInt(0);
|
|
|
+
|
|
|
+ const days = Number(windowDays);
|
|
|
+ if (!Number.isFinite(days) || days <= 0) return BigInt(0);
|
|
|
+
|
|
|
+ const ms = BigInt(Math.floor(days * 24 * 60 * 60 * 1000));
|
|
|
+ const now = nowEpochMsBigInt();
|
|
|
+ const cutoff = now - ms;
|
|
|
+
|
|
|
+ return cutoff > BigInt(0) ? cutoff : BigInt(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private readFiniteNumber(key: string, fallback: number): number {
|
|
|
+ const raw = this.configService.get<string>(key);
|
|
|
+
|
|
|
+ if (raw == null || raw.trim() === '') return fallback;
|
|
|
+
|
|
|
+ const n = Number.parseFloat(raw.trim());
|
|
|
+ if (!Number.isFinite(n)) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Config ${key}="${raw}" is not a finite number; using ${fallback}`,
|
|
|
+ );
|
|
|
+ return fallback;
|
|
|
+ }
|
|
|
+
|
|
|
+ return n;
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeWeights(
|
|
|
+ popularity: number,
|
|
|
+ ctr: number,
|
|
|
+ recency: number,
|
|
|
+ ): {
|
|
|
+ popularity: number;
|
|
|
+ ctr: number;
|
|
|
+ recency: number;
|
|
|
+ } {
|
|
|
+ const wp = Number.isFinite(popularity) && popularity >= 0 ? popularity : 0;
|
|
|
+ const wc = Number.isFinite(ctr) && ctr >= 0 ? ctr : 0;
|
|
|
+ const wr = Number.isFinite(recency) && recency >= 0 ? recency : 0;
|
|
|
+
|
|
|
+ const sum = wp + wc + wr;
|
|
|
+
|
|
|
+ if (sum <= 0) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Invalid weights (sum<=0). Falling back to defaults (0.5,0.3,0.2)`,
|
|
|
+ );
|
|
|
+ return { popularity: 0.5, ctr: 0.3, recency: 0.2 };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Normalize to sum=1 so config mistakes don’t blow up scoring scale
|
|
|
+ const np = wp / sum;
|
|
|
+ const nc = wc / sum;
|
|
|
+ const nr = wr / sum;
|
|
|
+
|
|
|
+ if (Math.abs(sum - 1) > 1e-9) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Weights normalized from (pop=${wp}, ctr=${wc}, rec=${wr}, sum=${sum}) to ` +
|
|
|
+ `(pop=${np.toFixed(4)}, ctr=${nc.toFixed(4)}, rec=${nr.toFixed(4)})`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return { popularity: np, ctr: nc, recency: nr };
|
|
|
+ }
|
|
|
+
|
|
|
+ private bigIntToSafeNumber(v: bigint): number {
|
|
|
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
|
+ if (v <= BigInt(0)) return 0;
|
|
|
+
|
|
|
+ if (v > MAX_SAFE) return Number.MAX_SAFE_INTEGER;
|
|
|
+
|
|
|
+ return Number(v);
|
|
|
+ }
|
|
|
+
|
|
|
+ private newRunId(prefix: string): string {
|
|
|
+ // tiny unique-ish id for logs
|
|
|
+ const ts = Date.now().toString(36);
|
|
|
+ const rnd = Math.floor(Math.random() * 1_000_000)
|
|
|
+ .toString(36)
|
|
|
+ .padStart(4, '0');
|
|
|
+ return `${prefix}-${ts}-${rnd}`;
|
|
|
+ }
|
|
|
}
|