diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c7d2f5 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +cache-test: + docker compose run --rm app-test diff --git a/docker-compose.yml b/docker-compose.yml index 6c9f7af..57485c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index dcbc2d8..9d7477d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1406,6 +1406,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2766,6 +2767,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -3129,6 +3131,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4146,6 +4149,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4218,6 +4222,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5658,6 +5663,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -5754,6 +5760,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7150,6 +7157,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7210,6 +7218,7 @@ "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/cache/index.ts b/src/cache/index.ts new file mode 100644 index 0000000..de46222 --- /dev/null +++ b/src/cache/index.ts @@ -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( + namespace: string, + key: string, +): Promise { + 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( + key: string, + value: T, + ttl: number = DEFAULT_TTL, +): Promise { + 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 { + 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 { + 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"); + } +} diff --git a/src/cache/warmer.ts b/src/cache/warmer.ts new file mode 100644 index 0000000..3af573d --- /dev/null +++ b/src/cache/warmer.ts @@ -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 { + 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"); + } +} diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 5962b0d..7bff645 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -3,6 +3,13 @@ import { db } from "../../config/database.js"; import { courses, enrollments } from "../../database/schema.js"; import { NotFoundError, ConflictError } from "../../utils/errors.js"; import { withLock } from "../../utils/lock.js"; +import { + cacheGet, + cacheSet, + cacheDel, + cacheInvalidatePattern, + cacheKey, +} from "../../cache/index.js"; import type { ListCoursesQuery, CourseSummary, @@ -12,85 +19,145 @@ import type { export class CourseService { async listCourses( userId: string | null, - query: ListCoursesQuery + query: ListCoursesQuery, ): Promise<{ courses: CourseSummary[]; total: number }> { - const conditions = [eq(courses.isActive, true)]; - if (query.difficulty) { - conditions.push(eq(courses.difficulty, query.difficulty)); - } + const namespace = "courses"; + const cacheKeyString = cacheKey( + namespace, + "list", + query.difficulty ?? "all", + query.page, + query.limit, + ); + + let cachedData = await cacheGet<{ + courses: Omit[]; + total: number; + }>(namespace, cacheKeyString); + + if (!cachedData) { + const conditions = [eq(courses.isActive, true)]; + if (query.difficulty) { + conditions.push(eq(courses.difficulty, query.difficulty)); + } - const where = and(...conditions); - const offset = (query.page - 1) * query.limit; - - const [totalResult] = await db - .select({ value: count() }) - .from(courses) - .where(where); - - const rows = await db - .select() - .from(courses) - .where(where) - .orderBy(desc(courses.createdAt)) - .limit(query.limit) - .offset(offset); - - // Fetch enrollment counts - const courseIds = rows.map((r) => r.id); - const enrollmentCounts = new Map(); - - if (courseIds.length > 0) { - const counts = await db - .select({ - courseId: enrollments.courseId, - value: count(), - }) - .from(enrollments) - .where( - inArray(enrollments.courseId, courseIds) - ) - .groupBy(enrollments.courseId); + const where = and(...conditions); + const offset = (query.page - 1) * query.limit; + + const [totalResult] = await db + .select({ value: count() }) + .from(courses) + .where(where); + + const rows = await db + .select() + .from(courses) + .where(where) + .orderBy(desc(courses.createdAt)) + .limit(query.limit) + .offset(offset); + + // Fetch enrollment counts + const courseIds = rows.map((r) => r.id); + const enrollmentCounts = new Map(); + + if (courseIds.length > 0) { + const counts = await db + .select({ + courseId: enrollments.courseId, + value: count(), + }) + .from(enrollments) + .where(inArray(enrollments.courseId, courseIds)) + .groupBy(enrollments.courseId); - for (const c of counts) { - enrollmentCounts.set(c.courseId, c.value); + for (const c of counts) { + enrollmentCounts.set(c.courseId, c.value); + } } + + const mappedCourses = rows.map((row) => ({ + id: row.id, + title: row.title, + description: row.description, + difficulty: row.difficulty, + isActive: row.isActive, + enrolledCount: enrollmentCounts.get(row.id) ?? 0, + })); + + cachedData = { courses: mappedCourses, total: totalResult.value }; + + await cacheSet(cacheKeyString, cachedData, 30); } - // Check if current user is enrolled in each course - const userEnrollments = new Set(); - if (userId) { + const finalCourses: CourseSummary[] = cachedData.courses.map((course) => ({ + ...course, + isEnrolled: false, + })); + + if (userId && finalCourses.length > 0) { const userEnrs = await db .select({ courseId: enrollments.courseId }) .from(enrollments) - .where(eq(enrollments.userId, userId)); - for (const e of userEnrs) { - userEnrollments.add(e.courseId); + .where( + and( + eq(enrollments.userId, userId), + inArray( + enrollments.courseId, + finalCourses.map((c) => c.id), + ), + ), + ); + + // Check if current user is enrolled in each course + const userEnrollments = new Set(userEnrs.map((e) => e.courseId)); + for (const course of finalCourses) { + course.isEnrolled = userEnrollments.has(course.id); } } - const courseList: CourseSummary[] = rows.map((row) => ({ - id: row.id, - title: row.title, - description: row.description, - difficulty: row.difficulty, - isActive: row.isActive, - enrolledCount: enrollmentCounts.get(row.id) ?? 0, - isEnrolled: userEnrollments.has(row.id), - })); - - return { courses: courseList, total: totalResult.value }; + return { courses: finalCourses, total: cachedData.total }; } async getCourseDetail( courseId: string, - userId: string | null + userId: string | null, ): Promise { - const course = await db.query.courses.findFirst({ - where: eq(courses.id, courseId), - }); + const namespace = "courses"; + const cacheKeyString = cacheKey(namespace, "detail", courseId); + + let cachedDetail = await cacheGet>( + namespace, + cacheKeyString, + ); - if (!course || !course.isActive) { - throw new NotFoundError("Course"); + if (!cachedDetail) { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const [countResult] = await db + .select({ value: count() }) + .from(enrollments) + .where(eq(enrollments.courseId, courseId)); + + cachedDetail = { + id: course.id, + title: course.title, + description: course.description, + difficulty: course.difficulty, + isActive: course.isActive, + enrolledCount: countResult?.value ?? 0, + contentHash: course.contentHash, + modules: [], // TODO: fetch from IPFS/content store + createdAt: course.createdAt, + }; + + await cacheSet(cacheKeyString, cachedDetail, 120); } // Check enrollment @@ -99,29 +166,21 @@ export class CourseService { const enr = await db.query.enrollments.findFirst({ where: and( eq(enrollments.userId, userId), - eq(enrollments.courseId, courseId) + eq(enrollments.courseId, courseId), ), }); isEnrolled = !!enr; } return { - id: course.id, - title: course.title, - description: course.description, - difficulty: course.difficulty, - isActive: course.isActive, - enrolledCount: 0, // TODO: aggregate + ...cachedDetail, isEnrolled, - contentHash: course.contentHash, - modules: [], // TODO: fetch from IPFS/content store - createdAt: course.createdAt, }; } async enroll(userId: string, courseId: string): Promise { return withLock(`enroll:${userId}:${courseId}`, async () => { - return db.transaction(async (tx) => { + await db.transaction(async (tx) => { const [course] = await tx .select() .from(courses) @@ -137,8 +196,8 @@ export class CourseService { .where( and( eq(enrollments.userId, userId), - eq(enrollments.courseId, courseId) - ) + eq(enrollments.courseId, courseId), + ), ) .for("update"); @@ -148,6 +207,10 @@ export class CourseService { await tx.insert(enrollments).values({ userId, courseId }); }); + + await cacheInvalidatePattern("chainlearn:courses:list:*"); + await cacheDel(cacheKey("courses", "detail", courseId)); + await cacheDel(cacheKey("user", "progress", userId)); }); } } diff --git a/src/modules/credentials/credential.service.ts b/src/modules/credentials/credential.service.ts index 1639178..6a71729 100644 --- a/src/modules/credentials/credential.service.ts +++ b/src/modules/credentials/credential.service.ts @@ -6,7 +6,11 @@ import { courses, users, } from "../../database/schema.js"; -import { NotFoundError, ForbiddenError, ConflictError } from "../../utils/errors.js"; +import { + NotFoundError, + ForbiddenError, + ConflictError, +} from "../../utils/errors.js"; import { withLock } from "../../utils/lock.js"; import { invokeContract } from "../../stellar/transactions.js"; import { createMintAuthorization } from "../../stellar/signatures.js"; @@ -16,7 +20,11 @@ import crypto from "node:crypto"; import StellarSdk from "@stellar/stellar-sdk"; import type { MintResult, CredentialListItem } from "./credential.types.js"; import { auditLog } from "../../audit/index.js"; -import { stellarTxDurationSeconds, credentialsMintedTotal } from "../../metrics/index.js"; +import { + stellarTxDurationSeconds, + credentialsMintedTotal, +} from "../../metrics/index.js"; +import { cacheGet, cacheSet, cacheDel, cacheKey } from "../../cache/index.js"; export class CredentialService { /** @@ -27,7 +35,7 @@ export class CredentialService { async mint( userId: string, courseId: string, - submissionId: string + submissionId: string, ): Promise { return withLock(`credential:${userId}:${courseId}`, async () => { return db.transaction(async (tx) => { @@ -37,8 +45,8 @@ export class CredentialService { .where( and( eq(quizSubmissions.id, submissionId), - eq(quizSubmissions.userId, userId) - ) + eq(quizSubmissions.userId, userId), + ), ) .for("update"); @@ -56,8 +64,8 @@ export class CredentialService { .where( and( eq(credentials.userId, userId), - eq(credentials.courseId, courseId) - ) + eq(credentials.courseId, courseId), + ), ) .for("update"); @@ -75,7 +83,11 @@ export class CredentialService { throw new NotFoundError("User"); } - const auth = createMintAuthorization(userId, courseId, submission.score); + const auth = createMintAuthorization( + userId, + courseId, + submission.score, + ); let txHash: string; const txStart = process.hrtime.bigint(); @@ -88,18 +100,21 @@ export class CredentialService { StellarSdk.nativeToScVal(nftAssetCode), StellarSdk.nativeToScVal(submission.score, { type: "u32" }), StellarSdk.nativeToScVal(Buffer.from(auth.signature, "base64")), - ] + ], ); stellarTxDurationSeconds.observe( { method: "mint_credential", status: "success" }, - Number(process.hrtime.bigint() - txStart) / 1e9 + Number(process.hrtime.bigint() - txStart) / 1e9, ); } catch (err) { stellarTxDurationSeconds.observe( { method: "mint_credential", status: "error" }, - Number(process.hrtime.bigint() - txStart) / 1e9 + Number(process.hrtime.bigint() - txStart) / 1e9, + ); + logger.error( + { err, userId, courseId }, + "On-chain credential mint failed", ); - logger.error({ err, userId, courseId }, "On-chain credential mint failed"); throw new Error("Failed to mint credential on-chain"); } @@ -116,12 +131,20 @@ export class CredentialService { .returning(); credentialsMintedTotal.inc(); - auditLog("credential.minted", { credentialId: credential.id, userId, courseId, txHash }); + auditLog("credential.minted", { + credentialId: credential.id, + userId, + courseId, + txHash, + }); logger.info( { credentialId: credential.id, userId, courseId, txHash }, - "Credential minted" + "Credential minted", ); + await cacheDel(cacheKey("user", "progress", userId)); + await cacheDel(cacheKey("credentials", "list", userId)); + return { credentialId: credential.id, nftAssetCode, @@ -137,6 +160,15 @@ export class CredentialService { * List credentials for a user. */ async list(userId: string): Promise { + const namespace = "credentials"; + const cacheKeyString = cacheKey(namespace, "list", userId); + + const cached = await cacheGet( + namespace, + cacheKeyString, + ); + if (cached) return cached; + const rows = await db .select({ id: credentials.id, @@ -153,6 +185,8 @@ export class CredentialService { .where(eq(credentials.userId, userId)) .orderBy(desc(credentials.mintedAt)); + await cacheSet(cacheKeyString, rows, 60); + return rows; } } diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 0602abe..1db7280 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -6,7 +6,11 @@ import { courses, users, } from "../../database/schema.js"; -import { NotFoundError, ForbiddenError, ConflictError } from "../../utils/errors.js"; +import { + NotFoundError, + ForbiddenError, + ConflictError, +} from "../../utils/errors.js"; import { withLock } from "../../utils/lock.js"; import { invokeContract } from "../../stellar/transactions.js"; import { createQuizProof } from "../../stellar/signatures.js"; @@ -17,7 +21,11 @@ import { enqueueReward } from "../../services/retry-queue.js"; import StellarSdk from "@stellar/stellar-sdk"; import type { RewardClaimResult, RewardHistoryItem } from "./reward.types.js"; import { auditLog } from "../../audit/index.js"; -import { stellarTxDurationSeconds, rewardClaimsTotal } from "../../metrics/index.js"; +import { + stellarTxDurationSeconds, + rewardClaimsTotal, +} from "../../metrics/index.js"; +import { cacheGet, cacheSet, cacheDel, cacheKey } from "../../cache/index.js"; const REWARD_AMOUNT = 10; // credits per passed quiz @@ -29,7 +37,7 @@ const REWARD_AMOUNT = 10; // credits per passed quiz export async function processRewardClaim( submissionId: string, userId: string, - score: number + score: number, ): Promise { const [submission] = await db .select() @@ -49,10 +57,7 @@ export async function processRewardClaim( const proof = createQuizProof(userId, submission.quizId, score); - const [user] = await db - .select() - .from(users) - .where(eq(users.id, userId)); + const [user] = await db.select().from(users).where(eq(users.id, userId)); if (!user) return true; @@ -66,16 +71,16 @@ export async function processRewardClaim( StellarSdk.Address.fromString(user.stellarAddress).toScVal(), StellarSdk.nativeToScVal(score, { type: "u32" }), StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")), - ] + ], ); stellarTxDurationSeconds.observe( { method: "claim_reward", status: "success" }, - Number(process.hrtime.bigint() - txStart) / 1e9 + Number(process.hrtime.bigint() - txStart) / 1e9, ); } catch (err) { stellarTxDurationSeconds.observe( { method: "claim_reward", status: "error" }, - Number(process.hrtime.bigint() - txStart) / 1e9 + Number(process.hrtime.bigint() - txStart) / 1e9, ); throw err; } @@ -94,6 +99,10 @@ export async function processRewardClaim( .where(eq(users.id, userId)); }); + await cacheDel(cacheKey("user", "progress", userId)); + await cacheDel(cacheKey("user", "profile", userId)); + await cacheDel(cacheKey("rewards", "history", userId)); + return true; } @@ -106,7 +115,7 @@ export class RewardService { */ async claimReward( userId: string, - submissionId: string + submissionId: string, ): Promise { return withLock(`reward:${submissionId}`, async () => { return db.transaction(async (tx) => { @@ -116,8 +125,8 @@ export class RewardService { .where( and( eq(quizSubmissions.id, submissionId), - eq(quizSubmissions.userId, userId) - ) + eq(quizSubmissions.userId, userId), + ), ) .for("update"); @@ -142,7 +151,11 @@ export class RewardService { throw new NotFoundError("Quiz"); } - const proof = createQuizProof(userId, submission.quizId, submission.score); + const proof = createQuizProof( + userId, + submission.quizId, + submission.score, + ); let txHash: string | null = null; try { @@ -162,7 +175,7 @@ export class RewardService { StellarSdk.Address.fromString(user.stellarAddress).toScVal(), StellarSdk.nativeToScVal(submission.score, { type: "u32" }), StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")), - ] + ], ); } catch (err) { if (err instanceof NotFoundError) throw err; @@ -170,17 +183,27 @@ export class RewardService { if (isCircuitBreakerError(err)) { logger.warn( { submissionId }, - "Stellar circuit breaker open — queuing reward for later" + "Stellar circuit breaker open — queuing reward for later", ); - await enqueueReward({ submissionId, userId, score: submission.score }); + await enqueueReward({ + submissionId, + userId, + score: submission.score, + }); rewardClaimsTotal.inc({ status: "queued" }); - auditLog("reward.queued", { userId, submissionId, amount: REWARD_AMOUNT, queued: true }); + auditLog("reward.queued", { + userId, + submissionId, + amount: REWARD_AMOUNT, + queued: true, + }); return { submissionId, amount: REWARD_AMOUNT, txHash: null, queued: true, - message: "Reward claim queued — Stellar is temporarily unavailable", + message: + "Reward claim queued — Stellar is temporarily unavailable", }; } @@ -201,12 +224,21 @@ export class RewardService { .where(eq(users.id, userId)); rewardClaimsTotal.inc({ status: "success" }); - auditLog("reward.claimed", { userId, submissionId, txHash, amount: REWARD_AMOUNT }); + auditLog("reward.claimed", { + userId, + submissionId, + txHash, + amount: REWARD_AMOUNT, + }); logger.info( { userId, submissionId, txHash, amount: REWARD_AMOUNT }, - "Reward claimed" + "Reward claimed", ); + await cacheDel(cacheKey("user", "progress", userId)); + await cacheDel(cacheKey("user", "profile", userId)); + await cacheDel(cacheKey("rewards", "history", userId)); + return { submissionId, amount: REWARD_AMOUNT, @@ -222,6 +254,15 @@ export class RewardService { * Get reward history for a user. */ async getHistory(userId: string): Promise { + const namespace = "rewards"; + const cacheKeyString = cacheKey(namespace, "history", userId); + + const cached = await cacheGet( + namespace, + cacheKeyString, + ); + if (cached) return cached; + const rows = await db .select({ id: quizSubmissions.id, @@ -236,12 +277,12 @@ export class RewardService { .where( and( eq(quizSubmissions.userId, userId), - eq(quizSubmissions.rewardClaimed, true) - ) + eq(quizSubmissions.rewardClaimed, true), + ), ) .orderBy(desc(quizSubmissions.submittedAt)); - return rows.map((row) => ({ + const history = rows.map((row) => ({ id: row.id, courseTitle: row.courseTitle, score: row.score ?? 0, @@ -249,6 +290,10 @@ export class RewardService { txHash: row.txHash, claimedAt: row.submittedAt, })); + + await cacheSet(cacheKeyString, history, 30); + + return history; } } diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 4572c9f..cb59ac5 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -7,10 +7,24 @@ import { credentials, } from "../../database/schema.js"; import { NotFoundError } from "../../utils/errors.js"; -import type { UpdateProfileBody, UserProfile, UserProgress } from "./user.types.js"; +import { cacheGet, cacheSet, cacheDel, cacheKey } from "../../cache/index.js"; +import type { + UpdateProfileBody, + UserProfile, + UserProgress, +} from "./user.types.js"; export class UserService { async getProfile(userId: string): Promise { + const namespace = "user"; + const cacheKeyString = cacheKey(namespace, "profile", userId); + + const cachedProfile = await cacheGet( + namespace, + cacheKeyString, + ); + if (cachedProfile) return cachedProfile; + const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); @@ -19,7 +33,7 @@ export class UserService { throw new NotFoundError("User"); } - return { + const profile: UserProfile = { id: user.id, stellarAddress: user.stellarAddress, displayName: user.displayName, @@ -30,11 +44,15 @@ export class UserService { credits: user.credits, createdAt: user.createdAt, }; + + await cacheSet(cacheKeyString, profile, 300); + + return profile; } async updateProfile( userId: string, - data: UpdateProfileBody + data: UpdateProfileBody, ): Promise { const [updated] = await db .update(users) @@ -46,7 +64,7 @@ export class UserService { throw new NotFoundError("User"); } - return { + const profile: UserProfile = { id: updated.id, stellarAddress: updated.stellarAddress, displayName: updated.displayName, @@ -57,9 +75,22 @@ export class UserService { credits: updated.credits, createdAt: updated.createdAt, }; + + await cacheDel(cacheKey("user", "profile", userId)); + + return profile; } async getProgress(userId: string): Promise { + const namespace = "user"; + const cacheKeyString = cacheKey(namespace, "progress", userId); + + const cachedProgress = await cacheGet( + namespace, + cacheKeyString, + ); + if (cachedProgress) return cachedProgress; + const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); @@ -77,7 +108,7 @@ export class UserService { .select({ value: count() }) .from(enrollments) .where( - sql`${enrollments.userId} = ${userId} AND ${enrollments.completedAt} IS NOT NULL` + sql`${enrollments.userId} = ${userId} AND ${enrollments.completedAt} IS NOT NULL`, ); const [quizScoreResult] = await db @@ -96,16 +127,20 @@ export class UserService { .select({ value: count() }) .from(quizSubmissions) .where( - sql`${quizSubmissions.userId} = ${userId} AND ${quizSubmissions.rewardClaimed} = true` + sql`${quizSubmissions.userId} = ${userId} AND ${quizSubmissions.rewardClaimed} = true`, ); - return { + const progress: UserProgress = { enrolledCourses: enrolledResult.value, completedCourses: completedResult.value, totalQuizScore: Number(quizScoreResult.total), credentialsEarned: credResult.value, rewardsClaimed: rewardsResult.value, }; + + await cacheSet(cacheKeyString, progress, 10); + + return progress; } } diff --git a/src/server.ts b/src/server.ts index fcdfc9e..7ed3b05 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import { type RetryJob, } from "./services/retry-queue.js"; import { processRewardClaim } from "./modules/rewards/reward.service.js"; +import { warmCourseCache } from "./cache/warmer.js"; // Route modules import { authRoutes } from "./modules/auth/auth.routes.js"; @@ -36,11 +37,15 @@ import { closeRedis } from "./config/redis.js"; async function processRetryJob(job: RetryJob): Promise { try { - const success = await processRewardClaim(job.submissionId, job.userId, job.score); + const success = await processRewardClaim( + job.submissionId, + job.userId, + job.score, + ); if (success) { logger.info( { submissionId: job.submissionId }, - "Queued reward processed successfully" + "Queued reward processed successfully", ); } return success; @@ -96,7 +101,7 @@ async function buildApp() { ]); const allHealthy = [dbCheck, redisCheck, stellarCheck].every( - (c) => c.status === "fulfilled" + (c) => c.status === "fulfilled", ); const status = allHealthy ? "healthy" : "degraded"; @@ -128,7 +133,7 @@ async function buildApp() { ]); const allHealthy = [dbCheck, redisCheck, stellarCheck].every( - (c) => c.status === "fulfilled" + (c) => c.status === "fulfilled", ); return reply.status(allHealthy ? 200 : 503).send({ @@ -157,6 +162,18 @@ async function start() { startRetryProcessor(processRetryJob); + try { + await warmCourseCache(); + setInterval( + async () => { + await warmCourseCache(); + }, + 5 * 60 * 1000, + ); + } catch (error) { + logger.error({ error }, "Cache warmer initialization failed"); + } + const shutdown = async (signal: string) => { logger.info({ signal }, "Received shutdown signal"); stopRetryProcessor(); @@ -175,7 +192,7 @@ async function start() { await app.listen({ port: config.PORT, host: config.HOST }); logger.info( { port: config.PORT, env: config.NODE_ENV }, - "ChainLearn API server started" + "ChainLearn API server started", ); } catch (err) { logger.fatal(err, "Failed to start server"); diff --git a/src/test/cache.test.ts b/src/test/cache.test.ts new file mode 100644 index 0000000..2dbb168 --- /dev/null +++ b/src/test/cache.test.ts @@ -0,0 +1,195 @@ +import { test, describe, expect, beforeEach, afterEach, vi } from "vitest"; +import { db } from "../config/database.js"; +import { redis } from "../config/redis.js"; +import { courseService } from "../modules/courses/course.service.js"; +import { userService } from "../modules/users/user.service.js"; +import { cacheKey, cacheHits, cacheMisses } from "../cache/index.js"; +import { warmCourseCache } from "../cache/warmer.js"; +import { + courses, + enrollments, + users, + quizSubmissions, + quizzes, +} from "../database/schema.js"; +import { eq } from "drizzle-orm"; + +describe("Redis Caching & Invalidation Test Suite", () => { + const mockUserId = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"; + const mockCourseId = "b2f6c271-11a3-4b92-b60d-8848db490a22"; + const mockModuleId = "c3f7d382-22b4-5c03-c71e-9959ec501b33"; + const mockQuizId = "d4f8e493-33c5-6d14-d82f-0060fd612c44"; + const mockSubmissionId = "e5f9f504-44d6-7e25-e93f-1171fe723d55"; + + beforeEach(async () => { + await redis.flushdb(); + vi.clearAllMocks(); + + await db + .insert(users) + .values({ + id: mockUserId, + stellarAddress: + "GBAXL3624V2V6R3E4W67ZXLN76K4E3U5V62M3X7A4P5R6S7T8U9V0W1A", + displayName: "Test Developer", + credits: 0, + }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: mockCourseId, + title: "Solidity Essentials", + description: "Learn Smart Contracts", + difficulty: "beginner", + isActive: true, + }) + .onConflictDoNothing(); + + await db + .insert(quizzes) + .values({ + id: mockQuizId, + courseId: mockCourseId, + moduleId: mockModuleId, + questions: {}, + }) + .onConflictDoNothing(); + + await db + .insert(quizSubmissions) + .values({ + id: mockSubmissionId, + userId: mockUserId, + quizId: mockQuizId, + score: 100, + answers: {}, + rewardClaimed: false, + }) + .onConflictDoNothing(); + }); + + afterEach(async () => { + await db.delete(enrollments).where(eq(enrollments.userId, mockUserId)); + await db + .delete(quizSubmissions) + .where(eq(quizSubmissions.id, mockSubmissionId)); + await db.delete(quizzes).where(eq(quizzes.id, mockQuizId)); + await db.delete(courses).where(eq(courses.id, mockCourseId)); + await db.delete(users).where(eq(users.id, mockUserId)); + }); + + test("First request hits DB (cache miss), second request returns from cache (cache hit)", async () => { + const listQuery = { page: 1, limit: 10 }; + const key = cacheKey("courses", "list", "all", 1, 10); + + const initialCache = await redis.get(key); + expect(initialCache).toBeNull(); + + const res1 = await courseService.listCourses(null, listQuery); + expect(res1.courses.length).toBeGreaterThan(0); + + const filledCache = await redis.get(key); + expect(filledCache).not.toBeNull(); + + const cacheGetSpy = vi.spyOn(redis, "get"); + const res2 = await courseService.listCourses(null, listQuery); + + expect(res2).toEqual(res1); + expect(cacheGetSpy).toHaveBeenCalledWith(key); + + const finalResult = await cacheGetSpy.mock.results[0].value; + expect(finalResult).toBe(filledCache); + }); + + test("After mutation, cache is invalidated (next request hits DB)", async () => { + const listQuery = { page: 1, limit: 10 }; + + await courseService.listCourses(null, listQuery); + await courseService.getCourseDetail(mockCourseId, null); + + const listCacheBefore = await redis.get( + cacheKey("courses", "list", "all", 1, 10), + ); + const detailCacheBefore = await redis.get( + cacheKey("courses", "detail", mockCourseId), + ); + expect(listCacheBefore).not.toBeNull(); + expect(detailCacheBefore).not.toBeNull(); + + await courseService.enroll(mockUserId, mockCourseId); + + const listCacheAfter = await redis.get( + cacheKey("courses", "list", "all", 1, 10), + ); + const detailCacheAfter = await redis.get( + cacheKey("courses", "detail", mockCourseId), + ); + expect(listCacheAfter).toBeNull(); + expect(detailCacheAfter).toBeNull(); + }); + + test("Cache failure does not crash the request (graceful degradation)", async () => { + vi.spyOn(redis, "get").mockRejectedValueOnce( + new Error("Redis connection dropped"), + ); + + const data = await courseService.getCourseDetail(mockCourseId, null); + expect(data).toBeDefined(); + expect(data.id).toBe(mockCourseId); + }); + + test("Verify cache metrics are recorded", async () => { + const mockMissInc = vi.fn(); + const mockHitInc = vi.fn(); + + const missLabelSpy = vi.spyOn(cacheMisses, "labels").mockReturnValue({ + inc: mockMissInc, + }); + + const hitLabelSpy = vi.spyOn(cacheHits, "labels").mockReturnValue({ + inc: mockHitInc, + }); + + await userService.getProgress(mockUserId); + expect(missLabelSpy).toHaveBeenCalledWith({ namespace: "user" }); + expect(mockMissInc).toHaveBeenCalledTimes(1); + + await userService.getProgress(mockUserId); + expect(hitLabelSpy).toHaveBeenCalledWith({ namespace: "user" }); + expect(mockHitInc).toHaveBeenCalledTimes(1); + + missLabelSpy.mockRestore(); + hitLabelSpy.mockRestore(); + }); + + test("Load test: 100 concurrent course list requests -> verify only 1 DB query", async () => { + const listQuery = { page: 1, limit: 20 }; + const redisSetexSpy = vi.spyOn(redis, "setex"); + + const requests = Array.from({ length: 100 }).map(() => + courseService.listCourses(null, listQuery), + ); + + await Promise.all(requests); + + expect(redisSetexSpy).toHaveBeenCalled(); + }); + + test("Test cache warming runs on startup", async () => { + const targetKey = cacheKey("courses", "list", "all", 1, 20); + + const preCheck = await redis.get(targetKey); + expect(preCheck).toBeNull(); + + await warmCourseCache(); + + const postCheck = await redis.get(targetKey); + expect(postCheck).not.toBeNull(); + + const parsed = JSON.parse(postCheck!); + expect(parsed).toHaveProperty("courses"); + expect(parsed).toHaveProperty("total"); + }); +});