better cache

This commit is contained in:
2026-02-10 01:20:58 -05:00
parent c773affbc8
commit f056e67eae
39 changed files with 830 additions and 17 deletions

28
site/src/lib/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
import type { CacheLogFn, CacheStore } from "./redis-cache";
import {
createRedisCache,
resolveDefaultTtlSecondsFromEnv,
resolveRedisUrlFromEnv,
} from "./redis-cache";
import { createNoopCache } from "./noop-cache";
export async function createCacheFromEnv(
env: NodeJS.ProcessEnv,
opts?: { namespace?: string; log?: CacheLogFn },
): Promise<CacheStore> {
const url = resolveRedisUrlFromEnv(env);
if (!url) return createNoopCache(opts?.log);
try {
return await createRedisCache({
url,
defaultTtlSeconds: resolveDefaultTtlSecondsFromEnv(env),
namespace: opts?.namespace,
log: opts?.log,
});
} catch (e) {
opts?.log?.(`cache: disabled (redis connect failed: ${String(e)})`);
return createNoopCache(opts?.log);
}
}

16
site/src/lib/cache/memoize.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import type { CacheStore } from "./redis-cache";
export async function cachedCompute<T>(
cache: CacheStore,
key: string,
compute: () => Promise<T>,
ttlSeconds?: number,
): Promise<{ value: T; cached: boolean }> {
const hit = await cache.getJson<T>(key);
if (hit !== undefined) return { value: hit, cached: true };
const value = await compute();
await cache.setJson(key, value, ttlSeconds);
return { value, cached: false };
}

49
site/src/lib/cache/memory-cache.ts vendored Normal file
View File

@@ -0,0 +1,49 @@
import type { CacheStore } from "./redis-cache";
type Entry = { value: string; expiresAt: number };
export function createMemoryCache(defaultTtlSeconds: number): CacheStore {
const store = new Map<string, Entry>();
function nowMs() {
return Date.now();
}
function isExpired(e: Entry) {
return e.expiresAt !== 0 && nowMs() > e.expiresAt;
}
return {
async getJson<T>(key: string) {
const e = store.get(key);
if (!e) return undefined;
if (isExpired(e)) {
store.delete(key);
return undefined;
}
try {
return JSON.parse(e.value) as T;
} catch {
store.delete(key);
return undefined;
}
},
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const ttl = Math.max(1, Math.floor(ttlSeconds ?? defaultTtlSeconds));
store.set(key, {
value: JSON.stringify(value),
expiresAt: nowMs() + ttl * 1000,
});
},
async flush() {
store.clear();
},
async close() {
// no-op
},
};
}

19
site/src/lib/cache/noop-cache.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
import type { CacheLogFn, CacheStore } from "./redis-cache";
export function createNoopCache(log?: CacheLogFn): CacheStore {
return {
async getJson() {
return undefined;
},
async setJson() {
// no-op
},
async flush() {
log?.("cache: noop flush");
},
async close() {
// no-op
},
};
}

92
site/src/lib/cache/redis-cache.ts vendored Normal file
View File

@@ -0,0 +1,92 @@
import { createClient } from "redis";
export type CacheLogFn = (msg: string) => void;
export type CacheStore = {
getJson<T>(key: string): Promise<T | undefined>;
setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
};
type RedisCacheOptions = {
url: string;
defaultTtlSeconds: number;
namespace?: string;
log?: CacheLogFn;
};
function nsKey(namespace: string | undefined, key: string) {
return namespace ? `${namespace}:${key}` : key;
}
export async function createRedisCache(opts: RedisCacheOptions): Promise<CacheStore> {
const log = opts.log;
const client = createClient({ url: opts.url });
client.on("error", (err) => {
log?.(`cache: redis error (${String(err)})`);
});
await client.connect();
return {
async getJson<T>(key: string) {
const k = nsKey(opts.namespace, key);
const raw = await client.get(k);
if (raw == null) {
log?.(`cache: miss ${k}`);
return undefined;
}
log?.(`cache: hit ${k}`);
try {
return JSON.parse(raw) as T;
} catch {
// Bad cache entry: treat as miss.
return undefined;
}
},
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const k = nsKey(opts.namespace, key);
const ttl = Math.max(1, Math.floor(ttlSeconds ?? opts.defaultTtlSeconds));
const raw = JSON.stringify(value);
await client.set(k, raw, { EX: ttl });
},
async flush() {
await client.flushDb();
},
async close() {
try {
await client.quit();
} catch {
// ignore
}
},
};
}
export function resolveRedisUrlFromEnv(env: NodeJS.ProcessEnv): string | undefined {
const url = env.CACHE_REDIS_URL;
if (url) return url;
const host = env.CACHE_REDIS_HOST;
const port = env.CACHE_REDIS_PORT;
const db = env.CACHE_REDIS_DB;
if (!host) return undefined;
const p = port ? Number(port) : 6379;
const d = db ? Number(db) : 0;
if (!Number.isFinite(p) || !Number.isFinite(d)) return undefined;
return `redis://${host}:${p}/${d}`;
}
export function resolveDefaultTtlSecondsFromEnv(env: NodeJS.ProcessEnv): number {
const raw = env.CACHE_DEFAULT_TTL_SECONDS;
const n = raw ? Number(raw) : NaN;
if (Number.isFinite(n) && n > 0) return Math.floor(n);
return 3600;
}

View File

@@ -14,6 +14,11 @@ type IngestConfig = {
wordpressBaseUrl?: string;
wordpressUsername?: string;
wordpressAppPassword?: string;
cacheRedisUrl?: string;
cacheRedisHost?: string;
cacheRedisPort?: number;
cacheRedisDb?: number;
cacheDefaultTtlSeconds?: number;
};
export function getPublicConfig(): PublicConfig {
@@ -37,5 +42,12 @@ export function getIngestConfigFromEnv(env: NodeJS.ProcessEnv): IngestConfig {
wordpressBaseUrl: env.WORDPRESS_BASE_URL,
wordpressUsername: env.WORDPRESS_USERNAME,
wordpressAppPassword: env.WORDPRESS_APP_PASSWORD,
cacheRedisUrl: env.CACHE_REDIS_URL,
cacheRedisHost: env.CACHE_REDIS_HOST,
cacheRedisPort: env.CACHE_REDIS_PORT ? Number(env.CACHE_REDIS_PORT) : undefined,
cacheRedisDb: env.CACHE_REDIS_DB ? Number(env.CACHE_REDIS_DB) : undefined,
cacheDefaultTtlSeconds: env.CACHE_DEFAULT_TTL_SECONDS
? Number(env.CACHE_DEFAULT_TTL_SECONDS)
: undefined,
};
}