Day 28 of 100·Project Deep-Dive Day·535 words

Traverse's indexes and the query-shape fixes — 300→2, 120→4

The performance work in traverse that interviewers can actually verify from your commit history.

Know your own system

Two refactors define traverse's performance story. Both are query-shape fixes (Day 27's thesis), and both are the kind of story that survives cross-examination because the before/after is in git.

Fix 1: the streak walk (O(days) → O(1) round-trips)

Before: calculateCurrentStreak looped findUnique({where: {userId_date: {userId, date: day}}}) backwards from today, one query per day, until it hit a gap. 300-day streak = 300 sequential round-trips per call, and the call ran on every solve (streak recalculation was in the request→worker path). At ~0.5 ms/query that's ~150 ms of pure round-trip latency per solve, scaled by streak length — latency that grows with user loyalty, the worst possible direction.

After (src/lib/streakCalculator.ts): fetch the user's entire StreakDay history in two queries (today + all history), then walk dates in memory checking contiguity. setUTCDate for the day math (not setDate — local-timezone drift bugs), and the same walk now also returns longestStreak (the old API returned a monotonic count labeled "BEST" — a bug where the "record" could only ever grow, never reflect an actual longest run).

The generalization to state: any loop of indexed single-row lookups is a red flag — the fix is one range scan + in-memory walk. Prisma makes it easy to hide (each findUnique looks innocent); the audit technique is to count queries per request in the OTel/Prisma logs, not to eyeball code.

Fix 2: achievements (120 → 4 queries)

Before: awardAchievements checked/upserted per achievement — ~60 conditions, ~2 queries each (check UserAchievement, then upsert), so a big solve batch fired up to ~120 queries.

After: 4 queries regardless of how many awards fire: bulk-read all achievements + all user achievements, evaluate conditions in memory, then one createMany for new awards + one update pass. Bonus correctness fix: the old upsert with an empty update block couldn't distinguish "already had it" from "just earned it" — the refactor returns which awards are new, so notifications fire only on actual unlocks.

The index side (what supports these shapes)

  • StreakDay @@id([userId, date]) — the composite PK is the range scan: all rows for a user are physically clustered by date.
  • Revision indexes on [userId, scheduledFor] (the "due today" query), [userId, completedAt] (history), [userId, type], [modelVersion] (calibration analysis).
  • Notification [userId, coalesceKey, readAt] — designed for coalescing's exact lookup.
  • RingDay @@id([userId, date]) — same clustered-by-day pattern for ring history.
  • The updateMany-as-claim pattern (ring closure count===1, set-once topic where: {topic: null}) is index-friendly by construction: the WHERE clause is the index.

How to tell the story (numbers discipline)

"Two refactors, both round-trip-count fixes, not index fixes: streak recalc went from one query per streak-day (300-day streak = 300 sequential round-trips, growing with user loyalty) to 2 queries total; achievements went from ~2 queries per condition (~120 worst case) to a fixed 4. Neither shows up as 'slow query' in any EXPLAIN — the per-query plans were perfect; the shape was the pathology. That's why I now audit Prisma query counts per request, and why the OTel instrumentation exists: you can't fix what you don't count."

Bulletproof your resume

Say these answers aloud before moving on

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

01

How do you find query-shape problems before they hurt?

Count queries per request, not query duration. Prisma's query logging + the OTel instrumentation give per-request query counts; any number that scales with data size (days, achievements, friends) instead of being constant is a shape bug. The two I fixed — streaks and achievements — both scaled with user history. The audit rule: per-request query count must be O(1) with respect to table size; if it's O(n), you've written a loop that belongs in SQL or one bulk read.

02

Why did longestStreak need the same rewrite?

The old code returned a count that only grew — labeled 'BEST' but actually 'current max ever seen'. A real longest-streak calculation needs the full day set to find the longest contiguous run, which is exactly what the in-memory walk over the bulk-fetched history gives for free. It's a nice example of a query-shape fix unlocking a correctness fix: the data was finally in memory, so computing the true metric was one more loop.

03

What's your rule for when in-memory computation beats pushing logic into SQL?

When the row set is naturally bounded per entity (a user's streak days, their achievements) and the logic is stateful walking (contiguity, run-length) rather than set algebra. Pushing to SQL wins for aggregates over unbounded sets (count-min, group-by across all users). My heuristic: fetch-bounded-set-then-walk for per-entity analytics; SQL aggregation for cross-entity analytics. The streak walk is 300 rows — trivial in memory; a global leaderboard is a SQL GROUP BY, not a fetch-everything walk.