Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cache-test:
docker compose run --rm app-test
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ services:
volumes:
- redisdata:/data

app-test:
build:
context: .
target: deps
volumes:
- .:/app
- /app/node_modules
env_file:
- .env
depends_on:
- postgres
- redis
command: sh -c "npx drizzle-kit push && npx vitest run src/test/cache.test.ts"

volumes:
pgdata:
redisdata:
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { redis } from "../config/redis.js";
import { logger } from "../utils/logger.js";
import { Counter } from "prom-client";

const DEFAULT_TTL = 60;

export const cacheHits = new Counter({
name: "cache_hits_total",
help: "Total cache hits",
labelNames: ["namespace"],
});

export const cacheMisses = new Counter({
name: "cache_misses_total",
help: "Total cache misses",
labelNames: ["namespace"],
});

export interface CacheOptions {
ttl?: number;
prefix?: string;
}

export function cacheKey(
namespace: string,
...parts: (string | number)[]
): string {
return `chainlearn:${namespace}:${parts.join(":")}`;
}

export async function cacheGet<T>(
namespace: string,
key: string,
): Promise<T | null> {
try {
const raw = await redis.get(key);
if (!raw) {
cacheMisses.labels({ namespace }).inc();
return null;
}
cacheHits.labels({ namespace }).inc();
return JSON.parse(raw) as T;
} catch (err) {
logger.warn(
{ err, key },
"Cache read failed - Degrading gracefully to database",
);
return null;
}
}

export async function cacheSet<T>(
key: string,
value: T,
ttl: number = DEFAULT_TTL,
): Promise<void> {
try {
await redis.setex(key, ttl, JSON.stringify(value));
} catch (err) {
logger.warn({ err, key }, "Cache write failed");
}
}

/**
* Deletes precise keys safely. Avoids high-latency KEYS scanning in production.
*/
export async function cacheDel(key: string): Promise<void> {
try {
await redis.del(key);
} catch (err) {
logger.warn({ err, key }, "Cache delete failed");
}
}

/**
* Safely clears groups of keys using SCAN instead of KEYS *
*/
export async function cacheInvalidatePattern(pattern: string): Promise<void> {
try {
let cursor = "0";
do {
const [newCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100,
);
cursor = newCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== "0");
} catch (err) {
logger.warn({ err, pattern }, "Pattern cache invalidation failed");
}
}
20 changes: 20 additions & 0 deletions src/cache/warmer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { cacheSet, cacheKey } from "./index.js";
import { logger } from "../utils/logger.js";
import { courseService } from "../modules/courses/course.service.js";

export async function warmCourseCache(): Promise<void> {
try {
logger.info("Starting course listing cache warming cycle...");

const landingPageQuery = { page: 1, limit: 20 };
const data = await courseService.listCourses(null, landingPageQuery);

const key = cacheKey("courses", "list", "all", 1, 20);

await cacheSet(key, data, 60);

logger.info("Course listing cache successfully warmed");
} catch (err) {
logger.error({ err }, "Cache warming cycle failed step execution");
}
}
Loading
Loading