fix(simulator): ensure configuration file reload will restart the
[e-mobility-charging-stations-simulator.git] / src / charging-station / Bootstrap.ts
CommitLineData
edd13439 1// Partial Copyright Jerome Benoit. 2021-2023. All Rights Reserved.
b4d34251 2
f130b8e6 3import { EventEmitter } from 'node:events';
d972af76 4import { dirname, extname, join } from 'node:path';
10687422 5import { exit } from 'node:process';
130783a7 6import { fileURLToPath } from 'node:url';
8114d10e
JB
7
8import chalk from 'chalk';
8603c1ca 9import { availableParallelism } from 'poolifier';
8114d10e 10
08b58f00 11import { waitChargingStationEvents } from './Helpers';
4c3c0d59
JB
12import type { AbstractUIServer } from './ui-server/AbstractUIServer';
13import { UIServerFactory } from './ui-server/UIServerFactory';
44ebef4c 14import { version } from '../../package.json';
268a74bb 15import { BaseError } from '../exception';
17bc43d7 16import { type Storage, StorageFactory } from '../performance';
e7aeea18 17import {
bbe10d5f
JB
18 type ChargingStationData,
19 type ChargingStationWorkerData,
20 type ChargingStationWorkerMessage,
21 type ChargingStationWorkerMessageData,
e7aeea18 22 ChargingStationWorkerMessageEvents,
5d049829 23 ConfigurationSection,
6bd808fd 24 ProcedureName,
268a74bb
JB
25 type StationTemplateUrl,
26 type Statistics,
5d049829
JB
27 type StorageConfiguration,
28 type UIServerConfiguration,
29 type WorkerConfiguration,
268a74bb 30} from '../types';
fa5995d6
JB
31import {
32 Configuration,
33 Constants,
9bf0ef23
JB
34 formatDurationMilliSeconds,
35 generateUUID,
fa5995d6
JB
36 handleUncaughtException,
37 handleUnhandledRejection,
9bf0ef23
JB
38 isNotEmptyArray,
39 isNullOrUndefined,
40 logPrefix,
fa5995d6
JB
41 logger,
42} from '../utils';
eda9c451 43import { type WorkerAbstract, WorkerFactory } from '../worker';
ded13d97 44
32de5a57
LM
45const moduleName = 'Bootstrap';
46
a307349b 47enum exitCodes {
a51a4ead 48 succeeded = 0,
a307349b
JB
49 missingChargingStationsConfiguration = 1,
50 noChargingStationTemplates = 2,
a51a4ead 51 gracefulShutdownError = 3,
a307349b 52}
e4cb2c14 53
f130b8e6 54export class Bootstrap extends EventEmitter {
535aaa27 55 private static instance: Bootstrap | null = null;
d1c99c59
JB
56 public numberOfChargingStations!: number;
57 public numberOfChargingStationTemplates!: number;
aa428a31 58 private workerImplementation: WorkerAbstract<ChargingStationWorkerData> | null;
551e477c 59 private readonly uiServer!: AbstractUIServer | null;
6a49ad23 60 private readonly storage!: Storage;
89b7a234 61 private numberOfStartedChargingStations!: number;
628c30e5 62 private readonly version: string = version;
a596d200 63 private initializedCounters: boolean;
eb87fe87 64 private started: boolean;
82e9c15a
JB
65 private starting: boolean;
66 private stopping: boolean;
9e23580d 67 private readonly workerScript: string;
ded13d97
JB
68
69 private constructor() {
f130b8e6 70 super();
6bd808fd 71 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
f130b8e6 72 process.on(signal, this.gracefulShutdown);
6bd808fd 73 }
4724a293 74 // Enable unconditionally for now
fa5995d6
JB
75 handleUnhandledRejection();
76 handleUncaughtException();
af8e02ca 77 this.started = false;
82e9c15a
JB
78 this.starting = false;
79 this.stopping = false;
0f040ac0 80 this.initializedCounters = false;
a596d200 81 this.initializeCounters();
af8e02ca 82 this.workerImplementation = null;
d972af76
JB
83 this.workerScript = join(
84 dirname(fileURLToPath(import.meta.url)),
5edd8ba0 85 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`,
e7aeea18 86 );
864e5f8d
JB
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 &&
e7aeea18 97 (this.storage = StorageFactory.getStorage(
864e5f8d
JB
98 performanceStorageConfiguration.type!,
99 performanceStorageConfiguration.uri!,
5edd8ba0 100 this.logPrefix(),
e7aeea18 101 ));
ab7a96fa 102 Configuration.configurationChangeCallback = async () => Bootstrap.getInstance().restart(false);
ded13d97
JB
103 }
104
105 public static getInstance(): Bootstrap {
1ca780f9 106 if (Bootstrap.instance === null) {
ded13d97
JB
107 Bootstrap.instance = new Bootstrap();
108 }
109 return Bootstrap.instance;
110 }
111
112 public async start(): Promise<void> {
ee60150f 113 if (this.started === false) {
82e9c15a
JB
114 if (this.starting === false) {
115 this.starting = true;
116 this.initializeCounters();
864e5f8d
JB
117 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
118 ConfigurationSection.worker,
119 );
120 this.initializeWorkerImplementation(workerConfiguration);
82e9c15a
JB
121 await this.workerImplementation?.start();
122 await this.storage?.open();
123 this.uiServer?.start();
124 // Start ChargingStation object instance in worker thread
e1d9a0f4 125 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
82e9c15a
JB
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(
5edd8ba0 134 `Error at starting charging station with template file ${stationTemplateUrl.file}: `,
82e9c15a 135 ),
5edd8ba0 136 error,
82e9c15a 137 );
ded13d97 138 }
ded13d97 139 }
82e9c15a
JB
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()
864e5f8d 146 ? `${workerConfiguration.poolMinSize?.toString()}/`
82e9c15a
JB
147 : ''
148 }${this.workerImplementation?.size}${
149 Configuration.workerPoolInUse()
864e5f8d 150 ? `/${workerConfiguration.poolMaxSize?.toString()}`
82e9c15a 151 : ''
864e5f8d 152 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
9bf0ef23 153 !isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
82e9c15a
JB
154 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
155 : ''
5edd8ba0
JB
156 }`,
157 ),
82e9c15a 158 );
56e2e1ab
JB
159 Configuration.workerDynamicPoolInUse() &&
160 console.warn(
161 chalk.yellow(
1d8f226b 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',
5edd8ba0 163 ),
56e2e1ab 164 );
0bde1ea1 165 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info);
82e9c15a
JB
166 this.started = true;
167 this.starting = false;
168 } else {
169 console.error(chalk.red('Cannot start an already starting charging stations simulator'));
ded13d97 170 }
b322b8b4
JB
171 } else {
172 console.error(chalk.red('Cannot start an already started charging stations simulator'));
ded13d97
JB
173 }
174 }
175
ab7a96fa 176 public async stop(waitChargingStationsStopped = true): Promise<void> {
ee60150f 177 if (this.started === true) {
82e9c15a
JB
178 if (this.stopping === false) {
179 this.stopping = true;
180 await this.uiServer?.sendInternalRequest(
181 this.uiServer.buildProtocolRequest(
9bf0ef23 182 generateUUID(),
82e9c15a 183 ProcedureName.STOP_CHARGING_STATION,
2035255d 184 Constants.EMPTY_FROZEN_OBJECT,
5edd8ba0 185 ),
82e9c15a 186 );
ab7a96fa
JB
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 }
82e9c15a
JB
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 }
b322b8b4 216 } else {
82e9c15a 217 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
ded13d97 218 }
ded13d97
JB
219 }
220
ab7a96fa
JB
221 public async restart(waitChargingStationsStopped?: boolean): Promise<void> {
222 await this.stop(waitChargingStationsStopped);
ded13d97
JB
223 await this.start();
224 }
225
864e5f8d 226 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
e1d9a0f4 227 let elementsPerWorker: number | undefined;
864e5f8d 228 if (workerConfiguration?.elementsPerWorker === 'auto') {
34c200d5
JB
229 elementsPerWorker =
230 this.numberOfChargingStations > availableParallelism()
411f6bb4 231 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
34c200d5 232 : 1;
8603c1ca 233 }
e2c77f10 234 this.workerImplementation === null &&
ec7f4dce
JB
235 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
236 this.workerScript,
864e5f8d 237 workerConfiguration.processType!,
ec7f4dce 238 {
864e5f8d
JB
239 workerStartDelay: workerConfiguration.startDelay,
240 elementStartDelay: workerConfiguration.elementStartDelay,
241 poolMaxSize: workerConfiguration.poolMaxSize!,
242 poolMinSize: workerConfiguration.poolMinSize!,
243 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
ec7f4dce 244 poolOptions: {
be245fda 245 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
ec7f4dce 246 },
5edd8ba0 247 },
ec7f4dce 248 ));
ded13d97 249 }
81797102 250
32de5a57 251 private messageHandler(
5edd8ba0 252 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
32de5a57
LM
253 ): void {
254 // logger.debug(
255 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
256 // msg,
4ed03b6e 257 // undefined,
e1d9a0f4
JB
258 // 2,
259 // )}`,
32de5a57
LM
260 // );
261 try {
8cc482a9 262 switch (msg.event) {
721646e9 263 case ChargingStationWorkerMessageEvents.started:
32de5a57 264 this.workerEventStarted(msg.data as ChargingStationData);
f130b8e6 265 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
32de5a57 266 break;
721646e9 267 case ChargingStationWorkerMessageEvents.stopped:
32de5a57 268 this.workerEventStopped(msg.data as ChargingStationData);
f130b8e6 269 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
32de5a57 270 break;
721646e9 271 case ChargingStationWorkerMessageEvents.updated:
32de5a57 272 this.workerEventUpdated(msg.data as ChargingStationData);
f130b8e6 273 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
32de5a57 274 break;
721646e9 275 case ChargingStationWorkerMessageEvents.performanceStatistics:
32de5a57 276 this.workerEventPerformanceStatistics(msg.data as Statistics);
f130b8e6
JB
277 this.emit(
278 ChargingStationWorkerMessageEvents.performanceStatistics,
5edd8ba0 279 msg.data as Statistics,
f130b8e6 280 );
32de5a57 281 break;
2bb7a73e
JB
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;
32de5a57
LM
291 default:
292 throw new BaseError(
f93dda6a
JB
293 `Unknown charging station worker event: '${
294 msg.event
4ed03b6e 295 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
32de5a57
LM
296 );
297 }
298 } catch (error) {
299 logger.error(
300 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
8cc482a9 301 msg.event
32de5a57 302 }' event:`,
5edd8ba0 303 error,
32de5a57
LM
304 );
305 }
306 }
307
e2c77f10 308 private workerEventStarted = (data: ChargingStationData) => {
51c83d6f 309 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 310 ++this.numberOfStartedChargingStations;
56eb297e 311 logger.info(
e6159ce8 312 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
56eb297e 313 data.stationInfo.chargingStationId
e6159ce8 314 } (hashId: ${data.stationInfo.hashId}) started (${
56eb297e 315 this.numberOfStartedChargingStations
5edd8ba0 316 } started from ${this.numberOfChargingStations})`,
56eb297e 317 );
e2c77f10 318 };
32de5a57 319
e2c77f10 320 private workerEventStopped = (data: ChargingStationData) => {
51c83d6f 321 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 322 --this.numberOfStartedChargingStations;
56eb297e 323 logger.info(
e6159ce8 324 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
56eb297e 325 data.stationInfo.chargingStationId
e6159ce8 326 } (hashId: ${data.stationInfo.hashId}) stopped (${
56eb297e 327 this.numberOfStartedChargingStations
5edd8ba0 328 } started from ${this.numberOfChargingStations})`,
56eb297e 329 );
e2c77f10 330 };
32de5a57 331
e2c77f10 332 private workerEventUpdated = (data: ChargingStationData) => {
51c83d6f 333 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
e2c77f10 334 };
32de5a57
LM
335
336 private workerEventPerformanceStatistics = (data: Statistics) => {
337 this.storage.storePerformanceStatistics(data) as void;
338 };
339
326cec2d 340 private initializeCounters() {
a596d200 341 if (this.initializedCounters === false) {
0f040ac0 342 this.resetCounters();
e1d9a0f4 343 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
9bf0ef23 344 if (isNotEmptyArray(stationTemplateUrls)) {
41bda658 345 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
7436ee0d 346 for (const stationTemplateUrl of stationTemplateUrls) {
a596d200 347 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
7436ee0d 348 }
a596d200
JB
349 } else {
350 console.warn(
5edd8ba0 351 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
a596d200 352 );
10687422 353 exit(exitCodes.missingChargingStationsConfiguration);
a596d200
JB
354 }
355 if (this.numberOfChargingStations === 0) {
356 console.warn(
5edd8ba0 357 chalk.yellow('No charging station template enabled in configuration, exiting'),
a596d200 358 );
10687422 359 exit(exitCodes.noChargingStationTemplates);
a596d200 360 }
a596d200 361 this.initializedCounters = true;
846d2851 362 }
7c72977b
JB
363 }
364
0f040ac0
JB
365 private resetCounters(): void {
366 this.numberOfChargingStationTemplates = 0;
367 this.numberOfChargingStations = 0;
368 this.numberOfStartedChargingStations = 0;
369 }
370
e7aeea18
JB
371 private async startChargingStation(
372 index: number,
5edd8ba0 373 stationTemplateUrl: StationTemplateUrl,
e7aeea18 374 ): Promise<void> {
6ed3c845 375 await this.workerImplementation?.addElement({
717c1e56 376 index,
d972af76
JB
377 templateFile: join(
378 dirname(fileURLToPath(import.meta.url)),
e7aeea18
JB
379 'assets',
380 'station-templates',
5edd8ba0 381 stationTemplateUrl.file,
e7aeea18 382 ),
6ed3c845 383 });
717c1e56
JB
384 }
385
f130b8e6 386 private gracefulShutdown = (): void => {
f130b8e6
JB
387 this.stop()
388 .then(() => {
83a36f14 389 console.info(`${chalk.green('Graceful shutdown')}`);
10687422 390 exit(exitCodes.succeeded);
f130b8e6
JB
391 })
392 .catch((error) => {
fca8bc64 393 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
10687422 394 exit(exitCodes.gracefulShutdownError);
f130b8e6
JB
395 });
396 };
397
8b7072dc 398 private logPrefix = (): string => {
9bf0ef23 399 return logPrefix(' Bootstrap |');
8b7072dc 400 };
ded13d97 401}