Harden KBO cache invalidation against batch limit

- schedule_day__ 일괄 무효화 제거 — dayTtlMs가 완료된 과거 경기일에 7일 TTL을 주는데 매일 밤 지워 무의미하게 만들고 있었다. 동일한 동적 TTL을 가진 game_detail__를 이미 제외하던 논리를 그대로 적용해 자연 만료에 위임한다
- 남은 rank__ 무효화를 400건 단위로 청킹 — 단일 batch는 500 op 하드 상한에서 INVALID_ARGUMENT로 던지므로, 캐시 문서가 늘어난 어느 날 잡 전체가 조용히 죽을 수 있었다
- 삭제 쿼리에 .select() 적용해 ref만 전송(문서 본문 불필요)
- 무효화를 try 블록 안으로 이동 — 기존에는 바깥이라 실패 시 rank 갱신·syncGamesForMonth·forceSyncDay가 전부 실행되지 않았다
This commit is contained in:
윤정민 2026-07-27 13:26:51 +09:00
parent 513bf70e87
commit ca5c4c69b9

View File

@ -13,15 +13,26 @@ import { daysAgoKst } from "../types/dateString";
const CACHE_COLLECTION = "kboCache"; const CACHE_COLLECTION = "kboCache";
async function invalidateByPrefix(prefix: string): Promise<void> { /** batch 하드 상한(500)보다 낮게 잡아 커밋이 터지지 않게 한다. */
const DELETE_CHUNK = 400;
async function invalidateByPrefix(prefix: string): Promise<number> {
const snap = await firestore const snap = await firestore
.collection(CACHE_COLLECTION) .collection(CACHE_COLLECTION)
.where("__name__", ">=", prefix) .where("__name__", ">=", prefix)
.where("__name__", "<", prefix + "\uf8ff") .where("__name__", "<", prefix + "\uf8ff")
// 삭제에는 ref만 필요하다 — 문서 본문 전송을 막는다.
.select()
.get(); .get();
// 단일 batch는 500 op에서 INVALID_ARGUMENT로 던진다. 청킹하지 않으면
// 캐시 문서가 늘어난 어느 날 이 잡 전체가 조용히 죽는다.
for (let i = 0; i < snap.docs.length; i += DELETE_CHUNK) {
const batch = firestore.batch(); const batch = firestore.batch();
snap.docs.forEach((d) => batch.delete(d.ref)); for (const d of snap.docs.slice(i, i + DELETE_CHUNK)) batch.delete(d.ref);
if (!snap.empty) await batch.commit(); await batch.commit();
}
return snap.size;
} }
export const kboDailyRefresh = onSchedule( export const kboDailyRefresh = onSchedule(
@ -33,13 +44,15 @@ export const kboDailyRefresh = onSchedule(
logger.info(`KBO refresh start: ${year}-${month}`); logger.info(`KBO refresh start: ${year}-${month}`);
await invalidateByPrefix("rank__"); // game_detail__와 schedule_day__는 응답 기반 동적 TTL(종료 7d, 라이브 30s 등)을
await invalidateByPrefix("schedule_day__"); // 이미 갖고 있어 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제하면 직후 조회가
// game_detail__는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖고 있어 // 한꺼번에 미스나 외부 재조회 폭주를 부르므로 TTL 자연 만료에 위임한다.
// 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제 시 직후 상세 조회가 한꺼번에 미스나 // (schedule_day__의 TTL은 dayTtlMs가 부여한다 — 완료된 과거 경기일은 7일이라
// 외부 재조회 폭주를 유발하므로, TTL 자연 만료에 위임한다. // 매일 밤 지우면 그 TTL이 통째로 무의미해진다.)
try { try {
const rankPurged = await invalidateByPrefix("rank__");
logger.info(`invalidated ${rankPurged} rank cache docs`);
await fetchRankFromKbo([year]); await fetchRankFromKbo([year]);
await fetchScheduleFromKbo({ year, month }); await fetchScheduleFromKbo({ year, month });