Caching
The system has six distinct caching layers. The first four are stacked in series for leaderboard reads.
Layer 1 — Memory LRU
File: lib/leaderboard/request-cache.ts
| Property | Value |
|---|---|
| Entries | 64 |
| TTL | 30 seconds |
| Scope | Per-function-instance (not shared across Vercel instances) |
Sub-millisecond lookup. If an entry is stale but present, the stale value is returned immediately and a background refresh is queued via after(). This prevents cache stampede when many users hit the same leaderboard simultaneously.
Layer 2 — Redis response cache
File: lib/leaderboard/request-cache.ts
| Property | Value |
|---|---|
| Key pattern | leaderboard:response:{cacheKey} |
| TTL | 24 hours |
Cross-instance shared. When Layer 1 misses, this layer is checked. A hit here means no database or GitHub API access. Serialised API responses are cached whole.
Graceful degradation: if Redis is unavailable (network error, missing env vars), the system falls through to Layer 3/4. Redis being down does not cause 500 errors.
Layer 3 — Redis snapshot cache
File: lib/leaderboard/redis-cache.ts
| Property | Value |
|---|---|
| Key pattern | leaderboard:snapshot:{presetId}:{scope}:{entity}:{period} |
| TTL | 24 hours |
More granular than Layer 2. Where Layer 2 caches the assembled HTTP response, Layer 3 caches individual leaderboard snapshots keyed by scoring preset, scope, entity type, and period. This allows partial cache hits — if one preset’s data is fresh but another is stale, each can be served independently.
Layer 4 — computed_scores database table
File: lib/supabase/leaderboard-db.ts
| Property | Value |
|---|---|
| Freshness check | computedAt column vs current time |
| Freshness TTL | 24 hours (CACHE.PRECOMPUTED_SCORES_TTL_MS) |
The persistent cache. Unlike Redis, it survives instance restarts and cold starts. When writing new scores, an advisory Redis lock prevents multiple concurrent ingest jobs from writing duplicate rows.
Freshness is checked against the computedAt timestamp. If older than 24 hours, a fresh ingest is triggered — but only if the 24h cooldown period has elapsed.
Layer 5 — GraphQL response cache
File: lib/github/graphql-response-cache.ts
| Property | Value |
|---|---|
| In-memory | LRU(64) |
| TTL | 90 seconds (most operations), 5 minutes (OrgRepositories) |
Separate from the leaderboard cache stack. Applied to POST /api/github/graphql only. Caches GitHub GraphQL responses keyed by operation + variables. Reduces GitHub API rate limit pressure for frequent queries.
Layer 6 — ETag cache
Table: repository_sync_state
| Property | Value |
|---|---|
| TTL | 24 hours (Redis), persistent in Postgres |
During leaderboard ingest, each repository’s ETags are stored per endpoint (commits_etag, pulls_etag, issues_etag, comments_etag, reviews_etag). On subsequent ingest runs, these ETags are sent as If-None-Match headers to GitHub. If the data hasn’t changed, GitHub returns 304 Not Modified and no payload is transferred.
Additionally, a pr_ids_hash is stored per repo to detect PR list changes without comparing full responses.
This is not a time-based cache. It is conditional GET semantics — an endpoint’s data is considered fresh until GitHub says it changed.
Cache invalidation
| Layer | Invalidation trigger |
|---|---|
| 1 (Memory) | TTL expiry (30s) or function cold start |
| 2 (Redis response) | New ingest writes → pattern delete on leaderboard:response:* |
| 3 (Redis snapshot) | New ingest writes → pattern delete on matching scope |
| 4 (computed_scores) | New materialization row (version increment) supersedes old |
| 5 (GraphQL) | TTL expiry (90s / 5min) |
| 6 (ETag) | GitHub returns 200 instead of 304 |
There is no manual cache flush endpoint exposed by default. ENABLE_DEBUG_ROUTES enables debug routes that expose cache state.
Staleness tolerance
The system is designed to serve slightly stale data rather than block on a slow ingest. When Layer 1 finds a stale-but-present cache entry, it returns the stale value immediately and triggers a background refresh. The user sees last-period’s data, and the next request gets the fresh data.
This only kicks in when the ingest cooldown is active. If data is genuinely stale and the cooldown has elapsed, the request blocks until ingest completes (or times out at maxDuration).