Fold rank snapshot into the daily judgment transaction (W4)

dailyArchive wrote users/{uid} twice per cron run: once for rankSnapshot
and once for the judgment. Extract the read-only computeRankSnapshot, then
pass the pre-judgment snapshot into judgeDay so applyDailyJudgmentTx merges
it into the same patch as the judgment write. The snapshot is computed from
current (pre-judgment) tierPoints before the transaction, preserving the
"rank BEFORE judgment" semantics; on the idempotent guard-skip path no
write occurs (and stale re-snapshotting is avoided). snapshotRankForUser is
kept as a thin wrapper for its existing callers/tests.
This commit is contained in:
윤정민 2026-05-28 17:29:14 +09:00
parent 6fc00b2952
commit 38a2f78647
4 changed files with 35 additions and 13 deletions

View File

@ -191,6 +191,12 @@ export async function applyDailyJudgmentTx(
* . .
*/
streakBrokenIn: boolean;
/**
* (= tierPoints ) rank . patch에
* `rankSnapshot` write를 . "판정 전 rank"
* .
*/
rankSnapshot?: RankSnapshot;
computePoints: (streakAfter: number) => number;
}
): Promise<DailyJudgmentResult> {
@ -241,6 +247,8 @@ export async function applyDailyJudgmentTx(
},
lastJudgedDate: date,
};
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot;
tx.set(ref, patch, { merge: true });
return {

View File

@ -6,13 +6,13 @@ import { invalidateStats } from "../services/statsService";
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService";
import {
precomputeScoreboardCache,
snapshotRankForUser,
computeRankSnapshot,
} from "../services/rankSnapshotService";
import { todayKst } from "../types/dateString";
import { getGame, createGameDayCache } from "../repositories/gameRepository";
import { deleteUserVoteGame } from "../repositories/voteRepository";
import { processGameEndWithGame } from "../services/gameResultService";
import type { VoteHistoryDoc } from "../types/panit";
import type { RankSnapshot, VoteHistoryDoc } from "../types/panit";
import {
dayOfWeekKst,
daysAgoKst,
@ -131,16 +131,18 @@ export async function runDailyArchive(
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
// 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함.
// 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다.
// 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존).
let rankSnapshot: RankSnapshot | null = null;
try {
await snapshotRankForUser(uid, date);
rankSnapshot = await computeRankSnapshot(uid, date);
} catch (err) {
logger.error(`snapshotRank failed uid=${uid} date=${date}`, err);
logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err);
}
// voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략.
// judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다.
try {
await judgeDay(uid, date, { data }, gameCache);
await judgeDay(uid, date, { data }, { gameCache, rankSnapshot });
} catch (err) {
logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
// 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문.

View File

@ -14,7 +14,7 @@ import {
streakBonus,
thresholdsFor,
} from "../constants/judgment";
import type { DailyJudgment, VoteHistoryDoc } from "../types/panit";
import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit";
import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString";
/**
@ -54,14 +54,16 @@ export async function hasMissedGameDayBetween(
* @param uid - ID
* @param date - (KST)
* @param voteDoc - voteHistory ( )
* @param opts.gameCache - games read를
* @param opts.rankSnapshot - rank . .
*/
export async function judgeDay(
uid: string,
date: DateString,
voteDoc: VoteHistoryDoc,
gameCache?: GameDayCache
opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null }
): Promise<void> {
const fetch = gameCache ?? createGameDayCache();
const fetch = opts?.gameCache ?? createGameDayCache();
const games = await fetch.listByDate(date);
const completedCount = games.filter((g) => g.status === "completed").length;
const threshold = thresholdsFor(completedCount);
@ -90,6 +92,7 @@ export async function judgeDay(
correctCount,
completedCount,
streakBrokenIn,
rankSnapshot: opts?.rankSnapshot ?? undefined,
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
});

View File

@ -23,14 +23,14 @@ import type {
*
* `tierPoints === 0` .
*/
export async function snapshotRankForUser(
export async function computeRankSnapshot(
uid: string,
date: DateString
): Promise<void> {
): Promise<RankSnapshot | null> {
const user = await getUser(uid);
if (!user) return;
if (!user) return null;
const tierPoints = user.tierPoints ?? 0;
if (tierPoints <= 0) return;
if (tierPoints <= 0) return null;
const overallAbove = await countUsersAboveTierPoints(tierPoints);
const snapshot: RankSnapshot = {
@ -47,6 +47,15 @@ export async function snapshotRankForUser(
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)