Share per-date game cache across dailyArchive users (R1)

judgeDay and hasMissedGameDayBetween re-ran the same listByDate query for
every user in an archive run (U× amplification for the archived date plus
its look-back window). Introduce a memoizing GameDayCache and thread one
instance through the whole runDailyArchive pass so each distinct date is
read from Firestore once. Off-day detection is naturally memoized via the
cached query results. Standalone callers (statsService) keep their prior
behavior via a per-call default cache. No change to judgment results.
This commit is contained in:
윤정민 2026-05-28 17:20:50 +09:00
parent 12f21803bc
commit dc87cda0c3
3 changed files with 44 additions and 8 deletions

View File

@ -35,3 +35,29 @@ export async function listByDate(date: DateString): Promise<GameWithId[]> {
.get(); .get();
return snap.docs.map((d) => ({ gameId: d.id, ...(d.data() as Game) })); return snap.docs.map((d) => ({ gameId: d.id, ...(d.data() as Game) }));
} }
/**
* `listByDate` .
*
* `dailyArchive` run에서 `games`
* , Firestore read를 1 .
* Promise를 .
*/
export interface GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]>;
}
/** 새 `GameDayCache`를 생성한다. 캐시는 인스턴스 수명 동안만 유효하다. */
export function createGameDayCache(): GameDayCache {
const cache = new Map<DateString, Promise<GameWithId[]>>();
return {
listByDate(date: DateString): Promise<GameWithId[]> {
let p = cache.get(date);
if (!p) {
p = listByDate(date);
cache.set(date, p);
}
return p;
},
};
}

View File

@ -9,7 +9,7 @@ import {
snapshotRankForUser, snapshotRankForUser,
} from "../services/rankSnapshotService"; } from "../services/rankSnapshotService";
import { todayKst } from "../types/dateString"; import { todayKst } from "../types/dateString";
import { getGame } from "../repositories/gameRepository"; import { getGame, createGameDayCache } from "../repositories/gameRepository";
import { deleteUserVoteGame } from "../repositories/voteRepository"; import { deleteUserVoteGame } from "../repositories/voteRepository";
import { processGameEndWithGame } from "../services/gameResultService"; import { processGameEndWithGame } from "../services/gameResultService";
import type { VoteHistoryDoc } from "../types/panit"; import type { VoteHistoryDoc } from "../types/panit";
@ -92,6 +92,9 @@ export async function runDailyArchive(
let archived = 0; let archived = 0;
const judgedUids: string[] = []; const judgedUids: string[] = [];
// 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록
// run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴.
const gameCache = createGameDayCache();
try { try {
const snap = await rtdb.ref("/userVotes").get(); const snap = await rtdb.ref("/userVotes").get();
if (!snap.exists()) { if (!snap.exists()) {
@ -136,7 +139,7 @@ export async function runDailyArchive(
logger.error(`snapshotRank failed uid=${uid} date=${date}`, err); logger.error(`snapshotRank failed uid=${uid} date=${date}`, err);
} }
try { try {
await judgeDay(uid, date, { data }); await judgeDay(uid, date, { data }, gameCache);
} catch (err) { } catch (err) {
logger.error(`judgeDay failed uid=${uid} date=${date}`, err); logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
} }

View File

@ -1,5 +1,8 @@
import { logger } from "firebase-functions"; import { logger } from "firebase-functions";
import { listByDate } from "../repositories/gameRepository"; import {
createGameDayCache,
type GameDayCache,
} from "../repositories/gameRepository";
import { getRange, setDay } from "../repositories/voteHistoryRepository"; import { getRange, setDay } from "../repositories/voteHistoryRepository";
import { import {
applyDailyJudgmentTx, applyDailyJudgmentTx,
@ -26,12 +29,14 @@ import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString";
*/ */
export async function hasMissedGameDayBetween( export async function hasMissedGameDayBetween(
lastJudged: DateString, lastJudged: DateString,
upTo: DateString upTo: DateString,
gameCache?: GameDayCache
): Promise<boolean> { ): Promise<boolean> {
const fetch = gameCache ?? createGameDayCache();
const MAX_LOOKBACK = 14; const MAX_LOOKBACK = 14;
let cursor = addDaysKst(upTo, -1); let cursor = addDaysKst(upTo, -1);
for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) { for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) {
const games = await listByDate(cursor); const games = await fetch.listByDate(cursor);
const completed = games.filter((g) => g.status === "completed").length; const completed = games.filter((g) => g.status === "completed").length;
if (thresholdsFor(completed) !== "skip") return true; if (thresholdsFor(completed) !== "skip") return true;
cursor = addDaysKst(cursor, -1); cursor = addDaysKst(cursor, -1);
@ -53,9 +58,11 @@ export async function hasMissedGameDayBetween(
export async function judgeDay( export async function judgeDay(
uid: string, uid: string,
date: DateString, date: DateString,
voteDoc: VoteHistoryDoc voteDoc: VoteHistoryDoc,
gameCache?: GameDayCache
): Promise<void> { ): Promise<void> {
const games = await listByDate(date); const fetch = gameCache ?? createGameDayCache();
const games = await fetch.listByDate(date);
const completedCount = games.filter((g) => g.status === "completed").length; const completedCount = games.filter((g) => g.status === "completed").length;
const threshold = thresholdsFor(completedCount); const threshold = thresholdsFor(completedCount);
@ -76,7 +83,7 @@ export async function judgeDay(
const streakBrokenIn = const streakBrokenIn =
lastJudgedPre != null && lastJudgedPre != null &&
lastJudgedPre < addDaysKst(date, -1) && lastJudgedPre < addDaysKst(date, -1) &&
(await hasMissedGameDayBetween(lastJudgedPre, date)); (await hasMissedGameDayBetween(lastJudgedPre, date, fetch));
const tx = await applyDailyJudgmentTx(uid, date, { const tx = await applyDailyJudgmentTx(uid, date, {
judgment, judgment,