mmday-firebase/src/services/rankSnapshotService.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

222 lines
6.9 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 { logger } from "firebase-functions";
import { firestore } from "../firebase";
import {
countRankedUsers,
countUsersAboveTierPoints,
getUser,
listAllRankedUsers,
listTopByTierPoints,
type ScoreboardUserEntry,
} from "../repositories/userRepository";
import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository";
import { tierOf } from "../constants/tiers";
import { computePercentile, deltaFor } from "./scoreboardHelpers";
import type { DateString } from "../types/dateString";
import { TeamCode, type RankSnapshot, type User } from "../types/panit";
import type {
ScoreboardEntry,
ScoreboardScopeCache,
} from "../types/scoreboard";
/**
* run 스코프 순위표 — `tierPoints`만으로 순위를 이진 탐색으로 구한다.
*
* `dailyArchive`처럼 유저 N명을 한 run에서 처리하는 경로에서, 유저마다 1~2회씩
* count aggregation을 발행하던 O(N×M) 비용을 순위표 1회 로드로 대체한다.
*/
export interface RankStandings {
/** 동점자 동일 순위("초과 인원 + 1"). `teamCode` 지정 시 팀 내 순위. */
rankOf(tierPoints: number, teamCode?: TeamCode): number;
}
/** 내림차순 배열에서 `threshold` 초과 원소 개수(= 첫 `<= threshold` 위치). */
function countAbove(sortedDesc: number[], threshold: number): number {
let lo = 0;
let hi = sortedDesc.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sortedDesc[mid] > threshold) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** 랭킹 대상 전원 목록으로 순위표를 만든다. */
export function buildRankStandings(
users: Array<{ tierPoints: number; favoriteTeamCode?: TeamCode }>
): RankStandings {
const overall = users.map((u) => u.tierPoints).sort((a, b) => b - a);
const byTeam = new Map<TeamCode, number[]>();
for (const u of users) {
if (!u.favoriteTeamCode) continue;
const list = byTeam.get(u.favoriteTeamCode);
if (list) list.push(u.tierPoints);
else byTeam.set(u.favoriteTeamCode, [u.tierPoints]);
}
for (const list of byTeam.values()) list.sort((a, b) => b - a);
return {
rankOf(tierPoints: number, teamCode?: TeamCode): number {
const list = teamCode ? byTeam.get(teamCode) ?? [] : overall;
return countAbove(list, tierPoints) + 1;
},
};
}
/** 랭킹 대상 전원을 읽어 순위표를 만든다. run 시작 시 1회만 호출할 것. */
export async function loadRankStandings(): Promise<RankStandings> {
return buildRankStandings(await listAllRankedUsers());
}
/**
* 유저 1명의 현재 `tierPoints` 기반 rank를 계산한다. `dailyArchive`에서
* `judgeDay` **이전에** 호출해 스냅샷이 "직전 상태의 rank"를 담도록 한다.
*
* `tierPoints === 0`이면 스냅샷을 남기지 않는다.
*
* @param opts.user - 호출자가 이미 읽은 user 문서. 넘기면 재조회하지 않는다.
* @param opts.standings - run 스코프 순위표. 넘기면 count aggregation을 쓰지 않는다.
*/
export async function computeRankSnapshot(
uid: string,
date: DateString,
opts?: { user?: User | null; standings?: RankStandings }
): Promise<RankSnapshot | null> {
const user = opts && "user" in opts ? opts.user : await getUser(uid);
if (!user) return null;
const tierPoints = user.tierPoints ?? 0;
if (tierPoints <= 0) return null;
const standings = opts?.standings;
const snapshot: RankSnapshot = {
date,
overall: standings ?
standings.rankOf(tierPoints) :
(await countUsersAboveTierPoints(tierPoints)) + 1,
};
if (user.favoriteTeamCode) {
snapshot.team = standings ?
standings.rankOf(tierPoints, user.favoriteTeamCode) :
(await countUsersAboveTierPoints(tierPoints, user.favoriteTeamCode)) + 1;
snapshot.teamCode = user.favoriteTeamCode;
}
return snapshot;
}
export async function snapshotRankForUser(
uid: string,
date: DateString
): Promise<void> {
const snapshot = await computeRankSnapshot(uid, date);
if (!snapshot) return;
await firestore
.collection("users")
.doc(uid)
.set({ rankSnapshot: snapshot }, { merge: true });
}
/**
* 여러 유저의 rank 스냅샷을 순차 기록. 한 명 실패해도 나머지는 계속.
*/
export async function snapshotRanksForUsers(
uids: string[],
date: DateString
): Promise<void> {
for (const uid of uids) {
try {
await snapshotRankForUser(uid, date);
} catch (err) {
logger.error(`snapshotRank failed for uid=${uid}`, err);
}
}
}
/** 동점자 동일 rank ("above count + 1"). 상위가 1위임을 가정한 in-memory 계산. */
function assignRanks(entries: ScoreboardUserEntry[]): number[] {
const ranks: number[] = [];
for (let i = 0; i < entries.length; i++) {
if (i === 0) {
ranks.push(1);
continue;
}
if (entries[i].tierPoints === entries[i - 1].tierPoints) {
ranks.push(ranks[i - 1]);
} else {
ranks.push(i + 1);
}
}
return ranks;
}
function enrichTop(
entries: ScoreboardUserEntry[],
totalCount: number,
scope: Scope
): ScoreboardEntry[] {
const ranks = assignRanks(entries);
return entries.map((e, i) => {
const rank = ranks[i];
const entry: ScoreboardEntry = {
uid: e.uid,
displayName: e.displayName,
tierPoints: e.tierPoints,
tier: tierOf(e.tierPoints),
rank,
rankDelta: deltaFor(e.rankSnapshot, rank, scope),
percentile: computePercentile(rank, totalCount),
};
if (e.photoUrl) entry.photoUrl = e.photoUrl;
if (e.favoriteTeamCode) entry.favoriteTeamCode = e.favoriteTeamCode;
return entry;
});
}
async function buildScopeCache(scope: Scope): Promise<ScoreboardScopeCache> {
const teamCode = scope.kind === "team" ? scope.teamCode : undefined;
const [top, totalCount] = await Promise.all([
listTopByTierPoints(10, teamCode),
countRankedUsers(teamCode),
]);
return {
top: enrichTop(top, totalCount, scope),
totalCount,
generatedAt: Date.now(),
};
}
/** overall + 10팀 = 전체 11개 스코프. */
export function allScoreboardScopes(): Scope[] {
return [
{ kind: "overall" },
...Object.values(TeamCode).map(
(teamCode) => ({ kind: "team" as const, teamCode })
),
];
}
/**
* overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다.
* `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함.
*
* @param scopes 재계산할 스코프. 한 유저의 변경처럼 영향 범위가 좁을 땐 해당
* 스코프만 넘겨 11개 전체 재계산(~110 read + 11 aggregation)을 피한다.
* 생략 시 전체(크론 경로).
*/
export async function precomputeScoreboardCache(
date: DateString,
scopes: Scope[] = allScoreboardScopes()
): Promise<void> {
for (const scope of scopes) {
try {
const doc = await buildScopeCache(scope);
await writeScope(date, scope, doc);
} catch (err) {
const label =
scope.kind === "overall" ? "overall" : `team/${scope.teamCode}`;
logger.error(`precomputeScoreboardCache failed scope=${label}`, err);
}
}
}