Skip to content

Commit dec247e

Browse files
committed
perf(cache): add two-tier profile score caching and optimize user comparison
- Add Tier 1 Redis computed profile cache (profile:<username>) saving CPU scoring loops - Add Tier 2 PostgreSQL pre-computed score lookup in DatabaseStore bypassing raw_data JSONB - Refactor compareUsers to reuse getUserProfile and shared profile cache - Purge profile cache upon raw user data invalidation - Add unit test coverage in profile-cache.test.ts
1 parent 84cf6e5 commit dec247e

7 files changed

Lines changed: 406 additions & 56 deletions

File tree

‎src/features/comparison/services/compare-service.ts‎

Lines changed: 32 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import { getUserData } from "@/lib/github";
2-
import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring";
3-
import { persistUserScores } from "@/features/developer/services";
1+
import { normalizeSelectedLanguages } from "@/features/scoring";
2+
import { getUserProfile, UserFetchError } from "@/features/developer/services";
43
import {
54
DEFAULT_LOCALE,
65
LOCALE_COOKIE,
76
isSupportedLocale,
87
parseAcceptLanguage,
98
type Locale,
109
} from "@/lib/i18n/core";
11-
import type { GitHubUserData } from "@/lib/github";
1210
import type { ComparedUserResult, CompareInsights, CompareWinner, LanguageWinner } from "../types";
1311

