diff --git a/src/config.ts b/src/config.ts index 9f348a1..fbda9c6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { config as configDotenv } from 'dotenv'; import { CountryCode } from 'libphonenumber-js'; import ValidatorJS from 'validator'; -configDotenv(); +configDotenv(); export const config = { internal_port: parseInt(process.env.APP_PORT) || 4010, development: process.env.NODE_ENV === "production", @@ -11,16 +11,17 @@ export const config = { jwt_secret: process.env.JWT_SECRET || "secretjwtsecret", station_token_secret: process.env.STATION_TOKEN_SECRET || "", nats_url: process.env.NATS_URL || "nats://localhost:4222", - phone_validation_countrycode: getPhoneCodeLocale(), - postalcode_validation_countrycode: getPostalCodeLocale(), - version: process.env.VERSION || require('../package.json').version, - seedTestData: getDataSeeding(), - app_url: process.env.APP_URL || "http://localhost:8080", - privacy_url: process.env.PRIVACY_URL || "/privacy", - imprint_url: process.env.IMPRINT_URL || "/imprint", - mailer_url: process.env.MAILER_URL || "", - mailer_key: process.env.MAILER_KEY || "" -} + nats_prewarm: process.env.NATS_PREWARM === "true", + phone_validation_countrycode: getPhoneCodeLocale(), + postalcode_validation_countrycode: getPostalCodeLocale(), + version: process.env.VERSION || require('../package.json').version, + seedTestData: getDataSeeding(), + app_url: process.env.APP_URL || "http://localhost:8080", + privacy_url: process.env.PRIVACY_URL || "/privacy", + imprint_url: process.env.IMPRINT_URL || "/imprint", + mailer_url: process.env.MAILER_URL || "", + mailer_key: process.env.MAILER_KEY || "" +} let errors = 0 if (typeof config.internal_port !== "number") { consola.error("Error: APP_PORT is not a number") diff --git a/src/loaders/index.ts b/src/loaders/index.ts index 5972028..96b6e34 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -1,5 +1,8 @@ import { Application } from "express"; +import consola from "consola"; +import { config } from "../config"; import NatsClient from "../nats/NatsClient"; +import { warmAll } from "../nats/RunnerKV"; import databaseLoader from "./database"; import expressLoader from "./express"; import openapiLoader from "./openapi"; @@ -11,6 +14,15 @@ import openapiLoader from "./openapi"; export default async (app: Application) => { await databaseLoader(); await NatsClient.connect(); + + if (config.nats_prewarm) { + consola.info("Prewarming NATS runner cache..."); + const startTime = Date.now(); + await warmAll(); + const duration = Date.now() - startTime; + consola.success(`NATS runner cache prewarmed in ${duration}ms`); + } + await openapiLoader(app); await expressLoader(app); return app; diff --git a/src/nats/RunnerKV.ts b/src/nats/RunnerKV.ts index 6ab9d62..6302cd9 100644 --- a/src/nats/RunnerKV.ts +++ b/src/nats/RunnerKV.ts @@ -126,3 +126,65 @@ export async function warmRunner(runnerId: number): Promise { await setRunnerEntry(runnerId, entry); return entry; } + +/** + * Bulk cache prewarming: loads all runners from the database and populates the KV cache. + * Uses 3 efficient queries and parallel KV writes to minimize startup time. + * + * Call from loader during startup (if NATS_PREWARM=true) to eliminate DB reads on the hot + * path from the very first scan. + */ +export async function warmAll(): Promise { + const connection = getConnection(); + + // Query 1: All runners + const runners = await connection + .getRepository(Runner) + .createQueryBuilder('runner') + .select(['runner.id', 'runner.firstname', 'runner.lastname']) + .getMany(); + + // Query 2: Total valid distance per runner + const distanceResults = await connection + .getRepository(TrackScan) + .createQueryBuilder('scan') + .select('scan.runner', 'runnerId') + .addSelect('COALESCE(SUM(track.distance), 0)', 'total') + .innerJoin('scan.track', 'track') + .where('scan.valid = :valid', { valid: true }) + .groupBy('scan.runner') + .getRawMany(); + + // Query 3: Latest valid scan timestamp per runner + const latestResults = await connection + .getRepository(TrackScan) + .createQueryBuilder('scan') + .select('scan.runner', 'runnerId') + .addSelect('MAX(scan.timestamp)', 'latestTimestamp') + .where('scan.valid = :valid', { valid: true }) + .groupBy('scan.runner') + .getRawMany(); + + // Build lookup maps + const distanceMap = new Map(); + distanceResults.forEach((row: any) => { + distanceMap.set(parseInt(row.runnerId, 10), parseInt(row.total, 10)); + }); + + const latestMap = new Map(); + latestResults.forEach((row: any) => { + latestMap.set(parseInt(row.runnerId, 10), parseInt(row.latestTimestamp, 10)); + }); + + // Write all entries in parallel + const writePromises = runners.map((runner) => { + const entry: RunnerKVEntry = { + displayName: `${runner.firstname} ${runner.lastname}`, + totalDistance: distanceMap.get(runner.id) ?? 0, + latestTimestamp: latestMap.get(runner.id) ?? 0, + }; + return setRunnerEntry(runner.id, entry); + }); + + await Promise.all(writePromises); +}