refactor: remove unneeded encapsulation in arrow function
[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_CHARGING_STATIONS_TIMEOUT,
219 )} reached at stopping charging stations`;
220 console.warn(chalk.yellow(message));
221 reject(new Error(message));
222 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT);
223 waitChargingStationEvents(
224 this,
225 ChargingStationWorkerMessageEvents.stopped,
226 this.numberOfChargingStations,
227 )
228 .then(() => {
229 resolve('Charging stations stopped');
230 })
231 .catch(reject)
232 .finally(() => {
233 clearTimeout(waitTimeout);
234 });
235 });
236 }
237
238 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
239 let elementsPerWorker: number | undefined;
240 if (workerConfiguration?.elementsPerWorker === 'auto') {
241 elementsPerWorker =
242 this.numberOfChargingStations > availableParallelism()
243 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
244 : 1;
245 }
246 this.workerImplementation === null &&
247 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
248 this.workerScript,
249 workerConfiguration.processType!,
250 {
251 workerStartDelay: workerConfiguration.startDelay,
252 elementStartDelay: workerConfiguration.elementStartDelay,
253 poolMaxSize: workerConfiguration.poolMaxSize!,
254 poolMinSize: workerConfiguration.poolMinSize!,
255 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
256 poolOptions: {
257 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
258 },
259 },
260 ));
261 }
262
263 private messageHandler(
264 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
265 ): void {
266 // logger.debug(
267 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
268 // msg,
269 // undefined,
270 // 2,
271 // )}`,
272 // );
273 try {
274 switch (msg.event) {
275 case ChargingStationWorkerMessageEvents.started:
276 this.workerEventStarted(msg.data as ChargingStationData);
277 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
278 break;
279 case ChargingStationWorkerMessageEvents.stopped:
280 this.workerEventStopped(msg.data as ChargingStationData);
281 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
282 break;
283 case ChargingStationWorkerMessageEvents.updated:
284 this.workerEventUpdated(msg.data as ChargingStationData);
285 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
286 break;
287 case ChargingStationWorkerMessageEvents.performanceStatistics:
288 this.workerEventPerformanceStatistics(msg.data as Statistics);
289 this.emit(
290 ChargingStationWorkerMessageEvents.performanceStatistics,
291 msg.data as Statistics,
292 );
293 break;
294 case ChargingStationWorkerMessageEvents.startWorkerElementError:
295 logger.error(
296 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
297 msg.data,
298 );
299 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data);
300 break;
301 case ChargingStationWorkerMessageEvents.startedWorkerElement:
302 break;
303 default:
304 throw new BaseError(
305 `Unknown charging station worker event: '${
306 msg.event
307 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
308 );
309 }
310 } catch (error) {
311 logger.error(
312 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
313 msg.event
314 }' event:`,
315 error,
316 );
317 }
318 }
319
320 private workerEventStarted = (data: ChargingStationData) => {
321 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
322 ++this.numberOfStartedChargingStations;
323 logger.info(
324 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
325 data.stationInfo.chargingStationId
326 } (hashId: ${data.stationInfo.hashId}) started (${
327 this.numberOfStartedChargingStations
328 } started from ${this.numberOfChargingStations})`,
329 );
330 };
331
332 private workerEventStopped = (data: ChargingStationData) => {
333 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
334 --this.numberOfStartedChargingStations;
335 logger.info(
336 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
337 data.stationInfo.chargingStationId
338 } (hashId: ${data.stationInfo.hashId}) stopped (${
339 this.numberOfStartedChargingStations
340 } started from ${this.numberOfChargingStations})`,
341 );
342 };
343
344 private workerEventUpdated = (data: ChargingStationData) => {
345 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
346 };
347
348 private workerEventPerformanceStatistics = (data: Statistics) => {
349 this.storage.storePerformanceStatistics(data) as void;
350 };
351
352 private initializeCounters() {
353 if (this.initializedCounters === false) {
354 this.resetCounters();
355 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
356 if (isNotEmptyArray(stationTemplateUrls)) {
357 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
358 for (const stationTemplateUrl of stationTemplateUrls) {
359 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
360 }
361 } else {
362 console.warn(
363 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
364 );
365 exit(exitCodes.missingChargingStationsConfiguration);
366 }
367 if (this.numberOfChargingStations === 0) {
368 console.warn(
369 chalk.yellow('No charging station template enabled in configuration, exiting'),
370 );
371 exit(exitCodes.noChargingStationTemplates);
372 }
373 this.initializedCounters = true;
374 }
375 }
376
377 private resetCounters(): void {
378 this.numberOfChargingStationTemplates = 0;
379 this.numberOfChargingStations = 0;
380 this.numberOfStartedChargingStations = 0;
381 }
382
383 private async startChargingStation(
384 index: number,
385 stationTemplateUrl: StationTemplateUrl,
386 ): Promise<void> {
387 await this.workerImplementation?.addElement({
388 index,
389 templateFile: join(
390 dirname(fileURLToPath(import.meta.url)),
391 'assets',
392 'station-templates',
393 stationTemplateUrl.file,
394 ),
395 });
396 }
397
398 private gracefulShutdown(): void {
399 this.stop()
400 .then(() => {
401 console.info(`${chalk.green('Graceful shutdown')}`);
402 // stop() asks for charging stations to stop by default
403 this.waitChargingStationsStopped()
404 .then(() => {
405 exit(exitCodes.succeeded);
406 })
407 .catch(() => {
408 exit(exitCodes.gracefulShutdownError);
409 });
410 })
411 .catch((error) => {
412 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
413 exit(exitCodes.gracefulShutdownError);
414 });
415 }
416
417 private logPrefix = (): string => {
418 return logPrefix(' Bootstrap |');
419 };
420 }