1412
export class CompareUserFetchError extends Error {
@@ -257,53 +255,36 @@ export async function compareUsers(
257255
usernames: string[],
258256
selectedLanguages: string[],
259257
): Promise<ComparedUserResult[]> {
260-
return Promise.all(
261-
usernames.map(async (username) => {
262-
let data: GitHubUserData;
263-
try {
264-
const { data: userData } = await getUserData(username, {
265-
cacheInRedis: true,
266-
withMetrics: true,
267-
});
268-
data = userData;
269-
} catch (error: unknown) {
270-
throw new CompareUserFetchError(username, error);
271-
}
272-
273-
const score = calculateUserScore(
274-
{
275-
...data,
276-
selectedLanguages,
277-
},
258+
const results: ComparedUserResult[] = [];
259+
for (const username of usernames) {
260+
try {
261+
const { user } = await getUserProfile(username, selectedLanguages);
262+
results.push({
263+
username: user.username,
264+
name: user.name,
265+
avatarUrl: user.avatarUrl,
266+
repoScore: user.repoScore,
267+
prScore: user.prScore,
268+
contributionScore: user.contributionScore,
269+
finalScore: user.finalScore,
270+
normalizedRepoScore: user.normalizedRepoScore ?? user.repoScore,
271+
normalizedPRScore: user.normalizedPRScore ?? user.prScore,
272+
normalizedContributionScore: user.normalizedContributionScore ?? user.contributionScore,
273+
normalizedFinalScore: user.normalizedFinalScore ?? user.finalScore,
274+
topRepos: user.topRepos as ComparedUserResult["topRepos"],
275+
topPullRequests: user.topPullRequests as ComparedUserResult["topPullRequests"],
276+
topCommunityContributions: (user.topCommunityContributions ??
277+
[]) as ComparedUserResult["topCommunityContributions"],
278+
languageScores: user.languageScores as ComparedUserResult["languageScores"],
279+
signals: user.signals,
280+
explanations: user.explanations,
281+
} as unknown as ComparedUserResult);
282+
} catch (error: unknown) {
283+
throw new CompareUserFetchError(
278284
username,
285+
error instanceof UserFetchError ? error.causeError : error,
279286
);
280-
281-
// Fire-and-forget: detect country & persist canonical scores into DB
282-
void persistUserScores({
283-
data,
284-
score,
285-
selectedLanguages,
286-
});
287-
288-
return {
289-
username: data.login,
290-
name: data.name,
291-
avatarUrl: data.avatarUrl,
292-
repoScore: Math.round(score.repoScore),
293-
prScore: Math.round(score.prScore),
294-
contributionScore: Math.round(score.contributionScore),
295-
finalScore: Math.round(score.finalScore),
296-
normalizedRepoScore: Math.round(score.normalizedRepoScore),
297-
normalizedPRScore: Math.round(score.normalizedPRScore),
298-
normalizedContributionScore: Math.round(score.normalizedContributionScore),
299-
normalizedFinalScore: Math.round(score.normalizedFinalScore),
300-
topRepos: score.topRepos,
301-
topPullRequests: score.topPullRequests,
302-
topCommunityContributions: score.topCommunityContributions,
303-
languageScores: score.languageScores,
304-
signals: score.signals,
305-
explanations: score.explanations,
306-
};
307-
}),
308-
);
287+
}
288+
}
289+
return results;
309290
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./user-service";
22
export * from "./user-persistence";
3+
export * from "./profile-cache";
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createCacheStore, getCacheConfigFromEnv, type CacheStore } from "@/lib/cache";
2+
import type { UserProfileResponse } from "../types";
3+
4+
let cacheStore: CacheStore | undefined;
5+
6+
function getStore(): CacheStore {
7+
if (!cacheStore) {
8+
cacheStore = createCacheStore(getCacheConfigFromEnv());
9+
}
10+
return cacheStore;
11+
}
12+
13+
export function buildProfileCacheKey(username: string, selectedLanguages: string[] = []): string {
14+
const config = getCacheConfigFromEnv();
15+
const normalizedUser = username.trim().toLowerCase();
16+
if (!selectedLanguages || selectedLanguages.length === 0) {
17+
return `${config.namespace}:profile:${normalizedUser}`;
18+
}
19+
const langKey = selectedLanguages
20+
.map((l) => l.trim().toLowerCase())
21+
.filter(Boolean)
22+
.sort()
23+
.join(",");
24+
return `${config.namespace}:profile:${normalizedUser}:${langKey}`;
25+
}
26+
27+
export async function getCachedProfile(
28+
username: string,
29+
selectedLanguages: string[] = [],
30+
): Promise<UserProfileResponse | null> {
31+
const store = getStore();
32+
if (!store.enabled) {
33+
return null;
34+
}
35+
try {
36+
const key = buildProfileCacheKey(username, selectedLanguages);
37+
const cached = await store.get<UserProfileResponse>(key);
38+
return cached ?? null;
39+
} catch {
40+
return null;
41+
}
42+
}
43+
44+
export async function setCachedProfile(
45+
username: string,
46+
profile: UserProfileResponse,
47+
selectedLanguages: string[] = [],
48+
ttlSeconds?: number,
49+
): Promise<void> {
50+
const store = getStore();
51+
if (!store.enabled) {
52+
return;
53+
}
54+
try {
55+
const key = buildProfileCacheKey(username, selectedLanguages);
56+
const ttl = ttlSeconds ?? getCacheConfigFromEnv().ttlSeconds;
57+
await store.set(key, profile, ttl);
58+
} catch {
59+
// Non-fatal
60+
}
61+
}
62+
63+
export async function invalidateProfileCache(username: string): Promise<void> {
64+
const store = getStore();
65+
if (!store.enabled || !store.del) {
66+
return;
67+
}
68+
try {
69+
const key = buildProfileCacheKey(username, []);
70+
await store.del(key);
71+
} catch {
72+
// Non-fatal
73+
}
74+
}

‎src/features/developer/services/user-service.ts‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { getUserData } from "@/lib/github";
22
import { calculateUserScore } from "@/features/scoring";
3+
import type { CalculateUserScoreResult } from "@/features/scoring/services";
34
import { persistUserScores } from "./user-persistence";
5+
import { getCachedProfile, setCachedProfile } from "./profile-cache";
46
import type { UserProfileResponse, UserResult } from "../types";
57
import type { GitHubUserData } from "@/lib/github";
68

@@ -25,6 +27,65 @@ export async function getUserProfile(
2527
throw new Error("Username is required");
2628
}
2729

30+
// ── 1. Check Redis Profile Cache (Tier 1: Pre-computed Profile) ──
31+
try {
32+
const cached = await getCachedProfile(normalizedUsername, selectedLanguages);
33+
if (cached) {
34+
return cached;
35+
}
36+
} catch {
37+
// Non-fatal: continue to DB / fresh calculation
38+
}
39+
40+
// ── 2. Check PostgreSQL Pre-calculated Scores (Tier 2 for Canonical) ──
41+
const isCanonical = selectedLanguages.length === 0;
42+
if (isCanonical) {
43+
try {
44+
const { getDatabaseStore } = await import("@/lib/db");
45+
const db = getDatabaseStore();
46+
if (typeof db.getUserProfile === "function") {
47+
const row = await db.getUserProfile(normalizedUsername);
48+
49+
if (row && row.scores && row.stale_after > new Date()) {
50+
const scores = row.scores as CalculateUserScoreResult;
51+
const profile: UserProfileResponse = {
52+
user: {
53+
username: row.username,
54+
name: row.name,
55+
avatarUrl: row.avatar_url,
56+
repoScore: row.repo_score,
57+
prScore: row.pr_score,
58+
contributionScore: row.contribution_score,
59+
finalScore: row.final_score,
60+
normalizedRepoScore: Math.round(scores.normalizedRepoScore ?? scores.repoScore),
61+
normalizedPRScore: Math.round(scores.normalizedPRScore ?? scores.prScore),
62+
normalizedContributionScore: Math.round(
63+
scores.normalizedContributionScore ?? scores.contributionScore,
64+
),
65+
normalizedFinalScore: Math.round(scores.normalizedFinalScore ?? scores.finalScore),
66+
topRepos: scores.topRepos ?? [],
67+
topPullRequests: scores.topPullRequests ?? [],
68+
topCommunityContributions: scores.topCommunityContributions ?? [],
69+
languageScores: scores.languageScores,
70+
signals: scores.signals,
71+
explanations: scores.explanations,
72+
scoreVersion: process.env.DEVIMPACT_VERSION || undefined,
73+
},
74+
location: row.location ?? null,
75+
};
76+
77+
// Warm Redis profile cache
78+
void setCachedProfile(normalizedUsername, profile, selectedLanguages);
79+
80+
return profile;
81+
}
82+
}
83+
} catch {
84+
// Non-fatal: fall through to full calculation
85+
}
86+
}
87+
88+
// ── 3. Fallback: Fetch raw GitHub data & compute score ──────────────
2889
let data: GitHubUserData;
2990
try {
3091
const { data: userData } = await getUserData(normalizedUsername, {
@@ -65,15 +126,20 @@ export async function getUserProfile(
65126
scoreVersion: process.env.DEVIMPACT_VERSION || undefined,
66127
};
67128

68-
// Fire-and-forget: detect country & persist canonical scores into DB
129+
const response: UserProfileResponse = {
130+
user,
131+
location: data.location ?? null,
132+
};
133+
134+
// Warm Redis profile cache with the computed profile
135+
void setCachedProfile(normalizedUsername, response, selectedLanguages);
136+
137+
// Persist canonical scores into DB
69138
void persistUserScores({
70139
data,
71140
score,
72141
selectedLanguages,
73142
});
74143

75-
return {
76-
user,
77-
location: data.location ?? null,
78-
};
144+
return response;
79145
}

0 commit comments

Comments
 (0)