From ca5c4c69b92a6dfae77ffd3ecba196bff8051e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:26:51 +0900 Subject: [PATCH] Harden KBO cache invalidation against batch limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schedule_day__ 일괄 무효화 제거 — dayTtlMs가 완료된 과거 경기일에 7일 TTL을 주는데 매일 밤 지워 무의미하게 만들고 있었다. 동일한 동적 TTL을 가진 game_detail__를 이미 제외하던 논리를 그대로 적용해 자연 만료에 위임한다 - 남은 rank__ 무효화를 400건 단위로 청킹 — 단일 batch는 500 op 하드 상한에서 INVALID_ARGUMENT로 던지므로, 캐시 문서가 늘어난 어느 날 잡 전체가 조용히 죽을 수 있었다 - 삭제 쿼리에 .select() 적용해 ref만 전송(문서 본문 불필요) - 무효화를 try 블록 안으로 이동 — 기존에는 바깥이라 실패 시 rank 갱신·syncGamesForMonth·forceSyncDay가 전부 실행되지 않았다 --- src/scheduled/kboRefresh.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) 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 });