mmday-firebase/src/services/gameDetailService.ts
윤정민 6fc00b2952 Use exponential backoff for cache-stampede polling (R4)
waitForCache, the schedule month-poll loop, and getOrFetchDynamic all
polled Firestore every fixed 500ms (up to ~50 reads) while waiting for a
lock holder to populate the cache. Replace the fixed interval with a
shared backoffDelayMs helper (300ms base, doubling, 5s cap) bounded by the
same ~25s timeout, cutting per-waiter read amplification by roughly 5x.
Timeout/fallback semantics are unchanged.
2026-05-28 17:25:45 +09:00

137 lines
4.3 KiB
TypeScript

import {
fetchGameDetail,
type GameDetail,
type GameDetailFilters,
} from "../kbo/game-detail";
import {
encodeKey,
getCached,
setCached,
acquireLock,
releaseLock,
backoffDelayMs,
} from "../repositories/kboCacheRepository";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const ONE_HOUR_MS = 60 * 60 * 1000;
const TEN_MIN_MS = 10 * 60 * 1000;
const THIRTY_SEC_MS = 30_000;
/**
* 종료된 경기는 데이터 불변 → 7d.
* 라이브 중에는 30s.
* 시작 전이면 시작 시각까지 (최대 1h).
*
* 종료 판정: scoreBoard.meta.endTime이 채워져 있으면 종료.
* scoreBoard가 null이면 KBO가 아직 경기 데이터를 만들지 않은 시작 전 상태 →
* 라인업이 발표 전(`preview-estimated`)이면 곧 바뀔 수 있으니 10분,
* 그 외엔 gameId에서 추정한 KST 18:30 시작 시각까지 캐시.
*/
function gameDetailTtlMs(detail: GameDetail): number {
const sb = detail.scoreBoard;
if (sb?.meta.endTime && sb.meta.endTime.trim() !== "") {
return SEVEN_DAYS_MS;
}
const startTs = sb ?
parseGameStartTs(sb.meta.gameDate, sb.meta.startTime) :
parseGameStartFromGameId(detail.gameId);
if (startTs == null) return THIRTY_SEC_MS;
const now = Date.now();
if (now < startTs) {
// 발표 전 fallback 라인업은 짧게 폴링 — false → true 전환 시점을 빠르게 잡기 위해.
if (detail.lineup?.source === "preview-estimated") {
return Math.min(TEN_MIN_MS, Math.max(startTs - now, THIRTY_SEC_MS));
}
return Math.min(ONE_HOUR_MS, Math.max(startTs - now, THIRTY_SEC_MS));
}
return THIRTY_SEC_MS;
}
function parseGameStartTs(
gameDate: string | undefined,
startTime: string | undefined
): number | null {
const dm = gameDate?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const tm = startTime?.match(/^(\d{1,2}):(\d{2})$/);
if (!dm || !tm) return null;
const [, y, mo, d] = dm;
const [, h, mi] = tm;
// KST(UTC+9) 기준 절대 시각
return Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(h) - 9,
Number(mi)
);
}
// scoreBoard가 없을 때(시작 전) gameId의 날짜만으로 시작 시각을 추정한다.
// 정확한 시각은 모르지만 KBO 정규 경기의 평일 18:30 / 주말 14:00·17:00을
// 일률적으로 18:30 KST로 가정해 캐시 TTL 상한선용으로 쓴다.
function parseGameStartFromGameId(gameId: string): number | null {
const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/);
if (!m) return null;
const [, y, mo, d] = m;
return Date.UTC(Number(y), Number(mo) - 1, Number(d), 18 - 9, 30);
}
export async function getGameDetail(
filters: GameDetailFilters
): Promise<GameDetail> {
if (!/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(filters.gameId)) {
throw new Error(`Invalid gameId: "${filters.gameId}"`);
}
const key = encodeKey(["game_detail", filters.gameId]);
return getOrFetchDynamic<GameDetail>(key, async () => {
const detail = await fetchGameDetail(filters);
const ttlMs = gameDetailTtlMs(detail);
return { value: detail, ttlMs };
});
}
interface DynamicResult<T> {
value: T;
ttlMs: number;
}
/**
* fetcher가 응답을 보고 동적으로 TTL을 결정해야 하는 경우 쓰는 헬퍼.
*
* `kboCacheRepository.getOrFetch`는 호출 시점에 TTL을 받기 때문에 응답 의존
* TTL을 표현할 수 없다. 여기선 캐시 doc의 `ttlMs` 필드(getCached가 우선 사용)에
* 의존하므로 read 시 fallback TTL은 무한대로 두고, write 시 fetcher가 정한 값을 쓴다.
* 락 흐름은 kboCacheRepository와 동일.
*/
async function getOrFetchDynamic<T>(
key: string,
fetcher: () => Promise<DynamicResult<T>>
): Promise<T> {
const cached = await getCached<T>(key, Number.MAX_SAFE_INTEGER);
if (cached !== null) return cached;
const locked = await acquireLock(key);
if (!locked) {
const start = Date.now();
for (let attempt = 0; Date.now() - start < 25_000; attempt++) {
await new Promise((r) => setTimeout(r, backoffDelayMs(attempt)));
const c = await getCached<T>(key, Number.MAX_SAFE_INTEGER);
if (c !== null) return c;
}
const { value } = await fetcher();
return value;
}
try {
const { value, ttlMs } = await fetcher();
await setCached(key, value, ttlMs);
return value;
} finally {
await releaseLock(key);
}
}