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

97.14% Statements 34/35
93.75% Branches 15/16
100% Functions 5/5
100% Lines 32/32

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 70 71 72 73 74 75 76 77 78 79                        20x               59x 59x 59x 59x     59x 59x       141x 141x 3x 3x   138x 3x 3x     135x 135x 141x 141x 141x 141x       87x 87x 86x 86x   86x 86x 86x 86x       186x                   20x     18x 18x    
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" };
 
export const DEFAULT_MAX_SIMULATIONS_PER_SUBJECT = 5;
 
/**
 * 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, Set<SimulationReservation>>();
  private readonly reservationsById = new Map<string, SimulationReservation>();
  private capacityRejectedTotal = 0;
  private identityRejectedTotal = 0;
 
  constructor(
    private readonly maxReservations = config.capacity.admissionMax,
    private readonly maxReservationsPerSubject = DEFAULT_MAX_SIMULATIONS_PER_SUBJECT,
  ) {}
 
  reserve(subject: string): AdmissionResult {
    const subjectReservations = this.reservationsBySubject.get(subject);
    if ((subjectReservations?.size ?? 0) >= this.maxReservationsPerSubject) {
      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 });
    const reservations = subjectReservations ?? new Set<SimulationReservation>();
    reservations.add(reservation);
    this.reservationsBySubject.set(subject, reservations);
    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;
    const subjectReservations = this.reservationsBySubject.get(reservation.subject);
    Iif (!subjectReservations?.has(reservation)) return false;
 
    this.reservationsById.delete(reservation.id);
    subjectReservations.delete(reservation);
    if (subjectReservations.size === 0) this.reservationsBySubject.delete(reservation.subject);
    return true;
  }
 
  getStats() {
    return {
      active: this.reservationsById.size,
      max: this.maxReservations,
      maxPerSubject: this.maxReservationsPerSubject,
      capacityRejectedTotal: this.capacityRejectedTotal,
      identityRejectedTotal: this.identityRejectedTotal,
    };
  }
}
 
let instance: SimulationAdmissionController | null = null;
 
export function getSimulationAdmissionController(): SimulationAdmissionController {
  instance ??= new SimulationAdmissionController();
  return instance;
}