From 93120686b6e2023f045d8485c5cb86e6d0cb04db Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:35:30 +0900 Subject: [PATCH] feat: make cache defaults runtime-configurable --- .env.example | 4 ++ README.md | 10 ++++- lib/cache-store.ts | 44 +++++++++++++------ test/github/github-cache.test.ts | 73 ++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3dcca8f..b9f4061 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,11 @@ DISABLE_CALCULATE_LEADERBOARD_ENDPOINT=false REDIS_URL= REDIS_ENABLED=false REDIS_PASSWORD= +# CACHE_NAMESPACE is also accepted as an alias. +# CACHE_NAMESPACE=devimpact:v1 REDIS_CACHE_NAMESPACE=devimpact:v1 +# CACHE_TTL_SECONDS is also accepted as an alias. Valid range: 1-31536000. +# CACHE_TTL_SECONDS=604800 REDIS_CACHE_TTL_SECONDS=604800 REDIS_CONNECT_TIMEOUT_MS=1500 diff --git a/README.md b/README.md index 0b33a9e..cb0ee95 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,17 @@ GITHUB_REPO_COUNT=30 GITHUB_PR_COUNT=80 GITHUB_ISSUE_COUNT=20 GITHUB_DISCUSSION_COUNT=10 - +REDIS_URL=redis://localhost:6379 +REDIS_ENABLED=false +REDIS_CACHE_NAMESPACE=devimpact:v1 +REDIS_CACHE_TTL_SECONDS=604800 ``` +`CACHE_NAMESPACE` and `CACHE_TTL_SECONDS` are accepted as aliases for the +Redis-prefixed cache settings. The namespace must be non-empty. Cache TTL must +be a positive integer no greater than `31536000` seconds (one year); invalid or +missing values fall back to `devimpact:v1` and `604800` seconds (seven days). + --- ### 4. Run the app diff --git a/lib/cache-store.ts b/lib/cache-store.ts index 22cbae4..d31c84c 100644 --- a/lib/cache-store.ts +++ b/lib/cache-store.ts @@ -2,6 +2,7 @@ import { createClient } from "redis"; export const DEFAULT_GITHUB_CACHE_TTL_SECONDS = 604_800; export const DEFAULT_CACHE_NAMESPACE = "devimpact:v1"; +export const MAX_CACHE_TTL_SECONDS = 31_536_000; type CacheLogger = Pick; type AppRedisClient = ReturnType; @@ -29,15 +30,40 @@ function parseBoolean(value: string | undefined): boolean | undefined { return undefined; } -function parsePositiveInt(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { +function parsePositiveInt( + value: string | undefined, + max = Number.MAX_SAFE_INTEGER, +): number | undefined { + const normalized = value?.trim(); + if (!normalized || !/^\d+$/.test(normalized)) return undefined; + + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > max) { return undefined; } return parsed; } +export function getCacheTtlSecondsFromEnv( + env: NodeJS.ProcessEnv = process.env, +): number { + return ( + parsePositiveInt(env.REDIS_CACHE_TTL_SECONDS, MAX_CACHE_TTL_SECONDS) ?? + parsePositiveInt(env.CACHE_TTL_SECONDS, MAX_CACHE_TTL_SECONDS) ?? + DEFAULT_GITHUB_CACHE_TTL_SECONDS + ); +} + +export function getCacheNamespaceFromEnv( + env: NodeJS.ProcessEnv = process.env, +): string { + return ( + env.REDIS_CACHE_NAMESPACE?.trim() || + env.CACHE_NAMESPACE?.trim() || + DEFAULT_CACHE_NAMESPACE + ); +} + export function getCacheConfigFromEnv( env: NodeJS.ProcessEnv = process.env, ): CacheConfig { @@ -48,14 +74,8 @@ export function getCacheConfigFromEnv( return { enabled, redisUrl, - namespace: - env.REDIS_CACHE_NAMESPACE?.trim() || - env.CACHE_NAMESPACE?.trim() || - DEFAULT_CACHE_NAMESPACE, - ttlSeconds: - parsePositiveInt(env.REDIS_CACHE_TTL_SECONDS) ?? - parsePositiveInt(env.CACHE_TTL_SECONDS) ?? - DEFAULT_GITHUB_CACHE_TTL_SECONDS, + namespace: getCacheNamespaceFromEnv(env), + ttlSeconds: getCacheTtlSecondsFromEnv(env), connectTimeoutMs: parsePositiveInt(env.REDIS_CONNECT_TIMEOUT_MS) ?? 1_500, }; } diff --git a/test/github/github-cache.test.ts b/test/github/github-cache.test.ts index 1d035ee..ac99380 100644 --- a/test/github/github-cache.test.ts +++ b/test/github/github-cache.test.ts @@ -6,8 +6,12 @@ import { type GitHubFetcherDependencies, } from "@/lib/github"; import { + DEFAULT_CACHE_NAMESPACE, DEFAULT_GITHUB_CACHE_TTL_SECONDS, getCacheConfigFromEnv, + getCacheNamespaceFromEnv, + getCacheTtlSecondsFromEnv, + MAX_CACHE_TTL_SECONDS, type CacheStore, } from "@/lib/cache-store"; import type { GitHubUserData } from "@/types/github"; @@ -16,6 +20,12 @@ type ExecuteCall = { operationName: string; }; +function makeProcessEnv( + values: Record = {}, +): NodeJS.ProcessEnv { + return { NODE_ENV: "test", ...values }; +} + function makeExecutor( calls: ExecuteCall[], delayMs = 0, @@ -496,4 +506,67 @@ describe("GitHub user data caching", () => { const config = getCacheConfigFromEnv({} as NodeJS.ProcessEnv); expect(config.ttlSeconds).toBe(DEFAULT_GITHUB_CACHE_TTL_SECONDS); }); + + test("reads cache TTL aliases with Redis-specific precedence", () => { + expect( + getCacheTtlSecondsFromEnv(makeProcessEnv({ + REDIS_CACHE_TTL_SECONDS: "3600", + CACHE_TTL_SECONDS: "7200", + })), + ).toBe(3600); + expect( + getCacheTtlSecondsFromEnv(makeProcessEnv({ + CACHE_TTL_SECONDS: "7200", + })), + ).toBe(7200); + }); + + test.each(["0", "-1", "1.5", "42seconds", `${MAX_CACHE_TTL_SECONDS + 1}`])( + "rejects invalid cache TTL %s", + (value) => { + expect( + getCacheTtlSecondsFromEnv(makeProcessEnv({ + REDIS_CACHE_TTL_SECONDS: value, + })), + ).toBe(DEFAULT_GITHUB_CACHE_TTL_SECONDS); + }, + ); + + test("falls through to the TTL alias when the preferred value is invalid", () => { + expect( + getCacheTtlSecondsFromEnv(makeProcessEnv({ + REDIS_CACHE_TTL_SECONDS: "invalid", + CACHE_TTL_SECONDS: "1800", + })), + ).toBe(1800); + }); + + test("accepts the maximum cache TTL", () => { + expect( + getCacheTtlSecondsFromEnv(makeProcessEnv({ + REDIS_CACHE_TTL_SECONDS: `${MAX_CACHE_TTL_SECONDS}`, + })), + ).toBe(MAX_CACHE_TTL_SECONDS); + }); + + test("reads, trims, and validates cache namespace aliases", () => { + expect( + getCacheNamespaceFromEnv(makeProcessEnv({ + REDIS_CACHE_NAMESPACE: " deployment:v2 ", + CACHE_NAMESPACE: "fallback:v1", + })), + ).toBe("deployment:v2"); + expect( + getCacheNamespaceFromEnv(makeProcessEnv({ + REDIS_CACHE_NAMESPACE: " ", + CACHE_NAMESPACE: " fallback:v1 ", + })), + ).toBe("fallback:v1"); + expect( + getCacheNamespaceFromEnv(makeProcessEnv({ + REDIS_CACHE_NAMESPACE: " ", + CACHE_NAMESPACE: "", + })), + ).toBe(DEFAULT_CACHE_NAMESPACE); + }); });