Day 44 of 100·Project Deep-Dive Day·1040 words

Traverse's CacheService — keys, TTLs, and invalidation wiring

Every cache key family, who invalidates it, and the worker-side wiring that keeps it honest.

Know your own system

The complete cache map (this is the kind of inventory that makes answers concrete):

Key familyTTLWritten byInvalidated by
user:{id}default 3600profile readsuser updates
user:{id}:stats600stats readssubmission/revision workers
user:profile:{username}3600public profileuser updates
submission:{id}600detail readssubmission worker (10-key batch)
submissions:{userId}:{filters}300list readssubmission worker
submissions:{userId}:stats600statssubmission worker
solve:{id}, solves:{userId}:*—solve readsworker invalidation
achievements:{id} / achievements:list—shelf readsaward achievements
problem:{platform}:{slug}—find-or-createset-once topic writes
streak:{id}, friend-streaks:{id}—streak readsstreak upserts
revisions:{id}, :stats, :analytics:v2, :score:v1120–300revision readsrevision service (4 keys + delPattern revisions:{id}:*)
groq:top_models35 daysmonthly cron refreshoverwrite on refresh

The patterns encode the entity:variant convention — entity:id:facet — which is what makes delPattern surgical (one user's data, one revision's facets) instead of global flushes.

The invalidation choreography (the part that shows engineering): derived state is computed in workers (achievements, streaks, revision scheduling), so invalidation runs in the worker, after the DB writes — invalidateSubmissionCaches fires 10 deletions in Promise.allSettled (one slow/failed del can't fail the job), then the next read repopulates from the new truth. Bulk import bypasses the queue and must call its own invalidation — and does, plus bulkInitializeMLRevisions inline (a documented exception to the worker pattern, and the reason: import is a one-shot admin flow, not user traffic).

Redis deployment facts (docker-compose.yml): redis:7-alpine, --appendonly yes (AOF persistence — cache contents survive restart, which mainly matters for the queue keys sharing the instance), --maxmemory 512mb, --maxmemory-policy noeviction. The backend container also runs an embedded redis-server --daemonize via start.sh — a documented duplication (embedded instance for… history reasons; the external one is the real one; host cron check-redis-memory.sh alerts at 80%). Say it as the honest wart it is: "one instance is the real one; the embedded one is a startup-order fossil I'd remove."

The noeviction decision (preview of Day 46): with cache keys + BullMQ keys sharing one 512 MB instance, eviction would delete jobs — so the policy is noeviction: hitting the cap fails writes with OOM, which the alerting catches, rather than silently dropping queued work. The trade: cache keys can't be evicted under memory pressure either — the correct production split is two instances (cache: allkeys-lru; queues: noeviction, maybe persistent). Know the trade as a stated decision.

The three cache bugs this key design prevents

A cache map is only worth writing down if you can say what it stops. Three concrete failure classes:

1. Stale derived state after a write. The classic bug: a worker updates Solve rows and the user's streak, but streak:{id} and submissions:{userId}:stats still hold pre-write values for their whole TTL. The user sees a wrong streak for ten minutes after solving a problem — and in a habit product a wrong streak is a trust bug, not a display bug. The fix is the choreography above: the writer invalidates, in the same worker that committed the truth, not the reader. Cache-aside plus write-time invalidation, with the TTL as a safety net rather than the primary mechanism.

2. The thundering herd on a cold or freshly invalidated key. Invalidation is itself the trigger: when revisions:{id}:* is deleted, the next N concurrent requests all miss and all recompute. At your scale N is small. The fix when it is not: a short-lived recompute lock, or letting one caller compute while others wait briefly — or simply accepting the recompute, because these are cheap indexed queries. The point is to know which regime you are in. Add stampede protection after measuring a stampede, not before.

3. The negative-cache question. What do you cache for a miss? If user:profile:{username} is queried for a username that does not exist, an uncached miss means every request hits the database — a cheap way for anyone to generate load through a valid-looking endpoint. A short-lived negative entry (30–60 s) fixes the amplification, at the cost of a newly created user being invisible for up to a minute. In Traverse the honest answer is that public profile lookups are not yet a hot abuse surface, so the negative cache is deferred — but knowing the trade exists is the difference between "I did not think about it" and "I decided it was not yet worth it."

Why the key naming is a correctness contract, not cosmetics

delPattern("revisions:{id}:*") works only because the entity id is a fixed, early segment of every variant key. Put the variant first — revisions:stats:{id} — and the wildcard silently stops matching one of the four keys. The invalidation "succeeds", the tests pass, and one surface goes stale forever. This is why the entity:id:facet convention matters: the key namespace is the invalidation API. The same reasoning governs keyspace-notification subscribers and any prefix-based invalidation anywhere. Changing a key's shape is changing an interface, and it deserves the same review attention as the queries that read it.

The two warts, and exactly how to fix them

The embedded redis-server. The backend container starts an embedded instance via start.sh while the real one runs as its own compose service. Two Redis instances on one host means two possible truths for anything that lands in the wrong one, and a memory alert (check-redis-memory.sh, 80%) that watches only one of them. The fix is to delete the embedded start entirely and make the app fail loudly if REDIS_URL is unreachable — a startup dependency check, not a silent fallback. Silent fallbacks are how you get a cache that works in development and splits state in production.

noeviction on a shared instance. Correct for queue safety (an evicted job is a lost side effect) and wrong for cache elasticity (a cache should evict, not error). The fix is the standard production split: two instances — allkeys-lru for cache, noeviction plus persistence for queues. State it as the known-correct answer and as a deliberate cost decision: one instance is cheaper to operate than two, and you accepted that the cache cannot absorb memory pressure in exchange.

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Why invalidate from the workers rather than the API after writes?

Because the derived data the cache serves is computed in the workers — achievements, streaks, revision stats change after the queue processes, not at write time. If the API invalidated on write, the cache would refill with the OLD derived state before the worker updated the DB (a stale-forever window until TTL). Worker-side invalidation after the DB write keeps the sequence: truth → invalidate → repopulate. The general rule: invalidate where the mutation lands.

02

Why allSettled for the cache invalidation batch?

With Promise.all, one slow or failed Redis DEL rejects the whole batch — the job retries (it's idempotent, so no harm, but the worker churns) and some deletions may have succeeded, making failure state partial and confusing. allSettled fires all ten, reports outcomes individually, and the job succeeds; a missed deletion self-heals via TTL. Correctness never depends on the invalidation succeeding — TTLs are the backstop — so paying a bounded stale window is better than coupling job success to cache hygiene.

03

What breaks in your cache design at 10 replicas?

Nothing in correctness (DB-first writes, TTL backstops), but two capacity issues: (1) 10× cache-key duplication — each process's local assumptions about hot keys matter less when they share one Redis, fine; (2) the real one — Redis itself: one 512mb noeviction instance shared by cache + queues becomes the throughput and memory ceiling; the split is cache instance (allkeys-lru) + queue instance (noeviction, AOF) + optionally cluster mode (the code already supports REDIS_CLUSTER_NODES). The design has the doors; opening them is config, not code.