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.
This commit is contained in:
윤정민 2026-05-28 17:25:45 +09:00
parent ee3badb112
commit 6fc00b2952
3 changed files with 21 additions and 7 deletions

View File

@ -110,6 +110,17 @@ async function releaseLock(key: string): Promise<void> {
await firestore.collection(LOCK_COLLECTION).doc(key).delete().catch(() => undefined);
}
/**
* (ms) .
*
* `attempt`(0) `baseMs * 2^attempt` `maxMs` .
* (500ms)
* read (: 25초 ~50 ~9).
*/
export function backoffDelayMs(attempt: number, baseMs = 300, maxMs = 5_000): number {
return Math.min(maxMs, baseMs * 2 ** attempt);
}
/**
* .
*
@ -122,8 +133,8 @@ async function releaseLock(key: string): Promise<void> {
*/
async function waitForCache<T>(key: string, ttlMs: number, timeoutMs = 25_000): Promise<T | null> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, 500));
for (let attempt = 0; Date.now() - start < timeoutMs; attempt++) {
await new Promise((r) => setTimeout(r, backoffDelayMs(attempt)));
const cached = await getCached<T>(key, ttlMs);
if (cached !== null) return cached;
}

View File

@ -24,6 +24,7 @@ import {
setCached,
acquireLock,
releaseLock,
backoffDelayMs,
} from "./kboCacheRepository";
import { firestore } from "../firebase";
import { getGameList } from "../services/gameListService";
@ -394,10 +395,10 @@ async function fetchScheduleMonth(
await releaseLock(lockKey);
}
} else {
// 다른 요청이 fetch 중. 짧게 polling.
// 다른 요청이 fetch 중. 지수 백오프로 polling(대기 중 read 폭증 완화).
const start = Date.now();
while (Date.now() - start < 25_000) {
await new Promise((r) => setTimeout(r, 500));
for (let attempt = 0; Date.now() - start < 25_000; attempt++) {
await new Promise((r) => setTimeout(r, backoffDelayMs(attempt)));
cached = await readDayDocs(keys);
missing = allDays.filter((_, i) => cached.get(keys[i]) === null);
if (missing.length === 0) break;

View File

@ -9,6 +9,7 @@ import {
setCached,
acquireLock,
releaseLock,
backoffDelayMs,
} from "../repositories/kboCacheRepository";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
@ -115,8 +116,9 @@ async function getOrFetchDynamic<T>(
const locked = await acquireLock(key);
if (!locked) {
for (let i = 0; i < 50; i++) {
await new Promise((r) => setTimeout(r, 500));
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;
}