mmday-firebase/src/scheduled/dailyArchive.ts
윤정민 513bf70e87 Reduce per-user reads in daily archive and stats
- dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다)
- 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백
- user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다)
- reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다)
- settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다
- 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다
- stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다
- precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다)
- 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일)
2026-07-27 13:26:41 +09:00

285 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { onSchedule } from "firebase-functions/scheduler";
import { logger } from "firebase-functions";
import { rtdb } from "../firebase";
import { setDay } from "../repositories/voteHistoryRepository";
import { invalidateStats } from "../services/statsService";
import { judgeDay } from "../services/judgmentService";
import {
precomputeScoreboardCache,
computeRankSnapshot,
loadRankStandings,
type RankStandings,
} from "../services/rankSnapshotService";
import { getUser } from "../repositories/userRepository";
import { maybeSettleSeason } from "../services/seasonService";
import { todayKst } from "../types/dateString";
import {
getGame,
createGameDayCache,
type GameDayCache,
} from "../repositories/gameRepository";
import { processGameEndWithGame } from "../services/gameResultService";
import {
getVotesByDate,
isVoteDateIndexBackfilled,
} from "../repositories/voteRepository";
import {
DRAW_TEAM_CODE,
type RankSnapshot,
type User,
type VoteHistoryDoc,
} from "../types/panit";
import {
daysAgoKst,
type DateString,
} from "../types/dateString";
interface RawVote {
team: string;
result?: boolean;
/** 경기 취소로 무효 처리된 투표 — 판정에서 제외하되 기록에는 남긴다. */
cancelled?: boolean;
}
type DayVotes = Record<string, RawVote>;
/**
* 미판정 경기들을 games 문서로 조회해 자가치유한다.
* - completed + winningTeamCode: 즉석 processGameEndWithGame 호출 후 result 재주입.
* - cancelled: 무효(cancelled) 마킹 — 판정에서 빠지되 참여 흔적은 보존.
* - 그 외: warn 로그 후 그대로 둔다(진짜 미판정).
*
* 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 마킹된 상태).
*/
async function reconcileDayVotes(
uid: string,
date: DateString,
dayVotes: DayVotes,
gameCache: GameDayCache,
healed: Set<string>
): Promise<DayVotes> {
const result: DayVotes = { ...dayVotes };
// 해당 날짜 경기를 한 번에 확보한다 — 유저×미판정경기 수만큼 getGame을 치던 N+1 제거.
const byId = new Map(
(await gameCache.listByDate(date)).map((g) => [g.gameId, g])
);
for (const [gameId, vote] of Object.entries(dayVotes)) {
if (vote.result !== undefined || vote.cancelled) continue;
const game = byId.get(gameId) ?? (await getGame(gameId));
if (!game) {
logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`);
continue;
}
// 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로.
// `winningTeamCode`가 없으면 무승부 — 무승부('DRAW') 투표만 적중으로 처리한다
// (`processGameEndWithGame`과 동일 규칙).
if (game.status === "completed") {
try {
// `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히
// 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의
// 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다.
if (!healed.has(gameId)) {
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
await processGameEndWithGame(gameId, game, { skipInvalidate: true });
healed.add(gameId);
}
const isDraw = !game.winningTeamCode;
result[gameId] = {
team: vote.team,
result: isDraw
? vote.team === DRAW_TEAM_CODE
: vote.team === game.winningTeamCode,
};
logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`);
} catch (err) {
logger.error(`reconcile: processGameEnd failed ${gameId}`, err);
}
// 취소 경기 뒤늦게 감지: 무효 마킹으로 allJudged 통과를 허용하되
// "픽했지만 취소됨"이라는 참여 흔적은 보존한다.
} else if (game.status === "cancelled") {
result[gameId] = { team: vote.team, cancelled: true };
logger.info(`reconcile: voided cancelled ${gameId} (uid=${uid})`);
} else {
logger.warn(
`reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})`
);
}
}
return result;
}
/**
* dailyArchive의 본체 로직. 스케줄 콜백과 수동 트리거(`runDailyArchiveNow`) 양쪽에서 호출.
*
* @param overrideDate 지정 시 그 날짜를 archive 대상으로 사용. 미지정 시 어제(KST).
* @returns 처리 결과 요약
*/
export async function runDailyArchive(
overrideDate?: DateString
): Promise<{ date: DateString; archived: number; judgedUids: string[] }> {
const date = overrideDate ?? daysAgoKst(1);
logger.info(`dailyArchive start: ${date}`);
let archived = 0;
const judgedUids: string[] = [];
// 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록
// run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴.
const gameCache = createGameDayCache();
// run 중 자가치유된 경기 — 뒤따르는 유저들이 같은 경기를 재처리하지 않도록 한다.
const healedGames = new Set<string>();
// 순위표를 run 시작 시 1회 로드해 유저별 count aggregation(O(N×M))을 없앤다.
// 실패 시 standings 없이 진행하면 computeRankSnapshot이 aggregation으로 폴백한다.
let standings: RankStandings | null = null;
try {
standings = await loadRankStandings();
} catch (err) {
logger.error("loadRankStandings failed — per-user aggregation으로 폴백", err);
}
try {
// 날짜별 인덱스만 읽는다 — 예전에는 하루치를 위해 `/userVotes` 트리 전체
// (전 유저 × 전 보존 날짜)를 내려받았다.
const byUid = (await getVotesByDate(date)) as Record<string, DayVotes>;
let mergedFromLegacy = 0;
// 백필이 끝났으면 인덱스가 전 이력을 담고 있으므로, 비어 있다는 것은
// "그날 투표가 없었다"는 사실이다 — 전체 스캔으로 되돌아가지 않는다.
// (이 가드가 없으면 월요일·비시즌 같은 무투표일마다 트리 전체를 다시 읽는다.)
//
// 반대로 마커가 없는 롤아웃 기간에는 인덱스가 **부분적으로만** 찼을 수 있다.
// 미러 배포 전에 투표한 유저는 인덱스에 없고 원본에만 있는데, 같은 날 배포 후
// 투표한 유저가 하나라도 있으면 인덱스가 비지 않는다. "비었을 때만 폴백"으로
// 두면 그 배포 전 투표자들이 판정·보상·스트릭 없이 영구 유실된다
// (아카이브는 매 run 다른 날짜를 처리하므로 그 날짜는 다시 열리지 않는다).
// 그래서 마커가 없으면 항상 원본을 읽어 인덱스에 없는 uid만 보충한다.
if (!(await isVoteDateIndexBackfilled())) {
const legacy = await rtdb.ref("/userVotes").get();
if (legacy.exists()) {
const all = legacy.val() as Record<string, Record<string, DayVotes>>;
for (const uid of Object.keys(all)) {
const day = all[uid]?.[date];
// 인덱스 값이 우선 — 원본은 인덱스에 없는 uid를 채우는 용도로만 쓴다.
if (day && !(uid in byUid)) {
byUid[uid] = day;
mergedFromLegacy += 1;
}
}
}
if (mergedFromLegacy > 0) {
logger.warn(
`dailyArchive: ${date} — 인덱스에 없는 ${mergedFromLegacy}명을 원본에서 보충했다. ` +
"백필 스크립트(npm run backfill:vote-index -- --apply) 실행 권장"
);
}
}
if (Object.keys(byUid).length === 0) {
logger.info("no userVotes to archive");
return { date, archived: 0, judgedUids: [] };
}
for (const uid of Object.keys(byUid)) {
let dayVotes = byUid[uid];
if (!dayVotes) continue;
const hasUnjudged = Object.values(dayVotes).some(
(v) => v.result === undefined && !v.cancelled
);
if (hasUnjudged) {
dayVotes = await reconcileDayVotes(uid, date, dayVotes, gameCache, healedGames);
}
const data: VoteHistoryDoc["data"] = [];
let allJudged = true;
for (const [gameId, vote] of Object.entries(dayVotes)) {
// 취소 무효표: 판정 계산에는 안 들어가지만 참여 흔적으로 기록에 남긴다.
if (vote.cancelled) {
data.push({ gameId, team: vote.team, cancelled: true });
continue;
}
if (vote.result === undefined) {
allJudged = false;
break;
}
data.push({ gameId, team: vote.team, result: vote.result });
}
if (!allJudged) {
logger.warn(`skip uid=${uid}: not all games judged for ${date}`);
continue;
}
// 리컨실 결과 모든 경기가 취소 무효 처리된 경우에도 skip 판정은 남겨
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
// 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다.
// 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존).
// user 문서는 여기서 1회만 읽어 rank 계산과 judgeDay가 공유한다.
// (판정 트랜잭션 내부의 tx.get은 원자성상 필수라 남는다.)
// 읽기 실패는 undefined로 남긴다 — null을 넘기면 judgeDay가 "유저 문서 없음"
// 으로 해석해 결석 판정을 건너뛴다. undefined면 judgeDay가 스스로 다시 읽는다.
let user: User | null | undefined;
try {
user = await getUser(uid);
} catch (err) {
logger.error(`getUser failed uid=${uid} — judgeDay가 재조회한다`, err);
}
let rankSnapshot: RankSnapshot | null = null;
try {
rankSnapshot = await computeRankSnapshot(uid, date, {
...(user !== undefined ? { user } : {}),
...(standings ? { standings } : {}),
});
} catch (err) {
logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err);
}
// voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략.
// judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다.
try {
await judgeDay(uid, date, { data }, {
gameCache,
rankSnapshot,
...(user !== undefined ? { userPre: user } : {}),
});
} catch (err) {
logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
// 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문.
await setDay(uid, date, { data }).catch(() => undefined);
}
// 원본과 날짜별 미러를 함께 정리한다.
await rtdb.ref().update({
[`/userVotes/${uid}/${date}`]: null,
[`/userVotesByDate/${date}/${uid}`]: null,
});
await invalidateStats(uid).catch(() => undefined);
judgedUids.push(uid);
archived += 1;
}
logger.info(
`dailyArchive done: ${archived} users archived for ${date}` +
(mergedFromLegacy > 0 ? ` (원본 보충 ${mergedFromLegacy}명)` : "")
);
return { date, archived, judgedUids };
} finally {
// 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤
// 정산·리셋하고, 이어지는 캐시 재계산이 리셋된(새 시즌) 보드를 만든다.
// 정산 실패가 캐시 재계산을 막지 않도록 각각 독립적으로 감싼다.
try {
await maybeSettleSeason(todayKst());
} catch (err) {
logger.error("maybeSettleSeason failed", err);
}
try {
await precomputeScoreboardCache(todayKst());
} catch (err) {
logger.error("precomputeScoreboardCache failed", err);
}
}
}
export const dailyArchive = onSchedule(
{ schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
async () => {
await runDailyArchive();
}
);