From d3e0206a3ccbff0e69024426bb2bf266cde30eeb Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Fri, 20 Feb 2026 19:36:38 +0100 Subject: [PATCH] refactor(scan): Implement KV-backed scan station submissions and response model --- src/controllers/RunnerCardController.ts | 23 +++-- src/controllers/RunnerController.ts | 36 ++++--- src/controllers/ScanController.ts | 114 ++++++++++++++++++++- src/models/responses/ResponseScanIntake.ts | 30 ++++++ 4 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 src/models/responses/ResponseScanIntake.ts diff --git a/src/controllers/RunnerCardController.ts b/src/controllers/RunnerCardController.ts index 4ffa340..0571f99 100644 --- a/src/controllers/RunnerCardController.ts +++ b/src/controllers/RunnerCardController.ts @@ -1,8 +1,9 @@ import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { Repository, getConnectionManager } from 'typeorm'; -import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors'; -import { RunnerNotFoundError } from '../errors/RunnerErrors'; +import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors'; +import { RunnerNotFoundError } from '../errors/RunnerErrors'; +import { deleteCardEntry } from '../nats/CardKV'; import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard'; import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard'; import { UpdateRunnerCardByCode } from '../models/actions/update/UpdateRunnerCardByCode'; @@ -109,8 +110,9 @@ export class RunnerCardController { throw new RunnerCardIdsNotMatchingError(); } - await this.cardRepository.save(await card.update(oldCard)); - return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse(); + await this.cardRepository.save(await card.update(oldCard)); + await deleteCardEntry(id); + return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse(); } @Put('/:code') @@ -151,11 +153,12 @@ export class RunnerCardController { throw new RunnerCardHasScansError(); } const scanController = new ScanController; - for (let scan of cardScans) { - await scanController.remove(scan.id, force); - } - - await this.cardRepository.delete(card); - return card.toResponse(); + for (let scan of cardScans) { + await scanController.remove(scan.id, force); + } + + await deleteCardEntry(id); + await this.cardRepository.delete(card); + return card.toResponse(); } } \ No newline at end of file diff --git a/src/controllers/RunnerController.ts b/src/controllers/RunnerController.ts index 454f08d..2f8d4d3 100644 --- a/src/controllers/RunnerController.ts +++ b/src/controllers/RunnerController.ts @@ -1,18 +1,19 @@ -import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; -import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; -import { Repository, getConnectionManager } from 'typeorm'; -import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors'; -import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors'; -import { CreateRunner } from '../models/actions/create/CreateRunner'; -import { UpdateRunner } from '../models/actions/update/UpdateRunner'; -import { Runner } from '../models/entities/Runner'; -import { ResponseEmpty } from '../models/responses/ResponseEmpty'; -import { ResponseRunner } from '../models/responses/ResponseRunner'; -import { ResponseScan } from '../models/responses/ResponseScan'; -import { ResponseTrackScan } from '../models/responses/ResponseTrackScan'; -import { DonationController } from './DonationController'; -import { RunnerCardController } from './RunnerCardController'; -import { ScanController } from './ScanController'; +import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { Repository, getConnectionManager } from 'typeorm'; +import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors'; +import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors'; +import { deleteRunnerEntry } from '../nats/RunnerKV'; +import { CreateRunner } from '../models/actions/create/CreateRunner'; +import { UpdateRunner } from '../models/actions/update/UpdateRunner'; +import { Runner } from '../models/entities/Runner'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseRunner } from '../models/responses/ResponseRunner'; +import { ResponseScan } from '../models/responses/ResponseScan'; +import { ResponseTrackScan } from '../models/responses/ResponseTrackScan'; +import { DonationController } from './DonationController'; +import { RunnerCardController } from './RunnerCardController'; +import { ScanController } from './ScanController'; @JsonController('/runners') @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @@ -125,8 +126,9 @@ export class RunnerController { throw new RunnerIdsNotMatchingError(); } - await this.runnerRepository.save(await runner.update(oldRunner)); - return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true); + await this.runnerRepository.save(await runner.update(oldRunner)); + await deleteRunnerEntry(id); + return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true); } @Delete('/:id') diff --git a/src/controllers/ScanController.ts b/src/controllers/ScanController.ts index a2179b2..79e1017 100644 --- a/src/controllers/ScanController.ts +++ b/src/controllers/ScanController.ts @@ -1,19 +1,24 @@ import { Request } from "express"; -import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers'; +import { Authorized, Body, Delete, Get, HttpError, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; -import { Repository, getConnectionManager } from 'typeorm'; +import { Repository, getConnection, getConnectionManager } from 'typeorm'; import { RunnerNotFoundError } from '../errors/RunnerErrors'; import { ScanIdsNotMatchingError, ScanNotFoundError } from '../errors/ScanErrors'; import { ScanStationNotFoundError } from '../errors/ScanStationErrors'; import ScanAuth from '../middlewares/ScanAuth'; +import { deleteCardEntry, getCardEntry, setCardEntry } from '../nats/CardKV'; +import { deleteRunnerEntry, getRunnerEntry, RunnerKVEntry, setRunnerEntry, warmRunner } from '../nats/RunnerKV'; +import { getStationEntryById } from '../nats/StationKV'; import { CreateScan } from '../models/actions/create/CreateScan'; import { CreateTrackScan } from '../models/actions/create/CreateTrackScan'; import { UpdateScan } from '../models/actions/update/UpdateScan'; import { UpdateTrackScan } from '../models/actions/update/UpdateTrackScan'; +import { RunnerCard } from '../models/entities/RunnerCard'; import { Scan } from '../models/entities/Scan'; import { TrackScan } from '../models/entities/TrackScan'; import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseScan } from '../models/responses/ResponseScan'; +import { ResponseScanIntake, ResponseScanIntakeRunner } from '../models/responses/ResponseScanIntake'; import { ResponseTrackScan } from '../models/responses/ResponseTrackScan'; @JsonController('/scans') @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @@ -77,17 +82,112 @@ export class ScanController { @Post("/trackscans") @UseBefore(ScanAuth) @ResponseSchema(ResponseTrackScan) + @ResponseSchema(ResponseScanIntake) @ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) @OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead).
Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) { + // Station token path — KV-backed intake flow if (req.isStationAuth) { - createScan.station = req.stationId; + return this.stationIntake(createScan.card, req.stationId); } + // JWT path — existing full flow, unchanged + createScan.station = createScan.station; let scan = await createScan.toEntity(); scan = await this.trackScanRepository.save(scan); return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse(); } + /** + * KV-backed hot path for scan station submissions. + * Zero DB reads on a fully warm cache. Fixes the race condition via CAS on the runner KV entry. + */ + private async stationIntake(rawCard: number, stationId: number): Promise { + const MAX_RETRIES = 3; + const cardId = rawCard % 200000000000; + + // --- Station (already verified by ScanAuth, just need track data) --- + const stationEntry = await getStationEntryById(stationId); + // stationEntry is always populated here — ScanAuth wrote it on the cold path + const trackDistance = stationEntry.trackDistance; + const minimumLapTime = stationEntry.minimumLapTime; + + // --- Card --- + let cardEntry = await getCardEntry(cardId); + if (!cardEntry) { + // Cold path: load from DB and cache + const card = await getConnection().getRepository(RunnerCard).findOne({ id: cardId }, { relations: ['runner'] }); + if (!card) throw new ScanNotFoundError(); + if (!card.runner) throw new RunnerNotFoundError(); + cardEntry = { + runnerId: card.runner.id, + runnerDisplayName: `${card.runner.firstname} ${card.runner.lastname}`, + enabled: card.enabled, + }; + await setCardEntry(cardId, cardEntry); + } + if (!cardEntry.enabled) throw new HttpError(400, 'Card is disabled.'); + const runnerId = cardEntry.runnerId; + + // --- Runner state + CAS update (fixes race condition) --- + const now = Math.round(Date.now() / 1000); + let retries = 0; + let response: ResponseScanIntake; + + while (retries < MAX_RETRIES) { + // Get current runner state (warm or cold) + let result = await getRunnerEntry(runnerId); + if (!result) { + const warmed = await warmRunner(runnerId); + result = { entry: warmed, revision: undefined }; + } + const { entry, revision } = result; + + // Compute + const lapTime = entry.latestTimestamp === 0 ? 0 : now - entry.latestTimestamp; + const valid = minimumLapTime === 0 || lapTime > minimumLapTime; + const newDistance = entry.totalDistance + (valid ? trackDistance : 0); + const newTimestamp = valid ? now : entry.latestTimestamp; + + const updated: RunnerKVEntry = { + displayName: entry.displayName, + totalDistance: newDistance, + latestTimestamp: newTimestamp, + }; + + // CAS write — if revision is undefined (warmed this request), plain put + const success = await setRunnerEntry(runnerId, updated, revision); + if (!success) { + retries++; + continue; + } + + // DB insert — synchronous, keeps DB as source of truth + const newScan = new TrackScan(); + newScan.runner = { id: runnerId } as any; + newScan.card = { id: cardId } as any; + newScan.station = { id: stationId } as any; + newScan.track = { id: stationEntry.trackId } as any; + newScan.timestamp = now; + newScan.lapTime = lapTime; + newScan.valid = valid; + await this.trackScanRepository.save(newScan); + + const runnerInfo = new ResponseScanIntakeRunner(); + runnerInfo.displayName = entry.displayName; + runnerInfo.totalDistance = newDistance; + + response = new ResponseScanIntake(); + response.accepted = true; + response.valid = valid; + response.lapTime = lapTime; + response.runner = runnerInfo; + + return response; + } + + throw new HttpError(409, 'Scan rejected: too many concurrent scans for this runner. Please retry.'); + } + @Put('/:id') @Authorized("SCAN:UPDATE") @ResponseSchema(ResponseScan) @@ -96,7 +196,7 @@ export class ScanController { @ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 }) @OpenAPI({ description: "Update the scan (not track scan use /scans/trackscans/:id instead) whose id you provided.
Please remember that ids can't be changed and distances must be positive." }) async put(@Param('id') id: number, @Body({ validate: true }) scan: UpdateScan) { - let oldScan = await this.scanRepository.findOne({ id: id }); + let oldScan = await this.scanRepository.findOne({ id: id }, { relations: ['runner'] }); if (!oldScan) { throw new ScanNotFoundError(); @@ -106,7 +206,9 @@ export class ScanController { throw new ScanIdsNotMatchingError(); } + const runnerId = oldScan.runner?.id; await this.scanRepository.save(await scan.update(oldScan)); + if (runnerId) await deleteRunnerEntry(runnerId); return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse(); } @@ -119,7 +221,7 @@ export class ScanController { @ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 }) @OpenAPI({ description: 'Update the track scan (not "normal" scan use /scans/trackscans/:id instead) whose id you provided.
Please remember that only the validity, runner and track can be changed.' }) async putTrackScan(@Param('id') id: number, @Body({ validate: true }) scan: UpdateTrackScan) { - let oldScan = await this.trackScanRepository.findOne({ id: id }); + let oldScan = await this.trackScanRepository.findOne({ id: id }, { relations: ['runner'] }); if (!oldScan) { throw new ScanNotFoundError(); @@ -129,7 +231,9 @@ export class ScanController { throw new ScanIdsNotMatchingError(); } + const runnerId = oldScan.runner?.id; await this.trackScanRepository.save(await scan.update(oldScan)); + if (runnerId) await deleteRunnerEntry(runnerId); return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse(); } diff --git a/src/models/responses/ResponseScanIntake.ts b/src/models/responses/ResponseScanIntake.ts new file mode 100644 index 0000000..d860995 --- /dev/null +++ b/src/models/responses/ResponseScanIntake.ts @@ -0,0 +1,30 @@ +import { IsBoolean, IsInt, IsNotEmpty, IsObject, IsString } from 'class-validator'; + +/** + * Lightweight response returned to scan stations after a TrackScan submission. + * Contains only what the scan display needs — validity, lap time, and runner info. + * Full ResponseTrackScan is still returned to JWT-authenticated admin/UI callers. + */ +export class ResponseScanIntakeRunner { + @IsString() + @IsNotEmpty() + displayName: string; + + @IsInt() + totalDistance: number; +} + +export class ResponseScanIntake { + @IsBoolean() + accepted: boolean; + + @IsBoolean() + valid: boolean; + + @IsInt() + lapTime: number; + + @IsObject() + @IsNotEmpty() + runner: ResponseScanIntakeRunner; +}