From 13e0c81957768c1b380914a0b93d3617c60e08a0 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Fri, 20 Feb 2026 22:16:14 +0100 Subject: [PATCH] perf(stats): Cache stats results for 60 seconds --- src/controllers/StatsController.ts | 93 +++++++++++++++++++++++++++++- src/nats/StatsKV.ts | 86 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/nats/StatsKV.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index b8269f6..33027fa 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -14,6 +14,7 @@ import { ResponseStats } from '../models/responses/ResponseStats'; import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; +import { getStatsCache, setStatsCache } from '../nats/StatsKV'; @JsonController('/stats') export class StatsController { @@ -22,6 +23,13 @@ export class StatsController { @ResponseSchema(ResponseStats) @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) async get() { + // Try cache first + const cached = await getStatsCache('overview'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats const connection = getConnection(); const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } }); const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } }); @@ -43,7 +51,12 @@ export class StatsController { let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] }); const donors = await connection.getRepository(Donor).count(); - return new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk) + const result = new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk); + + // Store in cache for 60 seconds + await setStatsCache('overview', result); + + return result; } @Get("/runners/distance") @@ -51,6 +64,13 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByDistance() { + // Try cache first + const cached = await getStatsCache('runners.distance'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); if (!runners || runners.length == 0) { return []; @@ -60,6 +80,10 @@ export class StatsController { topRunners.forEach(runner => { responseRunners.push(new ResponseStatsRunner(runner)); }); + + // Store in cache for 60 seconds + await setStatsCache('runners.distance', responseRunners); + return responseRunners; } @@ -68,6 +92,13 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByDonations() { + // Try cache first + const cached = await getStatsCache('runners.donations'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] }); if (!runners || runners.length == 0) { return []; @@ -77,6 +108,10 @@ export class StatsController { topRunners.forEach(runner => { responseRunners.push(new ResponseStatsRunner(runner)); }); + + // Store in cache for 60 seconds + await setStatsCache('runners.donations', responseRunners); + return responseRunners; } @@ -85,6 +120,14 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByLaptime(@QueryParam("track") track: number) { + // Try cache first (cache key includes track id, using dots for NATS KV compatibility) + const cacheKey = `runners.laptime.${track}`; + const cached = await getStatsCache(cacheKey); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] }); if (!scans || scans.length == 0) { return []; @@ -105,6 +148,10 @@ export class StatsController { topScans.forEach(scan => { responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime)); }); + + // Store in cache for 60 seconds + await setStatsCache(cacheKey, responseRunners); + return responseRunners; } @@ -121,6 +168,13 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopTeamsByDistance() { + // Try cache first + const cached = await getStatsCache('teams.distance'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] }); if (!teams || teams.length == 0) { return []; @@ -130,6 +184,10 @@ export class StatsController { topTeams.forEach(team => { responseTeams.push(new ResponseStatsTeam(team)); }); + + // Store in cache for 60 seconds + await setStatsCache('teams.distance', responseTeams); + return responseTeams; } @@ -138,6 +196,13 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopTeamsByDonations() { + // Try cache first + const cached = await getStatsCache('teams.donations'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); if (!teams || teams.length == 0) { return []; @@ -147,6 +212,10 @@ export class StatsController { topTeams.forEach(team => { responseTeams.push(new ResponseStatsTeam(team)); }); + + // Store in cache for 60 seconds + await setStatsCache('teams.donations', responseTeams); + return responseTeams; } @@ -155,6 +224,13 @@ export class StatsController { @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopOrgsByDistance() { + // Try cache first + const cached = await getStatsCache('organizations.distance'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); if (!orgs || orgs.length == 0) { return []; @@ -164,6 +240,10 @@ export class StatsController { topOrgs.forEach(org => { responseOrgs.push(new ResponseStatsOrgnisation(org)); }); + + // Store in cache for 60 seconds + await setStatsCache('organizations.distance', responseOrgs); + return responseOrgs; } @@ -172,6 +252,13 @@ export class StatsController { @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopOrgsByDonations() { + // Try cache first + const cached = await getStatsCache('organizations.donations'); + if (cached) { + return cached; + } + + // Cache miss - compute fresh stats let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] }); if (!orgs || orgs.length == 0) { return []; @@ -181,6 +268,10 @@ export class StatsController { topOrgs.forEach(org => { responseOrgs.push(new ResponseStatsOrgnisation(org)); }); + + // Store in cache for 60 seconds + await setStatsCache('organizations.donations', responseOrgs); + return responseOrgs; } } \ No newline at end of file diff --git a/src/nats/StatsKV.ts b/src/nats/StatsKV.ts new file mode 100644 index 0000000..b78c53f --- /dev/null +++ b/src/nats/StatsKV.ts @@ -0,0 +1,86 @@ +import { KvEntry } from 'nats'; +import NatsClient from './NatsClient'; + +const BUCKET = 'stats_cache'; +const TTL_SECONDS = 60; // 60 second TTL + +/** + * Stats cache stored in NATS KV with 60 second TTL. + * Used to cache expensive aggregation queries from the stats endpoints. + */ + +async function getBucket() { + return NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 }); // TTL in milliseconds +} + +/** + * Cache key patterns (using dots instead of colons for NATS KV compatibility): + * - "stats.overview" - main stats endpoint (GET /stats) + * - "stats.runners.distance" - top runners by distance + * - "stats.runners.donations" - top runners by donations + * - "stats.runners.laptime.{trackId}" - top runners by laptime for specific track + * - "stats.teams.distance" - top teams by distance + * - "stats.teams.donations" - top teams by donations + * - "stats.organizations.distance" - top organizations by distance + * - "stats.organizations.donations" - top organizations by donations + */ + +function cacheKey(path: string): string { + // Replace colons with dots for NATS KV compatibility + return `stats.${path.replace(/:/g, '.')}`; +} + +/** + * Returns the cached value for the given stats cache key, or null on a miss. + */ +export async function getStatsCache(path: string): Promise { + const bucket = await getBucket(); + let entry: KvEntry | null = null; + try { + entry = await bucket.get(cacheKey(path)); + } catch { + return null; + } + if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') { + return null; + } + return JSON.parse(entry.string()) as T; +} + +/** + * Stores a value in the stats cache with 60 second TTL. + * The TTL is applied at the bucket level, so all entries expire automatically. + */ +export async function setStatsCache(path: string, value: T): Promise { + const bucket = await getBucket(); + await bucket.put(cacheKey(path), JSON.stringify(value)); +} + +/** + * Removes the cached entry for the given stats path. + * Useful for cache invalidation when data changes. + */ +export async function deleteStatsCache(path: string): Promise { + const bucket = await getBucket(); + try { + await bucket.delete(cacheKey(path)); + } catch { + // Entry doesn't exist or already deleted - ignore + } +} + +/** + * Removes all cached stats entries. + * Call this when runners, scans, or donations are modified to ensure fresh data. + */ +export async function invalidateAllStats(): Promise { + const bucket = await getBucket(); + try { + // Purge the entire bucket to clear all cached stats + await bucket.destroy(); + // Recreate the bucket for future use + await NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 }); + } catch { + // Bucket operations can fail if bucket doesn't exist - ignore + } +}