Skip to content

Commit dcfc366

Browse files
authored
feat: make cache defaults runtime-configurable (#201)
Co-authored-by: snowyukitty <270071858+snowyukitty@users.noreply.github.com>
1 parent dab4a96 commit dcfc366

4 files changed

Lines changed: 118 additions & 13 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ DISABLE_CALCULATE_LEADERBOARD_ENDPOINT=false
1919
REDIS_URL=
2020
REDIS_ENABLED=false
2121
REDIS_PASSWORD=
22+
# CACHE_NAMESPACE is also accepted as an alias.
23+
# CACHE_NAMESPACE=devimpact:v1
2224
REDIS_CACHE_NAMESPACE=devimpact:v1
25+
# CACHE_TTL_SECONDS is also accepted as an alias. Valid range: 1-31536000.
26+
# CACHE_TTL_SECONDS=604800
2327
REDIS_CACHE_TTL_SECONDS=604800
2428
REDIS_CONNECT_TIMEOUT_MS=1500
2529

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,17 @@ GITHUB_REPO_COUNT=30
189189
GITHUB_PR_COUNT=80
190190
GITHUB_ISSUE_COUNT=20
191191
GITHUB_DISCUSSION_COUNT=10
192-
192+
REDIS_URL=redis://localhost:6379
193+
REDIS_ENABLED=false
194+
REDIS_CACHE_NAMESPACE=devimpact:v1
195+
REDIS_CACHE_TTL_SECONDS=604800
193196
```
194197

198+
`CACHE_NAMESPACE` and `CACHE_TTL_SECONDS` are accepted as aliases for the
199+
Redis-prefixed cache settings. The namespace must be non-empty. Cache TTL must
200+
be a positive integer no greater than `31536000` seconds (one year); invalid or
201+
missing values fall back to `devimpact:v1` and `604800` seconds (seven days).
202+
195203
---
196204

197205
### 4. Run the app

lib/cache-store.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createClient } from "redis";
22

33
export const DEFAULT_GITHUB_CACHE_TTL_SECONDS = 604_800;
44
export const DEFAULT_CACHE_NAMESPACE = "devimpact:v1";
5+
export const MAX_CACHE_TTL_SECONDS = 31_536_000;
56

67
type CacheLogger = Pick<Console, "info" | "warn">;
78
type AppRedisClient = ReturnType<typeof createClient>;
@@ -29,15 +30,40 @@ function parseBoolean(value: string | undefined): boolean | undefined {
2930
return undefined;
3031
}
3132

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) {
33+
function parsePositiveInt(
34+
value: string | undefined,
35+
max = Number.MAX_SAFE_INTEGER,
36+
): number | undefined {
37+
const normalized = value?.trim();
38+
if (!normalized || !/^\d+$/.test(normalized)) return undefined;
39+
40+
const parsed = Number(normalized);
41+
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > max) {
3642
return undefined;
3743
}
3844
return parsed;
3945
}
4046

47+
export function getCacheTtlSecondsFromEnv(
48+
env: NodeJS.ProcessEnv = process.env,
49+
): number {
50+
return (
51+
parsePositiveInt(env.REDIS_CACHE_TTL_SECONDS, MAX_CACHE_TTL_SECONDS) ??
52+
parsePositiveInt(env.CACHE_TTL_SECONDS, MAX_CACHE_TTL_SECONDS) ??
53+
DEFAULT_GITHUB_CACHE_TTL_SECONDS
54+
);
55+
}
56+
57+
export function getCacheNamespaceFromEnv(
58+
env: NodeJS.ProcessEnv = process.env,
59+
): string {
60+
return (
61+
env.REDIS_CACHE_NAMESPACE?.trim() ||
62+
env.CACHE_NAMESPACE?.trim() ||
63+
DEFAULT_CACHE_NAMESPACE
64+
);
65+
}
66+
4167
export function getCacheConfigFromEnv(
4268
env: NodeJS.ProcessEnv = process.env,
4369
): CacheConfig {
@@ -48,14 +74,8 @@ export function getCacheConfigFromEnv(
4874
return {
4975
enabled,
5076
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,
77+
namespace: getCacheNamespaceFromEnv(env),
78+
ttlSeconds: getCacheTtlSecondsFromEnv(env),
5979
connectTimeoutMs: parsePositiveInt(env.REDIS_CONNECT_TIMEOUT_MS) ?? 1_500,
6080
};
6181
}

test/github/github-cache.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import {
66
type GitHubFetcherDependencies,
77
} from "@/lib/github";
88
import {
9+
DEFAULT_CACHE_NAMESPACE,
910
DEFAULT_GITHUB_CACHE_TTL_SECONDS,
1011
getCacheConfigFromEnv,
12+
getCacheNamespaceFromEnv,
13+
getCacheTtlSecondsFromEnv,
14+
MAX_CACHE_TTL_SECONDS,
1115
type CacheStore,
1216
} from "@/lib/cache-store";
1317
import type { GitHubUserData } from "@/types/github";
@@ -16,6 +20,12 @@ type ExecuteCall = {
1620
operationName: string;
1721
};
1822

23+
function makeProcessEnv(
24+
values: Record<string, string> = {},
25+
): NodeJS.ProcessEnv {
26+
return { NODE_ENV: "test", ...values };
27+
}
28+
1929
function makeExecutor(
2030
calls: ExecuteCall[],
2131
delayMs = 0,
@@ -496,4 +506,67 @@ describe("GitHub user data caching", () => {
496506
const config = getCacheConfigFromEnv({} as NodeJS.ProcessEnv);
497507
expect(config.ttlSeconds).toBe(DEFAULT_GITHUB_CACHE_TTL_SECONDS);
498508
});
509+
510+
test("reads cache TTL aliases with Redis-specific precedence", () => {
511+
expect(
512+
getCacheTtlSecondsFromEnv(makeProcessEnv({
513+
REDIS_CACHE_TTL_SECONDS: "3600",
514+
CACHE_TTL_SECONDS: "7200",
515+
})),
516+
).toBe(3600);
517+
expect(
518+
getCacheTtlSecondsFromEnv(makeProcessEnv({
519+
CACHE_TTL_SECONDS: "7200",
520+
})),
521+
).toBe(7200);
522+
});
523+
524+
test.each(["0", "-1", "1.5", "42seconds", `${MAX_CACHE_TTL_SECONDS + 1}`])(
525+
"rejects invalid cache TTL %s",
526+
(value) => {
527+
expect(
528+
getCacheTtlSecondsFromEnv(makeProcessEnv({
529+
REDIS_CACHE_TTL_SECONDS: value,
530+
})),
531+
).toBe(DEFAULT_GITHUB_CACHE_TTL_SECONDS);
532+
},
533+
);
534+
535+
test("falls through to the TTL alias when the preferred value is invalid", () => {
536+
expect(
537+
getCacheTtlSecondsFromEnv(makeProcessEnv({
538+
REDIS_CACHE_TTL_SECONDS: "invalid",
539+
CACHE_TTL_SECONDS: "1800",
540+
})),
541+
).toBe(1800);
542+
});
543+
544+
test("accepts the maximum cache TTL", () => {
545+
expect(
546+
getCacheTtlSecondsFromEnv(makeProcessEnv({
547+
REDIS_CACHE_TTL_SECONDS: `${MAX_CACHE_TTL_SECONDS}`,
548+
})),
549+
).toBe(MAX_CACHE_TTL_SECONDS);
550+
});
551+
552+
test("reads, trims, and validates cache namespace aliases", () => {
553+
expect(
554+
getCacheNamespaceFromEnv(makeProcessEnv({
555+
REDIS_CACHE_NAMESPACE: " deployment:v2 ",
556+
CACHE_NAMESPACE: "fallback:v1",
557+
})),
558+
).toBe("deployment:v2");
559+
expect(
560+
getCacheNamespaceFromEnv(makeProcessEnv({
561+
REDIS_CACHE_NAMESPACE: " ",
562+
CACHE_NAMESPACE: " fallback:v1 ",
563+
})),
564+
).toBe("fallback:v1");
565+
expect(
566+
getCacheNamespaceFromEnv(makeProcessEnv({
567+
REDIS_CACHE_NAMESPACE: " ",
568+
CACHE_NAMESPACE: "",
569+
})),
570+
).toBe(DEFAULT_CACHE_NAMESPACE);
571+
});
499572
});

0 commit comments

Comments
 (0)