From c3fe153a98f00c9c46f1286a43f0ebfc319b72b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 28 May 2026 17:23:33 +0900 Subject: [PATCH] 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. --- src/scheduled/dailyArchive.ts | 7 +++++-- src/services/judgmentService.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 3973036..cd3ad62 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -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; diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index 66f0dbc..a03a9f4 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -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; }