monkeytypegame / monkeytypegame/monkeytype

Bug (backend): cacheWithTTL marks cache fresh before fetch resolves — failed fetch serves stale data for full TTL, no in-flight dedup

Open Beginner friendly
#8,363 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
20.7k
Forks
3.3k
PR merge metrics
No merged PRs in 30d

Description

Did you clear cache before opening an issue?
  • I have cleared my cache
Is there an existing issue for this?
  • I have searched the existing open and closed issues
Does the issue happen when logged in?

N/A

Does the issue happen when logged out?

N/A (backend source bug)

Does the issue happen in incognito mode when logged in?

N/A

Does the issue happen in incognito mode when logged out?

N/A

Issue details

Current Behavior

cacheWithTTL in backend/src/utils/ttl-cache.ts marks the cache as fresh before the fetch resolves:

// backend/src/utils/ttl-cache.ts:20-26
return async () => {
  if (lastFetchTime < Date.now() - ttlMs) {
    lastFetchTime = Date.now(); // updated before await completes
    cache = await fn();
  }
  return cache;
};

Consequences:

  1. Failed fetch poisons the cache for the full TTL. If fn() rejects, the rejection propagates to that caller, but lastFetchTime has already been advanced — every subsequent call within the TTL returns stale cached data instead of retrying. This utility backs the PSA endpoint (controllers/psa.ts), so one failed upstream fetch serves stale content for the whole TTL window.
  2. No in-flight promise dedup. When the TTL expires under concurrent requests, all callers run fn() simultaneously (thundering herd) since nothing records the pending promise.
Expected Behavior

Only advance lastFetchTime after a successful fetch, and dedupe concurrent calls by caching the promise itself:

let lastFetchTime = 0;
let cache: T | undefined;
let inflight: Promise<T> | undefined;

return async () => {
  if (lastFetchTime < Date.now() - ttlMs) {
    inflight ??= fn()
      .then((result) => {
        cache = result;
        lastFetchTime = Date.now();
        return result;
      })
      .finally(() => {
        inflight = undefined;
      });
    return inflight;
  }
  return cache;
};
Steps To Reproduce
  1. Call a cacheWithTTL-wrapped getter whose fn throws.
  2. Call again within the TTL — stale data is returned with no retry until TTL expiry.
Environment
  • Backend, master @ 91bd24bb8

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in backend/src/utils/ttl-cache.ts and review how cacheWithTTL is used by controllers/psa.ts. Verify the behavior for failed fetches and concurrent calls, then update the utility so successful fetches refresh the TTL, failures remain retryable, and concurrent requests share one in-flight fetch. Done means stale data is not served after a failed refresh and the upstream function runs only once for concurrent expired-cache calls.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.