From f7d1b64e9b647b2f118811ea4c229db32c12b6a0 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Fri, 11 Sep 2026 20:13:38 -0400 Subject: [PATCH] feat(fantasy): move persistent caches to Redis Move player, projection, FantasyCalc, waiver market, and AI analysis cache values out of the Node heap while preserving TTLs, stale fallback, and in-flight request deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/fantasy/fantasy.service.spec.ts | 87 +++-- .../backend/src/fantasy/fantasy.service.ts | 342 +++++++++++++----- .../backend/src/test/mocks/ioredis.mock.ts | 44 ++- 3 files changed, 334 insertions(+), 139 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index e3e6624c..5a7709a0 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -1,6 +1,7 @@ import Axios from 'axios'; import { getRepository } from 'typeorm'; import type { OpenAIClientLike } from '../lib/resilientOpenAIClient'; +import { RedisPersistenceService } from '../shared/services/redis.persistence.service'; import type { AITradeAnalysis, FantasyCalcPlayerValue, @@ -77,6 +78,7 @@ type FantasyCalcResponseEntry = { }; type FantasyServiceInternals = { + getPlayers: () => Promise>; getTradeAnalysis: ( key: string, refresh: boolean, @@ -148,7 +150,10 @@ describe('FantasyService', () => { const create = vi.fn(); let service: FantasyService; - beforeEach(() => { + beforeEach(async () => { + const redis = RedisPersistenceService.getInstance(); + const cacheKeys = await redis.getPattern('fantasy:'); + await Promise.all(cacheKeys.map((key) => redis.removeKey(key))); (getRepository as Mock).mockReturnValue({ findOne }); create.mockResolvedValue(aiResponse); service = new FantasyService({ responses: { create } } as unknown as OpenAIClientLike); @@ -178,6 +183,37 @@ describe('FantasyService', () => { expect(Axios.get).not.toHaveBeenCalled(); }); + it('shares a compact NFL player cache through Redis across service instances', async () => { + (Axios.get as Mock).mockResolvedValueOnce({ + data: { + p1: { + player_id: 'p1', + first_name: 'Drew', + last_name: 'Runner', + position: 'RB', + team: 'SEA', + injury_status: null, + fantasy_positions: ['RB'], + search_rank: 10, + oversized_blob: 'not retained', + }, + }, + }); + const firstInternals = service as unknown as FantasyServiceInternals; + const secondInternals = new FantasyService({ + responses: { create }, + } as unknown as OpenAIClientLike) as unknown as FantasyServiceInternals; + + const first = await firstInternals.getPlayers(); + const second = await secondInternals.getPlayers(); + + expect(Axios.get).toHaveBeenCalledOnce(); + expect(second).toEqual(first); + expect(await RedisPersistenceService.getInstance().getValue('fantasy:cache:players:nfl')).not.toContain( + 'oversized_blob', + ); + }); + it('caches AI analysis for 24 hours and bypasses the cache on refresh', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-09T12:00:00.000Z')); @@ -192,9 +228,9 @@ describe('FantasyService', () => { const generate = vi.fn().mockResolvedValue(analysis); try { - await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toBe(analysis); + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toEqual(analysis); vi.advanceTimersByTime(24 * 60 * 60 * 1000 - 1); - await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toBe(analysis); + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toEqual(analysis); expect(generate).toHaveBeenCalledOnce(); await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', true, generate)).resolves.toBe(analysis); @@ -1574,6 +1610,7 @@ describe('FantasyService', () => { const first = internals.getFantasyCalcValues(league); const second = internals.getFantasyCalcValues(league); + await vi.waitFor(() => expect(resolveRequest).toBeDefined()); resolveRequest?.({ data: [ { @@ -1654,13 +1691,6 @@ describe('FantasyService', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-11T12:00:00.000Z')); const internals = service as unknown as FantasyServiceInternals; - const serviceState = service as unknown as { - fantasyCalcCache: Map< - string, - { expiresAt: number; values: Map; version: number } - >; - fantasyCalcAnalysisVersions: Map; - }; const league: SleeperLeague = { league_id: '998', name: 'Redraft League', @@ -1684,17 +1714,17 @@ describe('FantasyService', () => { ], ]); const cacheKey = 'false:1:12:0.5'; - serviceState.fantasyCalcAnalysisVersions.set(cacheKey, 1); - serviceState.fantasyCalcCache.set(cacheKey, { - expiresAt: Date.now() - 1, - values: staleValues, - version: 1, - }); + await RedisPersistenceService.getInstance().setValueWithExpire( + `fantasy:cache:fantasycalc-stale:${encodeURIComponent(cacheKey)}`, + JSON.stringify({ values: [...staleValues], version: 1 }), + 'PX', + 24 * 60 * 60 * 1000, + ); (Axios.get as Mock).mockRejectedValueOnce(new Error('network error')); try { - await expect(internals.getFantasyCalcValues(league)).resolves.toBe(staleValues); - await expect(internals.getFantasyCalcValues(league)).resolves.toBe(staleValues); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(staleValues); + await expect(internals.getFantasyCalcValues(league)).resolves.toEqual(staleValues); expect(Axios.get).toHaveBeenCalledTimes(1); expect(internals.getFantasyCalcAnalysisVersion(league)).toBe(1); } finally { @@ -1764,13 +1794,6 @@ describe('FantasyService', () => { it('keeps the overview FantasyCalc version aligned with the fallback values snapshot', async () => { const internals = service as unknown as FantasyServiceInternals; - const serviceState = service as unknown as { - fantasyCalcCache: Map< - string, - { expiresAt: number; values: Map; version: number } - >; - fantasyCalcAnalysisVersions: Map; - }; const league: SleeperLeague = { league_id: '998', name: 'Redraft League', @@ -1794,12 +1817,12 @@ describe('FantasyService', () => { ], ]); const cacheKey = 'false:1:12:0.5'; - serviceState.fantasyCalcAnalysisVersions.set(cacheKey, 1); - serviceState.fantasyCalcCache.set(cacheKey, { - expiresAt: Date.now() - 1, - values: staleValues, - version: 1, - }); + await RedisPersistenceService.getInstance().setValueWithExpire( + `fantasy:cache:fantasycalc-stale:${encodeURIComponent(cacheKey)}`, + JSON.stringify({ values: [...staleValues], version: 1 }), + 'PX', + 24 * 60 * 60 * 1000, + ); (Axios.get as Mock).mockResolvedValueOnce({ data: [ { @@ -1815,7 +1838,7 @@ describe('FantasyService', () => { const snapshot = await internals.getFantasyCalcValuesForOverview(league); - expect(snapshot.values).toBe(staleValues); + expect(snapshot.values).toEqual(staleValues); expect(snapshot.version).toBe(1); await expect(internals.getFantasyCalcValues(league)).resolves.toEqual( new Map([ diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index 27f949fc..89d457ab 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -12,6 +12,7 @@ import type { OpenAIClientLike } from '../lib/resilientOpenAIClient'; import { SlackUser } from '../shared/db/models/SlackUser'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; +import { RedisPersistenceService } from '../shared/services/redis.persistence.service'; import type { AITradeAnalysis, FantasyLandingResponse, @@ -48,6 +49,9 @@ const WAIVER_MARKET_CACHE_MS = 6 * 60 * 60 * 1000; const FANTASYCALC_CACHE_MS = 6 * 60 * 60 * 1000; const FANTASYCALC_FAILURE_CACHE_MS = 5 * 60 * 1000; const FANTASYCALC_TIMEOUT_MS = 2000; +const FANTASYCALC_STALE_CACHE_MS = 24 * 60 * 60 * 1000; +const FANTASY_CACHE_PREFIX = 'fantasy:cache'; +const PLAYER_CACHE_KEY = `${FANTASY_CACHE_PREFIX}:players:nfl`; // Must be long enough for the FantasyCalc request (bounded by FANTASYCALC_TIMEOUT_MS) to actually // finish on a cold cache; a short wait here mostly guarantees the fallback (all-null marketValues) // on every first request after a deploy, since the in-memory cache resets on restart. @@ -115,8 +119,14 @@ interface FantasyCalcSnapshot { version: number; } -interface FantasyCalcCacheEntry extends FantasyCalcSnapshot { - expiresAt: number; +interface FantasyCalcRedisEntry { + values: Array<[string, FantasyCalcPlayerValue]>; + version: number; +} + +interface TradeAnalysisCacheEntry { + analysis: AITradeAnalysis; + marketValueVersion: number; } const isOutputMessage = (item: ResponseOutputItem): item is ResponseOutputMessage => item.type === 'message'; @@ -140,6 +150,124 @@ function isIntegerInRange(value: unknown, minimum: number, maximum: number): val return typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function compactPlayers(players: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(players)) { + if (isRecord(value)) { + result[key] = { + player_id: typeof value.player_id === 'string' ? value.player_id : key, + first_name: typeof value.first_name === 'string' ? value.first_name : null, + last_name: typeof value.last_name === 'string' ? value.last_name : null, + position: typeof value.position === 'string' ? value.position : null, + team: typeof value.team === 'string' ? value.team : null, + injury_status: typeof value.injury_status === 'string' ? value.injury_status : null, + fantasy_positions: Array.isArray(value.fantasy_positions) + ? value.fantasy_positions.filter((p): p is string => typeof p === 'string') + : undefined, + search_rank: typeof value.search_rank === 'number' ? value.search_rank : null, + }; + } + } + return result; +} + +function isSleeperPlayerRecord(value: unknown): value is Record { + return isRecord(value); +} + +function isSleeperProjectionArray(value: unknown): value is SleeperProjection[] { + return Array.isArray(value) && value.every((item) => isRecord(item) && typeof item.player_id === 'string'); +} + +function isWaiverMarketSampleArray(value: unknown): value is WaiverMarketSample[] { + return ( + Array.isArray(value) && + value.every( + (item) => + isRecord(item) && + typeof item.pricePerPoint === 'number' && + (typeof item.position === 'string' || item.position === null) && + typeof item.weight === 'number', + ) + ); +} + +function isFantasyCalcPlayerValue(value: unknown): value is FantasyCalcPlayerValue { + return ( + isRecord(value) && + typeof value.sleeperId === 'string' && + typeof value.value === 'number' && + typeof value.overallRank === 'number' && + typeof value.positionRank === 'number' && + typeof value.trend30Day === 'number' && + (typeof value.tradeFrequency === 'number' || value.tradeFrequency === null) + ); +} + +function isFantasyCalcRedisEntry(value: unknown): value is FantasyCalcRedisEntry { + return ( + isRecord(value) && + Number.isInteger(value.version) && + Array.isArray(value.values) && + value.values.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === 'string' && + isFantasyCalcPlayerValue(entry[1]), + ) + ); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isAITradeAnalysis(value: unknown): value is AITradeAnalysis { + return ( + isRecord(value) && + isRecord(value.teamHealth) && + typeof value.teamHealth.percentage === 'number' && + typeof value.teamHealth.summary === 'string' && + Array.isArray(value.tradeInsights) && + value.tradeInsights.every( + (item) => + isRecord(item) && + typeof item.transactionId === 'string' && + typeof item.insight === 'string' && + isRecommendation(item.recommendation), + ) && + Array.isArray(value.suggestions) && + value.suggestions.every( + (item) => + isRecord(item) && + typeof item.targetRosterId === 'number' && + isStringArray(item.givePlayerIds) && + isStringArray(item.receivePlayerIds) && + typeof item.rationale === 'string', + ) && + Array.isArray(value.waiverSuggestions) && + value.waiverSuggestions.every( + (item) => + isRecord(item) && + typeof item.addPlayerId === 'string' && + typeof item.dropPlayerId === 'string' && + typeof item.rationale === 'string' && + isPriority(item.priority) && + typeof item.recommendedBid === 'number', + ) && + typeof value.lineupSummary === 'string' + ); +} + +function isTradeAnalysisCacheEntry(value: unknown): value is TradeAnalysisCacheEntry { + return isRecord(value) && Number.isInteger(value.marketValueVersion) && isAITradeAnalysis(value.analysis); +} + export class FantasyValidationError extends Error { constructor(message: string) { super(message); @@ -148,18 +276,11 @@ export class FantasyValidationError extends Error { } export class FantasyService { - private playerCache: { expiresAt: number; players: Record } | null = null; private playerRequest: Promise> | null = null; - private waiverMarketCache = new Map(); - private projectionCache = new Map(); - private fantasyCalcCache = new Map(); private fantasyCalcAnalysisVersions = new Map(); private fantasyCalcRequests = new Map>>(); - private analysisCache = new Map< - string, - { expiresAt: number; analysis: AITradeAnalysis; marketValueVersion: number } - >(); private readonly openAi: OpenAIClientLike; + private readonly redis = RedisPersistenceService.getInstance(); private readonly serviceLogger = logger.child({ module: 'FantasyService' }); constructor(openAi?: OpenAIClientLike) { @@ -172,6 +293,38 @@ export class FantasyService { ); } + private getRedisCacheKey(namespace: string, key: string): string { + return `${FANTASY_CACHE_PREFIX}:${namespace}:${encodeURIComponent(key)}`; + } + + private async readRedisCache(key: string, validator: (value: unknown) => value is T): Promise { + try { + const raw = await this.redis.getValue(key); + if (!raw) { + return null; + } + + const parsed: unknown = JSON.parse(raw); + if (!validator(parsed)) { + this.serviceLogger.warn('Ignoring invalid fantasy cache entry', { key }); + await this.redis.removeKey(key); + return null; + } + return parsed; + } catch (error) { + logError(this.serviceLogger, 'Failed to read fantasy cache entry from Redis', error, { key }); + return null; + } + } + + private async writeRedisCache(key: string, value: unknown, ttlMs: number): Promise { + try { + await this.redis.setValueWithExpire(key, JSON.stringify(value), 'PX', ttlMs); + } catch (error) { + logError(this.serviceLogger, 'Failed to write fantasy cache entry to Redis', error, { key }); + } + } + public async getLanding(slackId: string, teamId: string): Promise { const dbUser = await getRepository(SlackUser).findOne({ where: { slackId, teamId } }); if (!dbUser) { @@ -432,22 +585,37 @@ export class FantasyService { * with less precise fairness signal. */ private async getFantasyCalcValues(league: SleeperLeague): Promise> { - const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); - const numTeams = league.total_rosters || 12; const cacheKey = this.getFantasyCalcCacheKey(league); - const cached = this.fantasyCalcCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.values; - } const inFlight = this.fantasyCalcRequests.get(cacheKey); - if (inFlight) { - return inFlight; + if (inFlight) return inFlight; + + const request = this.loadFantasyCalcValues(league, cacheKey).finally(() => { + this.fantasyCalcRequests.delete(cacheKey); + }); + this.fantasyCalcRequests.set(cacheKey, request); + return request; + } + + private async loadFantasyCalcValues( + league: SleeperLeague, + cacheKey: string, + ): Promise> { + const { isDynasty, numQbs, ppr } = this.resolveLeagueFormat(league); + const numTeams = league.total_rosters || 12; + const redisKey = this.getRedisCacheKey('fantasycalc', cacheKey); + const staleRedisKey = this.getRedisCacheKey('fantasycalc-stale', cacheKey); + const cached = await this.readRedisCache(redisKey, isFantasyCalcRedisEntry); + if (cached) { + this.fantasyCalcAnalysisVersions.set(cacheKey, cached.version); + return new Map(cached.values); } - const request = Axios.get(FANTASYCALC_API_URL, { + const stale = await this.readRedisCache(staleRedisKey, isFantasyCalcRedisEntry); + if (stale) this.fantasyCalcAnalysisVersions.set(cacheKey, stale.version); + return Axios.get(FANTASYCALC_API_URL, { params: { isDynasty, numQbs, numTeams, ppr }, timeout: FANTASYCALC_TIMEOUT_MS, }) - .then((response) => { + .then(async (response) => { const values = new Map( response.data .filter((entry): entry is FantasyCalcApiEntry & { player: { sleeperId: string } } => @@ -465,47 +633,46 @@ export class FantasyService { }, ]), ); - const version = this.getFantasyCalcAnalysisVersion(league) + 1; - this.fantasyCalcCache.set(cacheKey, { expiresAt: Date.now() + FANTASYCALC_CACHE_MS, values, version }); + const version = (stale?.version ?? this.getFantasyCalcAnalysisVersion(league)) + 1; + const entry: FantasyCalcRedisEntry = { values: [...values], version }; + await Promise.all([ + this.writeRedisCache(redisKey, entry, FANTASYCALC_CACHE_MS), + this.writeRedisCache(staleRedisKey, entry, FANTASYCALC_STALE_CACHE_MS), + ]); this.fantasyCalcAnalysisVersions.set(cacheKey, version); return values; }) - .catch((error) => { + .catch(async (error) => { logError(this.serviceLogger, 'Failed to load FantasyCalc player values', error, { isDynasty, numQbs, numTeams, ppr, }); - const fallbackValues = cached?.values ?? new Map(); - const version = cached?.version ?? this.getFantasyCalcAnalysisVersion(league); - this.fantasyCalcCache.set(cacheKey, { - expiresAt: Date.now() + FANTASYCALC_FAILURE_CACHE_MS, - values: fallbackValues, - version, - }); + const fallbackValues = new Map(stale?.values ?? []); + const version = stale?.version ?? this.getFantasyCalcAnalysisVersion(league); + await this.writeRedisCache(redisKey, { values: [...fallbackValues], version }, FANTASYCALC_FAILURE_CACHE_MS); return fallbackValues; - }) - .finally(() => { - this.fantasyCalcRequests.delete(cacheKey); }); - this.fantasyCalcRequests.set(cacheKey, request); - return request; } - private getFantasyCalcValuesForOverview(league: SleeperLeague): Promise { + private async getFantasyCalcValuesForOverview(league: SleeperLeague): Promise { const cacheKey = this.getFantasyCalcCacheKey(league); - const cached = this.fantasyCalcCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return Promise.resolve({ values: cached.values, version: cached.version }); + const redisKey = this.getRedisCacheKey('fantasycalc', cacheKey); + const staleRedisKey = this.getRedisCacheKey('fantasycalc-stale', cacheKey); + const cached = await this.readRedisCache(redisKey, isFantasyCalcRedisEntry); + if (cached) { + this.fantasyCalcAnalysisVersions.set(cacheKey, cached.version); + return { values: new Map(cached.values), version: cached.version }; } - - const fallbackSnapshot: FantasyCalcSnapshot = cached - ? { values: cached.values, version: cached.version } + const stale = await this.readRedisCache(staleRedisKey, isFantasyCalcRedisEntry); + if (stale) this.fantasyCalcAnalysisVersions.set(cacheKey, stale.version); + const fallbackSnapshot: FantasyCalcSnapshot = stale + ? { values: new Map(stale.values), version: stale.version } : { values: new Map(), version: this.getFantasyCalcAnalysisVersion(league) }; const refreshPromise = this.getFantasyCalcValues(league); - if (cached) { - return Promise.resolve(fallbackSnapshot); + if (stale) { + return fallbackSnapshot; } return new Promise((resolve) => { @@ -539,25 +706,22 @@ export class FantasyService { return this.get(`/user/${encodeURIComponent(userId)}/leagues/nfl/${encodeURIComponent(season)}`); } - private getProjections(state: NflState, refresh: boolean): Promise { + private async getProjections(state: NflState, refresh: boolean): Promise { const key = `current:${state.season}:${state.season_type}:${Math.max(state.week, 1)}`; - const cached = this.projectionCache.get(key); - if (!refresh && cached && cached.expiresAt > Date.now()) { - return Promise.resolve(cached.projections); + const redisKey = this.getRedisCacheKey('projections', key); + if (!refresh) { + const cached = await this.readRedisCache(redisKey, isSleeperProjectionArray); + if (cached) return cached; } - return Axios.get( + const response = await Axios.get( `${SLEEPER_PROJECTIONS_URL}/${encodeURIComponent(state.season)}/${Math.max(state.week, 1)}`, { params: { season_type: state.season_type }, timeout: 10000, }, - ).then((response) => { - this.projectionCache.set(key, { - expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, - projections: response.data, - }); - return response.data; - }); + ); + await this.writeRedisCache(redisKey, response.data, AI_ANALYSIS_CACHE_MS); + return response.data; } private async getTradeAnalysis( @@ -566,34 +730,31 @@ export class FantasyService { generate: () => Promise, marketValueVersion = 0, ): Promise { - const cached = this.analysisCache.get(key); - if (!refresh && cached && cached.expiresAt > Date.now() && cached.marketValueVersion === marketValueVersion) { - return cached.analysis; + const redisKey = this.getRedisCacheKey('analysis', key); + if (!refresh) { + const cached = await this.readRedisCache(redisKey, isTradeAnalysisCacheEntry); + if (cached && cached.marketValueVersion === marketValueVersion) return cached.analysis; } const analysis = await generate(); - this.analysisCache.set(key, { - expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, - analysis, - marketValueVersion, - }); + await this.writeRedisCache(redisKey, { analysis, marketValueVersion }, AI_ANALYSIS_CACHE_MS); return analysis; } - private getSeasonProjections(season: string, week: number): Promise { + private async getSeasonProjections(season: string, week: number): Promise { const key = `${season}:${week}`; - const cached = this.projectionCache.get(key); - if (cached && cached.expiresAt > Date.now()) return Promise.resolve(cached.projections); - return Axios.get(`${SLEEPER_PROJECTIONS_URL}/${encodeURIComponent(season)}/${week}`, { - params: { season_type: 'regular' }, - timeout: 10000, - }).then((response) => { - this.projectionCache.set(key, { - expiresAt: Date.now() + WAIVER_MARKET_CACHE_MS, - projections: response.data, - }); - return response.data; - }); + const redisKey = this.getRedisCacheKey('projections', key); + const cached = await this.readRedisCache(redisKey, isSleeperProjectionArray); + if (cached) return cached; + const response = await Axios.get( + `${SLEEPER_PROJECTIONS_URL}/${encodeURIComponent(season)}/${week}`, + { + params: { season_type: 'regular' }, + timeout: 10000, + }, + ); + await this.writeRedisCache(redisKey, response.data, WAIVER_MARKET_CACHE_MS); + return response.data; } private async getWaiverBidGuidance( @@ -607,9 +768,10 @@ export class FantasyService { remainingBudget: number, ): Promise { const cacheKey = `${league.league_id}:${state.season}:${state.week}`; - const cached = this.waiverMarketCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return this.buildWaiverBidGuidance(cached.samples, league, currentProjections, candidates, remainingBudget); + const redisKey = this.getRedisCacheKey('waiver-market', cacheKey); + const cached = await this.readRedisCache(redisKey, isWaiverMarketSampleArray); + if (cached) { + return this.buildWaiverBidGuidance(cached, league, currentProjections, candidates, remainingBudget); } type SeasonTransactions = { @@ -741,10 +903,7 @@ export class FantasyService { } } - this.waiverMarketCache.set(cacheKey, { - expiresAt: Date.now() + WAIVER_MARKET_CACHE_MS, - samples, - }); + await this.writeRedisCache(redisKey, samples, WAIVER_MARKET_CACHE_MS); return this.buildWaiverBidGuidance(samples, league, currentProjections, candidates, remainingBudget); } @@ -787,16 +946,17 @@ export class FantasyService { } private async getPlayers(): Promise> { - if (this.playerCache && this.playerCache.expiresAt > Date.now()) { - return this.playerCache.players; - } + const cached = await this.readRedisCache(PLAYER_CACHE_KEY, isSleeperPlayerRecord); + if (cached) return compactPlayers(cached); + if (this.playerRequest) { return this.playerRequest; } - this.playerRequest = this.get>('/players/nfl?active=true') - .then((players) => { - this.playerCache = { expiresAt: Date.now() + PLAYER_CACHE_MS, players }; - return players; + this.playerRequest = this.get>('/players/nfl?active=true') + .then(async (players) => { + const compacted = compactPlayers(players); + await this.writeRedisCache(PLAYER_CACHE_KEY, compacted, PLAYER_CACHE_MS); + return compacted; }) .finally(() => { this.playerRequest = null; diff --git a/packages/backend/src/test/mocks/ioredis.mock.ts b/packages/backend/src/test/mocks/ioredis.mock.ts index 20539bc6..462d24b5 100644 --- a/packages/backend/src/test/mocks/ioredis.mock.ts +++ b/packages/backend/src/test/mocks/ioredis.mock.ts @@ -1,5 +1,14 @@ export class Redis { - private readonly store = new Map(); + private readonly store = new Map(); + + private getEntry(key: string): { value: string; expiresAt: number | null } | undefined { + const entry = this.store.get(key); + if (entry?.expiresAt !== null && entry?.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + this.store.delete(key); + return undefined; + } + return entry; + } on(_event: string, _listener: (...args: unknown[]) => void): this { void _event; @@ -8,33 +17,36 @@ export class Redis { } get(key: string): Promise { - return Promise.resolve(this.store.get(key) ?? null); + return Promise.resolve(this.getEntry(key)?.value ?? null); } - set(key: string, value: string | number, _mode?: string): Promise { - void _mode; - this.store.set(key, String(value)); + set(key: string, value: string | number, mode?: string): Promise { + const expiresAt = mode === 'KEEPTTL' ? (this.getEntry(key)?.expiresAt ?? null) : null; + this.store.set(key, { value: String(value), expiresAt }); return Promise.resolve('OK'); } - setex(key: string, _seconds: number, value: string | number): Promise { - this.store.set(key, String(value)); + setex(key: string, seconds: number, value: string | number): Promise { + this.store.set(key, { value: String(value), expiresAt: Date.now() + seconds * 1000 }); return Promise.resolve('OK'); } - psetex(key: string, _milliseconds: number, value: string | number): Promise { - this.store.set(key, String(value)); + psetex(key: string, milliseconds: number, value: string | number): Promise { + this.store.set(key, { value: String(value), expiresAt: Date.now() + milliseconds }); return Promise.resolve('OK'); } - ttl(_key: string): Promise { - void _key; - return Promise.resolve(60); + ttl(key: string): Promise { + const entry = this.getEntry(key); + if (!entry) return Promise.resolve(-2); + if (entry.expiresAt === null) return Promise.resolve(-1); + return Promise.resolve(Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1000))); } - expire(_key: string, _seconds: number): Promise { - void _key; - void _seconds; + expire(key: string, seconds: number): Promise { + const entry = this.getEntry(key); + if (!entry) return Promise.resolve(0); + entry.expiresAt = Date.now() + seconds * 1000; return Promise.resolve(1); } @@ -45,7 +57,7 @@ export class Redis { keys(pattern: string): Promise { const sanitizedPattern = pattern.replace(/\*/g, ''); - const keys = [...this.store.keys()].filter((k) => k.includes(sanitizedPattern)); + const keys = [...this.store.keys()].filter((key) => this.getEntry(key) && key.includes(sanitizedPattern)); return Promise.resolve(keys); }