Collapse voteHistory double-write in dailyArchive to one (W2)

dailyArchive wrote the voteHistory doc twice per active user per day:
once data-only before judgeDay, then again with judgment fields inside
judgeDay. Drop the pre-write so judgeDay is the single writer in the
common path. To keep data safety: judgeDay now persists the doc before
userVotes is removed; on the idempotent guard-skip path judgeDay restores
data only if the doc is missing (crash-after-tx recovery); and if judgeDay
throws, dailyArchive still saves data-only before removal, preserving the
prior failure-path behavior. judgment/points/streak results unchanged.
This commit is contained in:
윤정민 2026-05-28 17:23:33 +09:00
parent dc87cda0c3
commit c3fe153a98
2 changed files with 11 additions and 3 deletions

View File

@ -130,19 +130,22 @@ export async function runDailyArchive(
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
await setDay(uid, date, { data });
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
// 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함.
try {
await snapshotRankForUser(uid, date);
} catch (err) {
logger.error(`snapshotRank failed uid=${uid} date=${date}`, err);
}
// voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략.
// judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다.
try {
await judgeDay(uid, date, { data }, gameCache);
} catch (err) {
logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
// 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문.
await setDay(uid, date, { data }).catch(() => undefined);
}
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
await invalidateStats(uid).catch(() => undefined);
judgedUids.push(uid);
archived += 1;

View File

@ -3,7 +3,7 @@ import {
createGameDayCache,
type GameDayCache,
} from "../repositories/gameRepository";
import { getRange, setDay } from "../repositories/voteHistoryRepository";
import { getDay, getRange, setDay } from "../repositories/voteHistoryRepository";
import {
applyDailyJudgmentTx,
applyWeeklyMasterTx,
@ -95,6 +95,11 @@ export async function judgeDay(
if (tx.skippedByGuard) {
logger.info(`judgeDay: ${uid} ${date} already judged, skipping`);
// 복구 경로: 앞선 run에서 트랜잭션은 커밋됐지만(=lastJudgedDate 갱신) voteHistory
// 기록 직전에 크래시한 경우 doc이 비어있을 수 있다. 그 때만 data를 복원한다.
// 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
const existing = await getDay(uid, date);
if (!existing) await setDay(uid, date, voteDoc);
return;
}