Skip to content

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

Description

@priyanshu1976

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions