Index user votes by date for daily archive

- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
This commit is contained in:
윤정민 2026-07-27 13:26:15 +09:00
parent ed573a81e0
commit eb44f50886
6 changed files with 240 additions and 4 deletions

View File

@ -15,6 +15,14 @@
".write": false ".write": false
} }
}, },
"userVotesByDate": {
".read": false,
".write": false
},
"userVotesByDateMeta": {
".read": false,
".write": false
},
"cache": { "cache": {
".read": false, ".read": false,
".write": false ".write": false

View File

@ -14,7 +14,8 @@
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts", "tools:sheet": "npx tsx scripts/chat-tools-sheet.ts",
"seed:rewards": "npx tsx scripts/seed-reward-products.ts", "seed:rewards": "npx tsx scripts/seed-reward-products.ts",
"upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts", "upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts",
"optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts" "optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts",
"backfill:vote-index": "npx tsx scripts/backfill-user-votes-by-date.ts"
}, },
"engines": { "engines": {
"node": "24" "node": "24"

View File

@ -0,0 +1,85 @@
/**
* `/userVotes/{uid}/{date}/{gameId}` `/userVotesByDate/{date}/{uid}/{gameId}` .
*
* `dailyArchive` ( `/userVotes`
* ).
* .
*
* .
*
* :
* npx tsx scripts/backfill-user-votes-by-date.ts #
* npx tsx scripts/backfill-user-votes-by-date.ts --apply #
*/
import "./_bootstrap";
import { rtdb } from "../src/firebase";
type VoteEntry = { team: string; result?: boolean; cancelled?: boolean };
type ByUid = Record<string, Record<string, Record<string, VoteEntry>>>;
/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */
const CHUNK = 500;
async function main(): Promise<void> {
const apply = process.argv.includes("--apply");
const snap = await rtdb.ref("/userVotes").get();
if (!snap.exists()) {
console.log("no /userVotes data — nothing to backfill");
if (apply) await markBackfilled();
return;
}
const byUid = snap.val() as ByUid;
const updates: Record<string, unknown> = {};
let uidCount = 0;
let entryCount = 0;
const dates = new Set<string>();
for (const uid of Object.keys(byUid)) {
const byDate = byUid[uid] ?? {};
let touched = false;
for (const date of Object.keys(byDate)) {
const games = byDate[date] ?? {};
for (const gameId of Object.keys(games)) {
updates[`/userVotesByDate/${date}/${uid}/${gameId}`] = games[gameId];
entryCount += 1;
dates.add(date);
touched = true;
}
}
if (touched) uidCount += 1;
}
const paths = Object.keys(updates);
console.log(
`${entryCount} entries / ${uidCount} uids / ${dates.size} dates` +
(apply ? "" : " (dry run — pass --apply to write)")
);
if (!apply) return;
for (let i = 0; i < paths.length; i += CHUNK) {
const slice: Record<string, unknown> = {};
for (const p of paths.slice(i, i + CHUNK)) slice[p] = updates[p];
await rtdb.ref().update(slice);
console.log(` written ${Math.min(i + CHUNK, paths.length)}/${paths.length}`);
}
await markBackfilled();
console.log("backfill complete");
}
/**
* . `dailyArchive`
* "그날 투표가 없었다" .
*/
async function markBackfilled(): Promise<void> {
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
console.log("marked /userVotesByDateMeta/backfilledAt");
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -66,6 +66,7 @@ export async function submitVote(params: {
updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1);
updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/votes/${gameId}/users/${uid}`] = { team };
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team };
updates[byDatePath(uid, date, gameId)] = { team };
await rtdb.ref().update(updates); await rtdb.ref().update(updates);
} }
@ -93,9 +94,47 @@ export async function changeVote(params: {
updates[`/votes/${gameId}/counts/${newSide}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/counts/${newSide}Count`] = ServerValue.increment(1);
updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam }; updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam };
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam };
updates[byDatePath(uid, date, gameId)] = { team: newTeam };
await rtdb.ref().update(updates); await rtdb.ref().update(updates);
} }
/**
* `/userVotes/{uid}/{date}` (date, uid) .
*
* RTDB에는 `dailyArchive`
* `/userVotes` ****( × ) .
* fan-out `/userVotesByDate/{date}` .
*
* ( ). RTDB read/write false.
*/
function byDatePath(uid: string, date: DateString, gameId: string): string {
return `/userVotesByDate/${date}/${uid}/${gameId}`;
}
/**
* ( ).
*
* @returns `{ [uid]: { [gameId]: VoteEntry } }`. .
*/
export async function getVotesByDate(
date: DateString
): Promise<Record<string, Record<string, VoteEntry>>> {
const snap = await rtdb.ref(`/userVotesByDate/${date}`).get();
return snap.exists() ? (snap.val() as Record<string, Record<string, VoteEntry>>) : {};
}
/**
* .
*
* ,
* "그날 투표가 없었다" .
* `dailyArchive` .
*/
export async function isVoteDateIndexBackfilled(): Promise<boolean> {
const snap = await rtdb.ref("/userVotesByDateMeta/backfilledAt").get();
return snap.exists();
}
/** /**
* . * .
* *
@ -127,7 +166,13 @@ export async function deleteGameVotes(gameId: string): Promise<void> {
* . * .
*/ */
export async function deleteUserVoteIndex(uid: string): Promise<void> { export async function deleteUserVoteIndex(uid: string): Promise<void> {
await rtdb.ref(`/userVotes/${uid}`).remove(); // 날짜별 미러도 함께 지운다. 어느 날짜에 기록이 있는지는 원본에만 있으므로
// 먼저 읽어서 해당 날짜들만 정리한다(유저 1명분이라 작다).
const snap = await rtdb.ref(`/userVotes/${uid}`).get();
const dates = snap.exists() ? Object.keys(snap.val() as Record<string, unknown>) : [];
const updates: Record<string, unknown> = { [`/userVotes/${uid}`]: null };
for (const date of dates) updates[`/userVotesByDate/${date}/${uid}`] = null;
await rtdb.ref().update(updates);
} }
/** /**
@ -139,5 +184,8 @@ export async function deleteUserVoteGame(
date: DateString, date: DateString,
gameId: string gameId: string
): Promise<void> { ): Promise<void> {
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove(); await rtdb.ref().update({
[`/userVotes/${uid}/${date}/${gameId}`]: null,
[byDatePath(uid, date, gameId)]: null,
});
} }

View File

@ -2,7 +2,7 @@ import { FieldValue } from "firebase-admin/firestore";
import { logger } from "firebase-functions"; import { logger } from "firebase-functions";
import { firestore, rtdb } from "../firebase"; import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { getGame } from "../repositories/gameRepository"; import { getGame, invalidateGameDay } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService"; import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString"; import { fromTimestamp } from "../types/dateString";
@ -36,6 +36,9 @@ export async function processGameEndWithGame(
: voted === game.winningTeamCode; : voted === game.winningTeamCode;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
// 날짜별 미러도 같은 원자 update에 포함 — 아카이브가 이쪽을 읽는다.
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/result`] = result;
} }
if (Object.keys(rtdbUpdates).length > 0) { if (Object.keys(rtdbUpdates).length > 0) {
await rtdb.ref().update(rtdbUpdates); await rtdb.ref().update(rtdbUpdates);
@ -71,5 +74,8 @@ export async function markGameEnded(
winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode, winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
endedAt: FieldValue.serverTimestamp(), endedAt: FieldValue.serverTimestamp(),
}); });
// 스코어·상태가 바뀌었으므로 해당 날짜의 games 캐시를 즉시 버린다.
const time = (snap.data() as { time?: { toDate(): Date } }).time;
if (time) invalidateGameDay(fromTimestamp(time));
return { ok: true, gameId }; return { ok: true, gameId };
} }

