better cache
This commit is contained in:
28
site/src/lib/cache/index.ts
vendored
Normal file
28
site/src/lib/cache/index.ts
vendored
Normal 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
16
site/src/lib/cache/memoize.ts
vendored
Normal 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
49
site/src/lib/cache/memory-cache.ts
vendored
Normal 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
19
site/src/lib/cache/noop-cache.ts
vendored
Normal 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
92
site/src/lib/cache/redis-cache.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user