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