Skip to content

fix: invalidate leaderboard cache after visibility settings change#1782

Merged
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/leaderboard-cache-invalidation
May 31, 2026
Merged

fix: invalidate leaderboard cache after visibility settings change#1782
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/leaderboard-cache-invalidation

Conversation

@Ridanshi
Copy link
Copy Markdown
Contributor

Closes #1779

Problem

The leaderboard is cached at two levels:

Layer Key TTL
Module-level in-process variable (_memoryCache) n/a 1 hour
Shared Redis/Upstash entry leaderboard:v1 6 hours

PATCH /api/user/settings persists is_public and leaderboard_opt_in changes to the database but does not invalidate either cache layer. A user who opts out of the leaderboard therefore remains visible to other users for up to one hour; a user who opts in remains invisible for the same duration.

Reproduction: change either setting in the dashboard and immediately visit the leaderboard — the old state persists until cache expiry.

Fix

src/lib/metrics-cache.ts — new cacheDelete

export async function cacheDelete(key: string): Promise<void> {
  memoryCache.delete(key);
  const redis = getRedisClient();
  if (!redis) return;
  try { await redis.del(key); } catch { /* best-effort */ }
}

Removes a single key from both the in-process memory Map and Redis/Upstash. Complements the existing invalidateUserMetricsCache (which scans a per-user prefix) for the case where one shared key must be evicted.

src/lib/leaderboard.ts — new clearLeaderboardCache

export async function clearLeaderboardCache(): Promise<void> {
  _memoryCache = null;                        // in-process module-level cache
  await cacheDelete(LEADERBOARD_CACHE_KEY);   // metrics memory + Redis/Upstash
}

Evicts every cache layer so the next leaderboard request must rebuild from the database, reflecting updated preferences immediately.

src/app/api/user/settings/route.ts — call invalidation after relevant PATCHes

const leaderboardEligibilityChanged =
  "is_public" in updates || "leaderboard_opt_in" in updates;

if (leaderboardEligibilityChanged) {
  try {
    await clearLeaderboardCache();
  } catch {
    // best-effort — must not fail the settings response
    console.error("[settings] Failed to invalidate leaderboard cache after visibility change");
  }
}

The check runs only after a successful DB write. It is scoped to the two fields that control leaderboard eligibility; unrelated changes (bio, timezone, etc.) do not trigger a cache bust.

Cache invalidation flow

User toggles is_public / leaderboard_opt_in
  → PATCH /api/user/settings
      → DB write succeeds
      → clearLeaderboardCache()
          → _memoryCache = null                (same process)
          → cacheDelete("leaderboard:v1")
              → memoryCache.delete(key)        (in-process metrics Map)
              → redis.del("leaderboard:v1")    (Redis/Upstash)
      → 200 OK
Next leaderboard request: all cache layers miss → buildLeaderboard() → fresh DB query

Tests — test/leaderboard-cache-invalidation.test.ts (11 new tests)

Category Tests
Cache cleared for is_public → false (regression #1779), → true
Cache cleared for leaderboard_opt_in → false (regression #1779), → true
Both change simultaneously Cleared exactly once
Irrelevant-field changes bio, timezone, weekly_digest_opt_in — cache NOT cleared
Error resilience clearLeaderboardCache throws → PATCH still returns 200
Auth guards 401 → no cache call; 404 → no cache call

All 11 pass. The one pre-existing failure in test/dateUtils.test.ts is a timezone boundary issue unrelated to this change.

Root cause
----------
The leaderboard is stored at two cache layers:
  1. A module-level in-process variable (_memoryCache) with a 1-hour TTL
  2. A shared Redis/Upstash entry (LEADERBOARD_CACHE_KEY) with a 6-hour TTL

PATCH /api/user/settings persisted is_public and leaderboard_opt_in
changes to the database but did not invalidate either cache layer.
A user who opted out of the leaderboard remained visible for up to
one hour; a user who opted in was invisible for the same period.

Fix
---

src/lib/metrics-cache.ts
  - Add cacheDelete(key): removes a single key from both the in-process
    memory Map and Redis/Upstash. Used instead of invalidateUserMetricsCache
    because the leaderboard is keyed by a single shared constant rather
    than a per-user prefix.

src/lib/leaderboard.ts
  - Import cacheDelete.
  - Export clearLeaderboardCache(): sets _memoryCache = null and calls
    cacheDelete(LEADERBOARD_CACHE_KEY). This evicts all cache layers so
    the next leaderboard request must rebuild from the database, reflecting
    the updated preferences immediately.

src/app/api/user/settings/route.ts
  - Import clearLeaderboardCache from leaderboard.ts.
  - After a successful PATCH, check whether updates contains is_public or
    leaderboard_opt_in. If so, call clearLeaderboardCache().
  - The call is wrapped in try/catch: a Redis failure must not prevent
    the settings response from reaching the client.

test/leaderboard-cache-invalidation.test.ts — 11 new tests:
  Cache IS cleared when:
  - is_public → false (regression for Priyanshu-byte-coder#1779)
  - is_public → true
  - leaderboard_opt_in → false (regression for Priyanshu-byte-coder#1779)
  - leaderboard_opt_in → true
  - Both fields change simultaneously (cleared exactly once)

  Cache is NOT cleared when:
  - Only bio changes
  - Only timezone changes
  - Only weekly_digest_opt_in changes

  Error resilience:
  - clearLeaderboardCache throws → PATCH still returns 200
  - Unauthenticated request → 401, no cache cleared
  - Unresolved user → 404, no cache cleared

Closes Priyanshu-byte-coder#1779
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

@Ridanshi is attempting to deploy a commit to the PRIYANSHU DOSHI's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added gssoc26 GSSoC 2026 contribution type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts) labels May 31, 2026
@github-actions
Copy link
Copy Markdown

GSSoC Label Checklist 🏷️

@Priyanshu-byte-coder — please apply the appropriate labels before merging:

Difficulty (pick one):

  • level:beginner — 20 pts
  • level:intermediate — 35 pts
  • level:advanced — 55 pts
  • level:critical — 80 pts

Quality (optional):

  • quality:clean — ×1.2 multiplier
  • quality:exceptional — ×1.5 multiplier

Validation (required to score):

  • gssoc:approved — counts for points
  • gssoc:invalid / gssoc:spam / gssoc:ai-slop — does not score

Type labels (type:*) are auto-detected from files and title. Review and adjust if needed.
Points formula: (difficulty × quality_multiplier) + type_bonus

@Priyanshu-byte-coder Priyanshu-byte-coder added level2 GSSoC Level 2 - Medium complexity (25 points) gssoc:approved GSSoC: PR approved for scoring labels May 31, 2026
@Priyanshu-byte-coder Priyanshu-byte-coder merged commit d83cb7d into Priyanshu-byte-coder:main May 31, 2026
4 of 5 checks passed
@github-actions
Copy link
Copy Markdown

🎉 Merged! Thanks for contributing to DevTrack.

If the project has been useful to you, a ⭐ star on the repo is the easiest way to support it — it helps DevTrack get discovered by more developers.

Keep an eye on open issues for your next contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved GSSoC: PR approved for scoring gssoc26 GSSoC 2026 contribution level2 GSSoC Level 2 - Medium complexity (25 points) type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Leaderboard visibility remains stale after profile visibility or opt-in changes

2 participants