- 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건(신규 파일)
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
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();
|
||
}
|
||
);
|