build: rollup -> esbuild
[e-mobility-charging-stations-simulator.git] / src / charging-station / Bootstrap.ts
1 // Partial Copyright Jerome Benoit. 2021-2023. All Rights Reserved.
2
3 import { EventEmitter } from 'node:events';
4 import { dirname, extname, join } from 'node:path';
5 import { exit } from 'node:process';
6 import { fileURLToPath } from 'node:url';
7 import { isMainThread } from 'node:worker_threads';
8
9 import chalk from 'chalk';
10 import { availableParallelism } from 'poolifier';
11
12 import { waitChargingStationEvents } from './Helpers';
13 import type { AbstractUIServer } from './ui-server/AbstractUIServer';
14 import { UIServerFactory } from './ui-server/UIServerFactory';
15 import { version } from '../../package.json';
16 import { BaseError } from '../exception';
17 import { type Storage, StorageFactory } from '../performance';
18 import {
19 type ChargingStationData,
20 type ChargingStationWorkerData,
21 type ChargingStationWorkerMessage,
22 type ChargingStationWorkerMessageData,
23 ChargingStationWorkerMessageEvents,
24 ConfigurationSection,
25 ProcedureName,
26 type StationTemplateUrl,
27 type Statistics,
28 type StorageConfiguration,
29 type UIServerConfiguration,
30 type WorkerConfiguration,
31 } from '../types';
32 import {
33 Configuration,
34 Constants,
35 formatDurationMilliSeconds,
36 generateUUID,
37 handleUncaughtException,
38 handleUnhandledRejection,
39 isNotEmptyArray,
40 isNullOrUndefined,
41 logPrefix,
42 logger,
43 } from '../utils';
44 import { type WorkerAbstract, WorkerFactory } from '../worker';
45
46 const moduleName = 'Bootstrap';
47
48 enum exitCodes {
49 succeeded = 0,
50 missingChargingStationsConfiguration = 1,
51 noChargingStationTemplates = 2,
52 gracefulShutdownError = 3,
53 }
54
55 export class Bootstrap extends EventEmitter {
56 private static instance: Bootstrap | null = null;
57 public numberOfChargingStations!: number;
58 public numberOfChargingStationTemplates!: number;
59 private workerImplementation: WorkerAbstract<ChargingStationWorkerData> | null;
60 private readonly uiServer!: AbstractUIServer | null;
61 private readonly storage!: Storage;
62 private numberOfStartedChargingStations!: number;
63 private readonly version: string = version;
64 private initializedCounters: boolean;
65 private started: boolean;
66 private starting: boolean;
67 private stopping: boolean;
68 private readonly workerScript: string;
69
70 private constructor() {
71 super();
72 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
73 process.on(signal, this.gracefulShutdown);
74 }
75 // Enable unconditionally for now
76 handleUnhandledRejection();
77 handleUncaughtException();
78 this.started = false;
79 this.starting = false;
80 this.stopping = false;
81 this.initializedCounters = false;
82 this.initializeCounters();
83 this.workerImplementation = null;
84 this.workerScript = join(
85 dirname(fileURLToPath(import.meta.url)),
86 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`,
87 );
88 const uiServerConfiguration = Configuration.getConfigurationSection<UIServerConfiguration>(
89 ConfigurationSection.uiServer,
90 );
91 uiServerConfiguration.enabled === true &&
92 (this.uiServer = UIServerFactory.getUIServerImplementation(uiServerConfiguration));
93 const performanceStorageConfiguration =
94 Configuration.getConfigurationSection<StorageConfiguration>(
95 ConfigurationSection.performanceStorage,
96 );
97 performanceStorageConfiguration.enabled === true &&
98 (this.storage = StorageFactory.getStorage(
99 performanceStorageConfiguration.type!,
100 performanceStorageConfiguration.uri!,
101 this.logPrefix(),
102 ));
103 Configuration.configurationChangeCallback = async () => Bootstrap.getInstance().restart();
104 }
105
106 public static getInstance(): Bootstrap {
107 if (Bootstrap.instance === null) {
108 Bootstrap.instance = new Bootstrap();
109 }
110 return Bootstrap.instance;
111 }
112
113 public async start(): Promise<void> {
114 if (!isMainThread) {
115 throw new BaseError('Cannot start charging stations simulator from worker thread');
116 }
117 if (this.started === false) {
118 if (this.starting === false) {
119 this.starting = true;
120 this.initializeCounters();
121 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
122 ConfigurationSection.worker,
123 );
124 this.initializeWorkerImplementation(workerConfiguration);
125 await this.workerImplementation?.start();
126 await this.storage?.open();
127 this.uiServer?.start();
128 // Start ChargingStation object instance in worker thread
129 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
130 try {
131 const nbStations = stationTemplateUrl.numberOfStations ?? 0;
132 for (let index = 1; index <= nbStations; index++) {
133 await this.startChargingStation(index, stationTemplateUrl);
134 }
135 } catch (error) {
136 console.error(
137 chalk.red(
138 `Error at starting charging station with template file ${stationTemplateUrl.file}: `,
139 ),
140 error,
141 );
142 }
143 }
144 console.info(
145 chalk.green(
146 `Charging stations simulator ${
147 this.version
148 } started with ${this.numberOfChargingStations.toString()} charging station(s) from ${this.numberOfChargingStationTemplates.toString()} configured charging station template(s) and ${
149 Configuration.workerDynamicPoolInUse()
150 ? `${workerConfiguration.poolMinSize?.toString()}/`
151 : ''
152 }${this.workerImplementation?.size}${
153 Configuration.workerPoolInUse()
154 ? `/${workerConfiguration.poolMaxSize?.toString()}`
155 : ''
156 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
157 !isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
158 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
159 : ''
160 }`,
161 ),
162 );
163 Configuration.workerDynamicPoolInUse() &&
164 console.warn(
165 chalk.yellow(
166 'Charging stations simulator is using dynamic pool mode. This is an experimental feature with known issues.\nPlease consider using fixed pool or worker set mode instead',
167 ),
168 );
169 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info);
170 this.started = true;
171 this.starting = false;
172 } else {
173 console.error(chalk.red('Cannot start an already starting charging stations simulator'));
174 }
175 } else {
176 console.error(chalk.red('Cannot start an already started charging stations simulator'));
177 }
178 }
179
180 public async stop(): Promise<void> {
181 if (!isMainThread) {
182 throw new BaseError('Cannot stop charging stations simulator from worker thread');
183 }
184 if (this.started === true) {
185 if (this.stopping === false) {
186 this.stopping = true;
187 await this.uiServer?.sendInternalRequest(
188 this.uiServer.buildProtocolRequest(
189 generateUUID(),
190 ProcedureName.STOP_CHARGING_STATION,
191 Constants.EMPTY_FROZEN_OBJECT,
192 ),
193 );
194 await Promise.race([
195 waitChargingStationEvents(
196 this,
197 ChargingStationWorkerMessageEvents.stopped,
198 this.numberOfChargingStations,
199 ),
200 new Promise<string>((resolve) => {
201 setTimeout(() => {
202 const message = `Timeout reached ${formatDurationMilliSeconds(
203 Constants.STOP_SIMULATOR_TIMEOUT,
204 )} at stopping charging stations simulator`;
205 console.warn(chalk.yellow(message));
206 resolve(message);
207 }, Constants.STOP_SIMULATOR_TIMEOUT);
208 }),
209 ]);
210 await this.workerImplementation?.stop();
211 this.workerImplementation = null;
212 this.uiServer?.stop();
213 await this.storage?.close();
214 this.resetCounters();
215 this.initializedCounters = false;
216 this.started = false;
217 this.stopping = false;
218 } else {
219 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'));
220 }
221 } else {
222 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
223 }
224 }
225
226 public async restart(): Promise<void> {
227 await this.stop();
228 await this.start();
229 }
230
231 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
232 let elementsPerWorker: number | undefined;
233 if (workerConfiguration?.elementsPerWorker === 'auto') {
234 elementsPerWorker =
235 this.numberOfChargingStations > availableParallelism()
236 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
237 : 1;
238 }
239 this.workerImplementation === null &&
240 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
241 this.workerScript,
242 workerConfiguration.processType!,
243 {
244 workerStartDelay: workerConfiguration.startDelay,
245 elementStartDelay: workerConfiguration.elementStartDelay,
246 poolMaxSize: workerConfiguration.poolMaxSize!,
247 poolMinSize: workerConfiguration.poolMinSize!,
248 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
249 poolOptions: {
250 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
251 },
252 },
253 ));
254 }
255
256 private messageHandler(
257 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
258 ): void {
259 // logger.debug(
260 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
261 // msg,
262 // undefined,
263 // 2,
264 // )}`,
265 // );
266 try {
267 switch (msg.event) {
268 case ChargingStationWorkerMessageEvents.started:
269 this.workerEventStarted(msg.data as ChargingStationData);
270 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
271 break;
272 case ChargingStationWorkerMessageEvents.stopped:
273 this.workerEventStopped(msg.data as ChargingStationData);
274 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
275 break;
276 case ChargingStationWorkerMessageEvents.updated:
277 this.workerEventUpdated(msg.data as ChargingStationData);
278 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
279 break;
280 case ChargingStationWorkerMessageEvents.performanceStatistics:
281 this.workerEventPerformanceStatistics(msg.data as Statistics);
282 this.emit(
283 ChargingStationWorkerMessageEvents.performanceStatistics,
284 msg.data as Statistics,
285 );
286 break;
287 case ChargingStationWorkerMessageEvents.startWorkerElementError:
288 logger.error(
289 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
290 msg.data,
291 );
292 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data);
293 break;
294 case ChargingStationWorkerMessageEvents.startedWorkerElement:
295 break;
296 default:
297 throw new BaseError(
298 `Unknown charging station worker event: '${
299 msg.event
300 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
301 );
302 }
303 } catch (error) {
304 logger.error(
305 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
306 msg.event
307 }' event:`,
308 error,
309 );
310 }
311 }
312
313 private workerEventStarted = (data: ChargingStationData) => {
314 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
315 ++this.numberOfStartedChargingStations;
316 logger.info(
317 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
318 data.stationInfo.chargingStationId
319 } (hashId: ${data.stationInfo.hashId}) started (${
320 this.numberOfStartedChargingStations
321 } started from ${this.numberOfChargingStations})`,
322 );
323 };
324
325 private workerEventStopped = (data: ChargingStationData) => {
326 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
327 --this.numberOfStartedChargingStations;
328 logger.info(
329 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
330 data.stationInfo.chargingStationId
331 } (hashId: ${data.stationInfo.hashId}) stopped (${
332 this.numberOfStartedChargingStations
333 } started from ${this.numberOfChargingStations})`,
334 );
335 };
336
337 private workerEventUpdated = (data: ChargingStationData) => {
338 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
339 };
340
341 private workerEventPerformanceStatistics = (data: Statistics) => {
342 this.storage.storePerformanceStatistics(data) as void;
343 };
344
345 private initializeCounters() {
346 if (this.initializedCounters === false) {
347 this.resetCounters();
348 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
349 if (isNotEmptyArray(stationTemplateUrls)) {
350 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
351 for (const stationTemplateUrl of stationTemplateUrls) {
352 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
353 }
354 } else {
355 console.warn(
356 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
357 );
358 exit(exitCodes.missingChargingStationsConfiguration);
359 }
360 if (this.numberOfChargingStations === 0) {
361 console.warn(
362 chalk.yellow('No charging station template enabled in configuration, exiting'),
363 );
364 exit(exitCodes.noChargingStationTemplates);
365 }
366 this.initializedCounters = true;
367 }
368 }
369
370 private resetCounters(): void {
371 this.numberOfChargingStationTemplates = 0;
372 this.numberOfChargingStations = 0;
373 this.numberOfStartedChargingStations = 0;
374 }
375
376 private async startChargingStation(
377 index: number,
378 stationTemplateUrl: StationTemplateUrl,
379 ): Promise<void> {
380 await this.workerImplementation?.addElement({
381 index,
382 templateFile: join(
383 dirname(fileURLToPath(import.meta.url)),
384 'assets',
385 'station-templates',
386 stationTemplateUrl.file,
387 ),
388 });
389 }
390
391 private gracefulShutdown = (): void => {
392 this.stop()
393 .then(() => {
394 console.info(`${chalk.green('Graceful shutdown')}`);
395 exit(exitCodes.succeeded);
396 })
397 .catch((error) => {
398 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
399 exit(exitCodes.gracefulShutdownError);
400 });
401 };
402
403 private logPrefix = (): string => {
404 return logPrefix(' Bootstrap |');
405 };
406 }