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