perf(stats): Cache stats results for 60 seconds
This commit is contained in:
@@ -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<ResponseStats>('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<ResponseStatsRunner[]>('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<ResponseStatsRunner[]>('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<ResponseStatsRunner[]>(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<ResponseStatsTeam[]>('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<ResponseStatsTeam[]>('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<ResponseStatsOrgnisation[]>('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<ResponseStatsOrgnisation[]>('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;
|
||||
}
|
||||
}
|
||||
86
src/nats/StatsKV.ts
Normal file
86
src/nats/StatsKV.ts
Normal file
@@ -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<T>(path: string): Promise<T | null> {
|
||||
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<T>(path: string, value: T): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user