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