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