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.
137 lines
4.3 KiB
TypeScript
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);
|
|
}
|
|
}
|