View File

@ -7,6 +7,10 @@ import {
getCounts, getCounts,
getUserDateVotes, getUserDateVotes,
getUserVote, getUserVote,
getVotesByDate,
isVoteDateIndexBackfilled,
deleteUserVoteGame,
deleteUserVoteIndex,
submitVote, submitVote,
} from "../../src/repositories/voteRepository"; } from "../../src/repositories/voteRepository";
import type { DateString } from "../../src/types/dateString"; import type { DateString } from "../../src/types/dateString";
@ -175,3 +179,87 @@ describe("voteRepository (RTDB)", () => {
}); });
}); });
}); });
/**
* `/userVotes` (date, uid) . dailyArchive가
* , .
*/
describe("voteRepository — /userVotesByDate 미러", () => {
beforeEach(async () => {
await rtdb.ref("/votes").remove();
await rtdb.ref("/userVotes").remove();
await rtdb.ref("/userVotesByDate").remove();
});
it("submitVote가 날짜별 인덱스에도 기록한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
const byDate = await getVotesByDate(date);
expect(byDate[uid]?.[gameId]).toEqual({ team: "LG" });
});
it("여러 유저의 같은 날짜 투표가 한 노드에 모인다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" });
await submitVote({ gameId: gameId2, uid, date, side: "draw", team: "DRAW" });
const byDate = await getVotesByDate(date);
expect(Object.keys(byDate).sort()).toEqual([uid, uid2].sort());
expect(Object.keys(byDate[uid])).toHaveLength(2);
});
it("changeVote가 미러의 팀도 함께 바꾼다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await changeVote({
gameId, uid, date, oldSide: "home", newSide: "away", newTeam: "KIA",
});
const byDate = await getVotesByDate(date);
expect(byDate[uid][gameId]).toEqual({ team: "KIA" });
});
it("deleteUserVoteGame이 양쪽에서 제거한다", async () => {
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId: gameId2, uid, date, side: "away", team: "KIA" });
await deleteUserVoteGame(uid, date, gameId);
expect(await getUserDateVotes(uid, date)).toEqual({ [gameId2]: { team: "KIA" } });
const byDate = await getVotesByDate(date);
expect(byDate[uid]).toEqual({ [gameId2]: { team: "KIA" } });
});
it("deleteUserVoteIndex가 그 유저의 모든 날짜 미러를 지운다", async () => {
const other = "2026-04-13" as DateString;
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
await submitVote({ gameId: gameId2, uid, date: other, side: "away", team: "KIA" });
await submitVote({ gameId, uid: uid2, date, side: "home", team: "LG" });
await deleteUserVoteIndex(uid);
expect(await getUserDateVotes(uid, date)).toEqual({});
expect((await getVotesByDate(date))[uid]).toBeUndefined();
expect((await getVotesByDate(other))[uid]).toBeUndefined();
// 다른 유저 기록은 남아 있어야 한다
expect((await getVotesByDate(date))[uid2]).toEqual({ [gameId]: { team: "LG" } });
});
it("투표가 없는 날짜는 빈 객체를 돌려준다", async () => {
expect(await getVotesByDate("2026-01-01" as DateString)).toEqual({});
});
});
describe("voteRepository — 백필 완료 표시", () => {
beforeEach(async () => {
await rtdb.ref("/userVotesByDateMeta").remove();
});
it("표시가 없으면 false", async () => {
expect(await isVoteDateIndexBackfilled()).toBe(false);
});
it("표시가 있으면 true — dailyArchive가 레거시 폴백을 건너뛰는 근거", async () => {
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
expect(await isVoteDateIndexBackfilled()).toBe(true);
});
});