Compare commits

..

2 Commits
1.8.0 ... 1.8.1

Author SHA1 Message Date
7aaac65af4 chore(release): 1.8.1
Some checks failed
Build release images / build-container (push) Failing after 1m4s
2026-02-20 22:18:36 +01:00
13e0c81957 perf(stats): Cache stats results for 60 seconds 2026-02-20 22:16:14 +01:00
4 changed files with 186 additions and 2 deletions

View File

@@ -2,8 +2,15 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [1.8.1](https://git.odit.services/lfk/backend/compare/1.8.0...1.8.1)
- perf(stats): Cache stats results for 60 seconds [`13e0c81`](https://git.odit.services/lfk/backend/commit/13e0c81957768c1b380914a0b93d3617c60e08a0)
#### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0) #### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0)
> 20 February 2026
- chore(release): 1.8.0 [`329a29a`](https://git.odit.services/lfk/backend/commit/329a29aca70b8c779c592149dc1cfe197ab62463)
- refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da) - refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da)
- refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91) - refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91)
- refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94) - refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@odit/lfk-backend", "name": "@odit/lfk-backend",
"version": "1.8.0", "version": "1.8.1",
"main": "src/app.ts", "main": "src/app.ts",
"repository": "https://git.odit.services/lfk/backend", "repository": "https://git.odit.services/lfk/backend",
"author": { "author": {

View File

@@ -14,6 +14,7 @@ import { ResponseStats } from '../models/responses/ResponseStats';
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization'; import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
import { getStatsCache, setStatsCache } from '../nats/StatsKV';
@JsonController('/stats') @JsonController('/stats')
export class StatsController { export class StatsController {
@@ -22,6 +23,13 @@ export class StatsController {
@ResponseSchema(ResponseStats) @ResponseSchema(ResponseStats)
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
async get() { async get() {
// Try cache first
const cached = await getStatsCache<ResponseStats>('overview');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
const connection = getConnection(); const connection = getConnection();
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } }); const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } }); 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'] }); let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
const donors = await connection.getRepository(Donor).count(); 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") @Get("/runners/distance")
@@ -51,6 +64,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true }) @ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDistance() { 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'] }); let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
if (!runners || runners.length == 0) { if (!runners || runners.length == 0) {
return []; return [];
@@ -60,6 +80,10 @@ export class StatsController {
topRunners.forEach(runner => { topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner)); responseRunners.push(new ResponseStatsRunner(runner));
}); });
// Store in cache for 60 seconds
await setStatsCache('runners.distance', responseRunners);
return responseRunners; return responseRunners;
} }
@@ -68,6 +92,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true }) @ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDonations() { 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'] }); let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
if (!runners || runners.length == 0) { if (!runners || runners.length == 0) {
return []; return [];
@@ -77,6 +108,10 @@ export class StatsController {
topRunners.forEach(runner => { topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner)); responseRunners.push(new ResponseStatsRunner(runner));
}); });
// Store in cache for 60 seconds
await setStatsCache('runners.donations', responseRunners);
return responseRunners; return responseRunners;
} }
@@ -85,6 +120,14 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true }) @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": [] }] }) @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) { 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'] }); let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
if (!scans || scans.length == 0) { if (!scans || scans.length == 0) {
return []; return [];
@@ -105,6 +148,10 @@ export class StatsController {
topScans.forEach(scan => { topScans.forEach(scan => {
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime)); responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
}); });
// Store in cache for 60 seconds
await setStatsCache(cacheKey, responseRunners);
return responseRunners; return responseRunners;
} }
@@ -121,6 +168,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true }) @ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDistance() { 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'] }); let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
if (!teams || teams.length == 0) { if (!teams || teams.length == 0) {
return []; return [];
@@ -130,6 +184,10 @@ export class StatsController {
topTeams.forEach(team => { topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team)); responseTeams.push(new ResponseStatsTeam(team));
}); });
// Store in cache for 60 seconds
await setStatsCache('teams.distance', responseTeams);
return responseTeams; return responseTeams;
} }
@@ -138,6 +196,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true }) @ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDonations() { 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'] }); let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
if (!teams || teams.length == 0) { if (!teams || teams.length == 0) {
return []; return [];
@@ -147,6 +212,10 @@ export class StatsController {
topTeams.forEach(team => { topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team)); responseTeams.push(new ResponseStatsTeam(team));
}); });
// Store in cache for 60 seconds
await setStatsCache('teams.donations', responseTeams);
return responseTeams; return responseTeams;
} }
@@ -155,6 +224,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDistance() { 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'] }); 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) { if (!orgs || orgs.length == 0) {
return []; return [];
@@ -164,6 +240,10 @@ export class StatsController {
topOrgs.forEach(org => { topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org)); responseOrgs.push(new ResponseStatsOrgnisation(org));
}); });
// Store in cache for 60 seconds
await setStatsCache('organizations.distance', responseOrgs);
return responseOrgs; return responseOrgs;
} }
@@ -172,6 +252,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDonations() { 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'] }); 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) { if (!orgs || orgs.length == 0) {
return []; return [];
@@ -181,6 +268,10 @@ export class StatsController {
topOrgs.forEach(org => { topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org)); responseOrgs.push(new ResponseStatsOrgnisation(org));
}); });
// Store in cache for 60 seconds
await setStatsCache('organizations.donations', responseOrgs);
return responseOrgs; return responseOrgs;
} }
} }

86
src/nats/StatsKV.ts Normal file
View 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
}
}