import S3Service from "../s3.service"; import logger from "../../utils/logger"; import { sleep } from "../../utils/helper"; import { Options } from "csv-parser"; import MetricService from "../metric.service"; import { isTest } from "../../utils/config"; export interface BucketMapping { inputUpper: number; outputUpper: number; outputLower: number; } export function parseBucketMapping( mapping: string, listSeparator = ",", tupleSeparator = "|" ): BucketMapping[] { const bucketMap: BucketMapping[] = []; if (!mapping || mapping.length < 2 || mapping === '""' || mapping === "[]") { return bucketMap; } mapping .substring(1, mapping.length - 1) .split(listSeparator) .forEach((x, i) => { const t = x.split(tupleSeparator); bucketMap.push({ inputUpper: parseFloat(t[0]), outputUpper: parseFloat(t[1]), outputLower: i > 0 ? bucketMap[i - 1].outputUpper : 0, }); }); bucketMap.push({ inputUpper: 1, outputUpper: 1, outputLower: bucketMap[bucketMap.length - 1].outputUpper, }); return bucketMap; } export interface NormStats { bucketsMap: BucketMapping[]; } export default abstract class AbstractScoreHashMap { protected abstract logId: string; protected abstract s3Bucket: string; protected abstract s3Prefix: string; protected abstract maxRetries: number; protected abstract retryDelay: number; protected abstract refreshInterval: number; protected abstract refreshMetricLabel: string; protected scoresFilesSubPrefix: string; protected normFilesSubPrefix: string; protected scoresMap: Map; protected normMap: Map; private lastRefreshTime: Date; protected metricService: MetricService; protected readonly s3Service: S3Service; protected shouldLoadScoreFiles: boolean = true; constructor() { this.s3Service = new S3Service(); this.metricService = MetricService.getInstance(); this.scoresFilesSubPrefix = "context"; this.normFilesSubPrefix = "campaign"; this.scoresMap = new Map(); this.normMap = new Map(); this.lastRefreshTime = new Date(0); } /** * return the modification date of s3 files to load * @returns {Promise} * */ public async getLastS3ModificationDate(): Promise { return this.s3Service.getLastModified(this.s3Bucket, `${this.s3Prefix}/_SUCCESS`); } private getCsvOptions(isNorm: boolean): Options { return { separator: "\t", strict: true, headers: !isNorm ? ["key", "score"] : ["key", "prior", "avg", "std", "bucketMap"], mapValues: ({ header, value }) => { if (header === "key" || header === "bucketMap") { return value; } else { return parseFloat(value); } }, }; } /** * load S3 file to new hashmap * @returns */ async loadHashMap( s3Keys: string[], options: Options, processLineFn: (hashmap: Map, line: any) => void ): Promise> { const hashmap = new Map(); await Promise.all( s3Keys.map(async (k) => { try { const reader = this.s3Service.readLinesCsv(this.s3Bucket, k, options); for await (const line of reader) { processLineFn(hashmap, line); } } catch (error: any) { logger.error(error, `${this.logId} Error while reading file s3://${this.s3Bucket}/${k}`); } }) ); return hashmap; } /** * refresh scores and normalization statistics hashmap. * @param retryCount retry ranking starting at 0 */ async refreshMaps(retryCount = 0): Promise { function filterKeysBySubPrefix(s3Keys: string[], subPrefix: string) { return s3Keys.filter((k) => k.includes(`/${subPrefix}/`)); } logger.info( `${this.logId} beginning hashmap refresh (attempt ${retryCount}): ${this.s3Bucket}/${this.s3Prefix}` ); try { const modificationDate = (await this.getLastS3ModificationDate()) ?? new Date(0); if (this.lastRefreshTime < modificationDate || this.lastRefreshTime === undefined) { const endRefreshTimer = this.metricService.startSection(this.refreshMetricLabel); const s3Keys = await this.s3Service.listKeys(this.s3Bucket, this.s3Prefix); let newScoreMap: Map = this.scoresMap; if (this.shouldLoadScoreFiles) { newScoreMap = await this.loadHashMap( filterKeysBySubPrefix(s3Keys, this.scoresFilesSubPrefix), this.getCsvOptions(false), (hashmap, line) => { hashmap.set(line.key, line.score); } ); } const newNormMap = await this.loadHashMap( filterKeysBySubPrefix(s3Keys, this.normFilesSubPrefix), this.getCsvOptions(true), (hashmap, line) => { hashmap.set(line.key, { bucketsMap: parseBucketMapping(line.bucketMap), }); } ); this.onRefreshSuccess(newScoreMap, newNormMap, modificationDate); endRefreshTimer(true); } } catch (err: any) { await this.onRefreshError(err, retryCount); } } protected onRefreshSuccess( newScoresMap: Map, newNormMap: Map, modificationDate: Date ): void { this.scoresMap = newScoresMap; this.normMap = newNormMap; this.lastRefreshTime = modificationDate; logger.info(`${this.logId} hashmap refresh succeeded`); } private async onRefreshError(err: any, retryCount: number): Promise { logger.error(err, `${this.logId} hashmap refresh failed`); if (retryCount >= this.maxRetries) { logger.error(err, `${this.logId} ${this.maxRetries} maximum retries reached`); } else { logger.info(`${this.logId} retrying refresh in ${this.retryDelay} ms`); await sleep(this.retryDelay); await this.refreshMaps(retryCount + 1); } } /** * schedule hashmap refresh */ protected scheduleRefresh() { if (!isTest()) { this.refreshMaps(); logger.info(`${this.logId} starting service`); setInterval(() => this.refreshMaps(), this.refreshInterval); } } /** * return hashmap size and last update epoch */ getStatus() { return { scoresMapSize: this.scoresMap.size, normMapSize: this.normMap.size, lastRefreshTimestamp: this.lastRefreshTime.getTime(), }; } getMaps() { return { scoresMap: this.scoresMap, normMap: this.normMap, }; } formatStatistics( stats: NormStats | undefined, fallbackStats: NormStats | undefined ): { bucketsMap: BucketMapping[] } { let bucketsMap: BucketMapping[] = []; if (stats?.bucketsMap && stats.bucketsMap.length > 0) { bucketsMap = stats.bucketsMap; } else if (fallbackStats?.bucketsMap && fallbackStats.bucketsMap.length > 0) { bucketsMap = fallbackStats.bucketsMap; } return { bucketsMap }; } }