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:
parent
ed573a81e0
commit
eb44f50886
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
85
scripts/backfill-user-votes-by-date.ts
Normal file
85
scripts/backfill-user-votes-by-date.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user