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

View File

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

View File

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

View File

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