fix: disable dynamic reload until spurious file change is identified
[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
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);
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 const uiServerConfiguration = Configuration.getConfigurationSection<UIServerConfiguration>(
88 ConfigurationSection.uiServer,
89 );
90 uiServerConfiguration.enabled === true &&
91 (this.uiServer = UIServerFactory.getUIServerImplementation(uiServerConfiguration));
92 const performanceStorageConfiguration =
93 Configuration.getConfigurationSection<StorageConfiguration>(
94 ConfigurationSection.performanceStorage,
95 );
96 performanceStorageConfiguration.enabled === true &&
97 (this.storage = StorageFactory.getStorage(
98 performanceStorageConfiguration.type!,
99 performanceStorageConfiguration.uri!,
100 this.logPrefix(),
101 ));
102 Configuration.configurationChangeCallback = async () => Bootstrap.getInstance().restart(false);
103 }
104
105 public static getInstance(): Bootstrap {
106 if (Bootstrap.instance === null) {
107 Bootstrap.instance = new Bootstrap();
108 }
109 return Bootstrap.instance;
110 }
111
112 public async start(): Promise<void> {
113 if (this.started === false) {
114 if (this.starting === false) {
115 this.starting = true;
116 this.initializeCounters();
117 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
118 ConfigurationSection.worker,
119 );
120 this.initializeWorkerImplementation(workerConfiguration);
121 await this.workerImplementation?.start();
122 await this.storage?.open();
123 this.uiServer?.start();
124 // Start ChargingStation object instance in worker thread
125 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
126 try {
127 const nbStations = stationTemplateUrl.numberOfStations ?? 0;
128 for (let index = 1; index <= nbStations; index++) {
129 await this.startChargingStation(index, stationTemplateUrl);
130 }
131 } catch (error) {
132 console.error(
133 chalk.red(
134 `Error at starting charging station with template file ${stationTemplateUrl.file}: `,
135 ),
136 error,
137 );
138 }
139 }
140 console.info(
141 chalk.green(
142 `Charging stations simulator ${
143 this.version
144 } started with ${this.numberOfChargingStations.toString()} charging station(s) from ${this.numberOfChargingStationTemplates.toString()} configured charging station template(s) and ${
145 Configuration.workerDynamicPoolInUse()
146 ? `${workerConfiguration.poolMinSize?.toString()}/`
147 : ''
148 }${this.workerImplementation?.size}${
149 Configuration.workerPoolInUse()
150 ? `/${workerConfiguration.poolMaxSize?.toString()}`
151 : ''
152 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
153 !isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
154 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
155 : ''
156 }`,
157 ),
158 );
159 Configuration.workerDynamicPoolInUse() &&
160 console.warn(
161 chalk.yellow(
162 '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',
163 ),
164 );
165 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info);
166 this.started = true;
167 this.starting = false;
168 } else {
169 console.error(chalk.red('Cannot start an already starting charging stations simulator'));
170 }
171 } else {
172 console.error(chalk.red('Cannot start an already started charging stations simulator'));
173 }
174 }
175
176 public async stop(waitChargingStationsStopped = true): Promise<void> {
177 if (this.started === true) {
178 if (this.stopping === false) {
179 this.stopping = 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 if (waitChargingStationsStopped === true) {
188 await Promise.race([
189 waitChargingStationEvents(
190 this,
191 ChargingStationWorkerMessageEvents.stopped,
192 this.numberOfChargingStations,
193 ),
194 new Promise<string>((resolve) => {
195 setTimeout(() => {
196 const message = `Timeout ${formatDurationMilliSeconds(
197 Constants.STOP_SIMULATOR_TIMEOUT,
198 )} reached at stopping charging stations simulator`;
199 console.warn(chalk.yellow(message));
200 resolve(message);
201 }, Constants.STOP_SIMULATOR_TIMEOUT);
202 }),
203 ]);
204 }
205 await this.workerImplementation?.stop();
206 this.workerImplementation = null;
207 this.uiServer?.stop();
208 await this.storage?.close();
209 this.resetCounters();
210 this.initializedCounters = false;
211 this.started = false;
212 this.stopping = false;
213 } else {
214 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'));
215 }
216 } else {
217 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
218 }
219 }
220
221 public async restart(waitChargingStationsStopped?: boolean): Promise<void> {
222 await this.stop(waitChargingStationsStopped);
223 await this.start();
224 }
225
226 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
227 let elementsPerWorker: number | undefined;
228 if (workerConfiguration?.elementsPerWorker === 'auto') {
229 elementsPerWorker =
230 this.numberOfChargingStations > availableParallelism()
231 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
232 : 1;
233 }
234 this.workerImplementation === null &&
235 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
236 this.workerScript,
237 workerConfiguration.processType!,
238 {
239 workerStartDelay: workerConfiguration.startDelay,
240 elementStartDelay: workerConfiguration.elementStartDelay,
241 poolMaxSize: workerConfiguration.poolMaxSize!,
242 poolMinSize: workerConfiguration.poolMinSize!,
243 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
244 poolOptions: {
245 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
246 },
247 },
248 ));
249 }
250
251 private messageHandler(
252 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
253 ): void {
254 // logger.debug(
255 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
256 // msg,
257 // undefined,
258 // 2,
259 // )}`,
260 // );
261 try {
262 switch (msg.event) {
263 case ChargingStationWorkerMessageEvents.started:
264 this.workerEventStarted(msg.data as ChargingStationData);
265 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
266 break;
267 case ChargingStationWorkerMessageEvents.stopped:
268 this.workerEventStopped(msg.data as ChargingStationData);
269 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
270 break;
271 case ChargingStationWorkerMessageEvents.updated:
272 this.workerEventUpdated(msg.data as ChargingStationData);
273 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
274 break;
275 case ChargingStationWorkerMessageEvents.performanceStatistics:
276 this.workerEventPerformanceStatistics(msg.data as Statistics);
277 this.emit(
278 ChargingStationWorkerMessageEvents.performanceStatistics,
279 msg.data as Statistics,
280 );
281 break;
282 case ChargingStationWorkerMessageEvents.startWorkerElementError:
283 logger.error(
284 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
285 msg.data,
286 );
287 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data);
288 break;
289 case ChargingStationWorkerMessageEvents.startedWorkerElement:
290 break;
291 default:
292 throw new BaseError(
293 `Unknown charging station worker event: '${
294 msg.event
295 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
296 );
297 }
298 } catch (error) {
299 logger.error(
300 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
301 msg.event
302 }' event:`,
303 error,
304 );
305 }
306 }
307
308 private workerEventStarted = (data: ChargingStationData) => {
309 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
310 ++this.numberOfStartedChargingStations;
311 logger.info(
312 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
313 data.stationInfo.chargingStationId
314 } (hashId: ${data.stationInfo.hashId}) started (${
315 this.numberOfStartedChargingStations
316 } started from ${this.numberOfChargingStations})`,
317 );
318 };
319
320 private workerEventStopped = (data: ChargingStationData) => {
321 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
322 --this.numberOfStartedChargingStations;
323 logger.info(
324 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
325 data.stationInfo.chargingStationId
326 } (hashId: ${data.stationInfo.hashId}) stopped (${
327 this.numberOfStartedChargingStations
328 } started from ${this.numberOfChargingStations})`,
329 );
330 };
331
332 private workerEventUpdated = (data: ChargingStationData) => {
333 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
334 };
335
336 private workerEventPerformanceStatistics = (data: Statistics) => {
337 this.storage.storePerformanceStatistics(data) as void;
338 };
339
340 private initializeCounters() {
341 if (this.initializedCounters === false) {
342 this.resetCounters();
343 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
344 if (isNotEmptyArray(stationTemplateUrls)) {
345 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
346 for (const stationTemplateUrl of stationTemplateUrls) {
347 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
348 }
349 } else {
350 console.warn(
351 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
352 );
353 exit(exitCodes.missingChargingStationsConfiguration);
354 }
355 if (this.numberOfChargingStations === 0) {
356 console.warn(
357 chalk.yellow('No charging station template enabled in configuration, exiting'),
358 );
359 exit(exitCodes.noChargingStationTemplates);
360 }
361 this.initializedCounters = true;
362 }
363 }
364
365 private resetCounters(): void {
366 this.numberOfChargingStationTemplates = 0;
367 this.numberOfChargingStations = 0;
368 this.numberOfStartedChargingStations = 0;
369 }
370
371 private async startChargingStation(
372 index: number,
373 stationTemplateUrl: StationTemplateUrl,
374 ): Promise<void> {
375 await this.workerImplementation?.addElement({
376 index,
377 templateFile: join(
378 dirname(fileURLToPath(import.meta.url)),
379 'assets',
380 'station-templates',
381 stationTemplateUrl.file,
382 ),
383 });
384 }
385
386 private gracefulShutdown = (): void => {
387 this.stop()
388 .then(() => {
389 console.info(`${chalk.green('Graceful shutdown')}`);
390 exit(exitCodes.succeeded);
391 })
392 .catch((error) => {
393 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
394 exit(exitCodes.gracefulShutdownError);
395 });
396 };
397
398 private logPrefix = (): string => {
399 return logPrefix(' Bootstrap |');
400 };
401 }