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