Skip to content

Commit 04a25d4

Browse files
committed
feat: add caching for the github api using redis
1 parent 1804249 commit 04a25d4

4 files changed

Lines changed: 322 additions & 1 deletion

File tree

docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
services:
2+
redis:
3+
image: redis:7-alpine
4+
container_name: devimpact-redis
5+
restart: unless-stopped
6+
ports:
7+
- "6379:6379"
8+
environment:
9+
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
10+
command: >
11+
sh -c "if [ -n \"$${REDIS_PASSWORD}\" ]; then
12+
redis-server --appendonly yes --requirepass \"$${REDIS_PASSWORD}\";
13+
else
14+
redis-server --appendonly yes;
15+
fi"
16+
volumes:
17+
- redis-data:/data
18+
healthcheck:
19+
test: ["CMD", "redis-cli", "ping"]
20+
interval: 10s
21+
timeout: 3s
22+
retries: 5
23+
24+
volumes:
25+
redis-data:

lib/cache-store.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { createClient } from "redis";
2+
3+
export const DEFAULT_GITHUB_CACHE_TTL_SECONDS = 604_800;
4+
export const DEFAULT_CACHE_NAMESPACE = "devimpact:v1";
5+
6+
type CacheLogger = Pick<Console, "info" | "warn">;
7+
type AppRedisClient = ReturnType<typeof createClient>;
8+
9+
export type CacheConfig = {
10+
enabled: boolean;
11+
redisUrl?: string;
12+
namespace: string;
13+
ttlSeconds: number;
14+
connectTimeoutMs: number;
15+
};
16+
17+
export interface CacheStore {
18+
readonly enabled: boolean;
19+
get<T>(key: string): Promise<T | undefined>;
20+
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
21+
del?(key: string): Promise<void>;
22+
}
23+
24+
function parseBoolean(value: string | undefined): boolean | undefined {
25+
if (value === undefined) return undefined;
26+
const normalized = value.trim().toLowerCase();
27+
if (normalized === "true" || normalized === "1") return true;
28+
if (normalized === "false" || normalized === "0") return false;
29+
return undefined;
30+
}
31+
32+
function parsePositiveInt(value: string | undefined): number | undefined {
33+
if (!value) return undefined;
34+
const parsed = Number.parseInt(value, 10);
35+
if (!Number.isFinite(parsed) || parsed <= 0) {
36+
return undefined;
37+
}
38+
return parsed;
39+
}
40+
41+
export function getCacheConfigFromEnv(
42+
env: NodeJS.ProcessEnv = process.env,
43+
): CacheConfig {
44+
const redisUrl = env.REDIS_URL?.trim() || undefined;
45+
const enabledFromEnv = parseBoolean(env.REDIS_ENABLED);
46+
const enabled = enabledFromEnv ?? Boolean(redisUrl);
47+
48+
return {
49+
enabled,
50+
redisUrl,
51+
namespace:
52+
env.REDIS_CACHE_NAMESPACE?.trim() ||
53+
env.CACHE_NAMESPACE?.trim() ||
54+
DEFAULT_CACHE_NAMESPACE,
55+
ttlSeconds:
56+
parsePositiveInt(env.REDIS_CACHE_TTL_SECONDS) ??
57+
parsePositiveInt(env.CACHE_TTL_SECONDS) ??
58+
DEFAULT_GITHUB_CACHE_TTL_SECONDS,
59+
connectTimeoutMs: parsePositiveInt(env.REDIS_CONNECT_TIMEOUT_MS) ?? 1_500,
60+
};
61+
}
62+
63+
class NoopCacheStore implements CacheStore {
64+
readonly enabled = false;
65+
66+
async get<T>(key: string): Promise<T | undefined> {
67+
void key;
68+
return undefined;
69+
}
70+
71+
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
72+
void key;
73+
void value;
74+
void ttlSeconds;
75+
return;
76+
}
77+
}
78+
79+
type RedisGlobalState = typeof globalThis & {
80+
__devimpactRedisClient?: AppRedisClient;
81+
__devimpactRedisConnectPromise?: Promise<AppRedisClient | null>;
82+
};
83+
84+
class RedisJsonCacheStore implements CacheStore {
85+
readonly enabled = true;
86+
private readonly config: CacheConfig;
87+
private readonly logger: CacheLogger;
88+
89+
constructor(config: CacheConfig, logger: CacheLogger) {
90+
this.config = config;
91+
this.logger = logger;
92+
}
93+
94+
async get<T>(key: string): Promise<T | undefined> {
95+
const client = await this.getClient();
96+
if (!client) {
97+
return undefined;
98+
}
99+
100+
const raw = await client.get(key);
101+
if (!raw) {
102+
return undefined;
103+
}
104+
105+
try {
106+
return JSON.parse(raw) as T;
107+
} catch (error: unknown) {
108+
this.logger.warn("cache-corrupt", {
109+
key,
110+
message: error instanceof Error ? error.message : String(error),
111+
});
112+
await this.safeDelete(client, key);
113+
return undefined;
114+
}
115+
}
116+
117+
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
118+
const client = await this.getClient();
119+
if (!client) {
120+
return;
121+
}
122+
123+
const ttl = ttlSeconds ?? this.config.ttlSeconds;
124+
await client.set(key, JSON.stringify(value), { EX: ttl });
125+
}
126+
127+
async del(key: string): Promise<void> {
128+
const client = await this.getClient();
129+
if (!client) {
130+
return;
131+
}
132+
await this.safeDelete(client, key);
133+
}
134+
135+
private async getClient(): Promise<AppRedisClient | null> {
136+
const globalState = globalThis as RedisGlobalState;
137+
if (globalState.__devimpactRedisClient?.isOpen) {
138+
return globalState.__devimpactRedisClient;
139+
}
140+
141+
if (!globalState.__devimpactRedisConnectPromise) {
142+
globalState.__devimpactRedisConnectPromise = this.connect();
143+
}
144+
145+
const client = await globalState.__devimpactRedisConnectPromise;
146+
if (!client) {
147+
globalState.__devimpactRedisConnectPromise = undefined;
148+
} else {
149+
globalState.__devimpactRedisClient = client;
150+
}
151+
return client;
152+
}
153+
154+
private async connect(): Promise<AppRedisClient | null> {
155+
if (!this.config.redisUrl) {
156+
return null;
157+
}
158+
159+
const client = createClient({
160+
url: this.config.redisUrl,
161+
socket: {
162+
connectTimeout: this.config.connectTimeoutMs,
163+
},
164+
});
165+
166+
client.on("error", (error: unknown) => {
167+
this.logger.warn("cache-redis-error", {
168+
message: error instanceof Error ? error.message : String(error),
169+
});
170+
});
171+
172+
try {
173+
await client.connect();
174+
this.logger.info("cache-connected", {
175+
namespace: this.config.namespace,
176+
});
177+
return client;
178+
} catch (error: unknown) {
179+
this.logger.warn("cache-connect-fail", {
180+
message: error instanceof Error ? error.message : String(error),
181+
});
182+
try {
183+
await client.disconnect();
184+
} catch {
185+
// best effort cleanup
186+
}
187+
return null;
188+
}
189+
}
190+
191+
private async safeDelete(client: AppRedisClient, key: string): Promise<void> {
192+
try {
193+
await client.del(key);
194+
} catch {
195+
// best effort delete
196+
}
197+
}
198+
}
199+
200+
export function createCacheStore(
201+
config: CacheConfig = getCacheConfigFromEnv(),
202+
logger: CacheLogger = console,
203+
): CacheStore {
204+
if (!config.enabled || !config.redisUrl) {
205+
logger.info("cache-disabled", {
206+
enabled: config.enabled,
207+
hasRedisUrl: Boolean(config.redisUrl),
208+
});
209+
return new NoopCacheStore();
210+
}
211+
212+
return new RedisJsonCacheStore(config, logger);
213+
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
"start": "next start",
99
"lint": "eslint .",
1010
"test": "vitest",
11-
"test:watch": "vitest --watch"
11+
"test:watch": "vitest --watch",
12+
"redis:up": "docker compose up -d redis",
13+
"redis:down": "docker compose stop redis"
1214
},
1315
"dependencies": {
1416
"@octokit/graphql": "^9.0.3",
@@ -22,6 +24,7 @@
2224
"react-dom": "^19.2.4",
2325
"react-icons": "^5.6.0",
2426
"recharts": "^2.12.7",
27+
"redis": "^5.12.1",
2528
"tailwind-merge": "^2.5.3"
2629
},
2730
"devDependencies": {

pnpm-lock.yaml

Lines changed: 80 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)