All files / server/services simulation-admission-controller.ts

96.29% Statements 26/27
88.88% Branches 8/9
100% Functions 5/5
96.15% Lines 25/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69                                    25x 25x 25x 25x   25x     58x 2x 2x   56x 2x 2x     54x 54x 54x 54x       50x 50x 49x     49x 49x 49x       61x                 8x     10x 10x    
import { randomUUID } from "node:crypto";
import { config } from "../config";
 
export type SimulationReservation = Readonly<{
  id: string;
  subject: string;
}>;
 
export type AdmissionResult =
  | { admitted: true; reservation: SimulationReservation }
  | { admitted: false; reason: "identity" | "capacity" };
 
/**
 * Process-local admission control. reserve() and release() mutate synchronously,
 * so each decision is atomic within Node's event loop. Reservation IDs make a
 * delayed release from an old run harmless.
 */
export class SimulationAdmissionController {
  private readonly reservationsBySubject = new Map<string, SimulationReservation>();
  private readonly reservationsById = new Map<string, SimulationReservation>();
  private capacityRejectedTotal = 0;
  private identityRejectedTotal = 0;
 
  constructor(private readonly maxReservations = config.server.simulationAdmissionMax) {}
 
  reserve(subject: string): AdmissionResult {
    if (this.reservationsBySubject.has(subject)) {
      this.identityRejectedTotal++;
      return { admitted: false, reason: "identity" };
    }
    if (this.reservationsById.size >= this.maxReservations) {
      this.capacityRejectedTotal++;
      return { admitted: false, reason: "capacity" };
    }
 
    const reservation = Object.freeze({ id: randomUUID(), subject });
    this.reservationsBySubject.set(subject, reservation);
    this.reservationsById.set(reservation.id, reservation);
    return { admitted: true, reservation };
  }
 
  release(reservation: SimulationReservation): boolean {
    const current = this.reservationsById.get(reservation.id);
    if (current !== reservation) return false;
    Iif (this.reservationsBySubject.get(reservation.subject) !== reservation) {
      return false;
    }
    this.reservationsById.delete(reservation.id);
    this.reservationsBySubject.delete(reservation.subject);
    return true;
  }
 
  getStats() {
    return {
      active: this.reservationsById.size,
      max: this.maxReservations,
      capacityRejectedTotal: this.capacityRejectedTotal,
      identityRejectedTotal: this.identityRejectedTotal,
    };
  }
}
 
let instance: SimulationAdmissionController | null = null;
 
export function getSimulationAdmissionController(): SimulationAdmissionController {
  instance ??= new SimulationAdmissionController();
  return instance;
}