fix: ensure UI server remains active at simulator stop
[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';
36adaf06 5import process, { 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;
6d2b7d01
JB
58 private workerImplementation?: WorkerAbstract<ChargingStationWorkerData>;
59 private readonly uiServer?: AbstractUIServer;
60 private 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;
ded13d97
JB
67
68 private constructor() {
f130b8e6 69 super();
6bd808fd 70 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
36adaf06 71 process.on(signal, this.gracefulShutdown.bind(this));
6bd808fd 72 }
4724a293 73 // Enable unconditionally for now
fa5995d6
JB
74 handleUnhandledRejection();
75 handleUncaughtException();
af8e02ca 76 this.started = false;
82e9c15a
JB
77 this.starting = false;
78 this.stopping = false;
0f040ac0 79 this.initializedCounters = false;
a596d200 80 this.initializeCounters();
36adaf06
JB
81 this.uiServer = UIServerFactory.getUIServerImplementation(
82 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer),
864e5f8d 83 );
b3b3f0eb 84 Configuration.configurationChangeCallback = async () => Bootstrap.getInstance().restart(false);
ded13d97
JB
85 }
86
87 public static getInstance(): Bootstrap {
1ca780f9 88 if (Bootstrap.instance === null) {
ded13d97
JB
89 Bootstrap.instance = new Bootstrap();
90 }
91 return Bootstrap.instance;
92 }
93
94 public async start(): Promise<void> {
ee60150f 95 if (this.started === false) {
82e9c15a
JB
96 if (this.starting === false) {
97 this.starting = true;
4354af5a
JB
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 );
82e9c15a 105 this.initializeCounters();
5b373a23 106 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
864e5f8d
JB
107 ConfigurationSection.worker,
108 );
5b373a23 109 this.initializeWorkerImplementation(workerConfiguration);
82e9c15a 110 await this.workerImplementation?.start();
6d2b7d01
JB
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 }
36adaf06
JB
123 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
124 .enabled === true && this.uiServer?.start();
82e9c15a 125 // Start ChargingStation object instance in worker thread
e1d9a0f4 126 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
82e9c15a
JB
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(
5edd8ba0 135 `Error at starting charging station with template file ${stationTemplateUrl.file}: `,
82e9c15a 136 ),
5edd8ba0 137 error,
82e9c15a 138 );
ded13d97 139 }
ded13d97 140 }
82e9c15a
JB
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()
5b373a23 147 ? `${workerConfiguration.poolMinSize?.toString()}/`
82e9c15a
JB
148 : ''
149 }${this.workerImplementation?.size}${
150 Configuration.workerPoolInUse()
5b373a23 151 ? `/${workerConfiguration.poolMaxSize?.toString()}`
82e9c15a 152 : ''
5b373a23 153 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
9bf0ef23 154 !isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
82e9c15a
JB
155 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
156 : ''
5edd8ba0
JB
157 }`,
158 ),
82e9c15a 159 );
56e2e1ab
JB
160 Configuration.workerDynamicPoolInUse() &&
161 console.warn(
162 chalk.yellow(
1d8f226b 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',
5edd8ba0 164 ),
56e2e1ab 165 );
0bde1ea1 166 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info);
82e9c15a
JB
167 this.started = true;
168 this.starting = false;
169 } else {
170 console.error(chalk.red('Cannot start an already starting charging stations simulator'));
ded13d97 171 }
b322b8b4
JB
172 } else {
173 console.error(chalk.red('Cannot start an already started charging stations simulator'));
ded13d97
JB
174 }
175 }
176
36adaf06 177 public async stop(stopChargingStations = true): Promise<void> {
ee60150f 178 if (this.started === true) {
82e9c15a
JB
179 if (this.stopping === false) {
180 this.stopping = true;
36adaf06
JB
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,
ab7a96fa 187 ),
36adaf06 188 );
5b2721db
JB
189 try {
190 await this.waitChargingStationsStopped();
191 } catch (error) {
192 console.error(chalk.red('Error while waiting for charging stations to stop: '), error);
193 }
ab7a96fa 194 }
82e9c15a 195 await this.workerImplementation?.stop();
6d2b7d01 196 delete this.workerImplementation;
ee7c1da0 197 this.removeAllListeners();
82e9c15a 198 await this.storage?.close();
6d2b7d01 199 delete this.storage;
82e9c15a
JB
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 }
b322b8b4 207 } else {
82e9c15a 208 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
ded13d97 209 }
ded13d97
JB
210 }
211
36adaf06
JB
212 public async restart(stopChargingStations?: boolean): Promise<void> {
213 await this.stop(stopChargingStations);
73edcc94
JB
214 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
215 .enabled === false && this.uiServer?.stop();
ded13d97
JB
216 await this.start();
217 }
218
5b2721db
JB
219 private async waitChargingStationsStopped(): Promise<string> {
220 return new Promise<string>((resolve, reject) => {
221 const waitTimeout = setTimeout(() => {
222 const message = `Timeout ${formatDurationMilliSeconds(
d81db081 223 Constants.STOP_CHARGING_STATIONS_TIMEOUT,
5b2721db
JB
224 )} reached at stopping charging stations`;
225 console.warn(chalk.yellow(message));
226 reject(new Error(message));
d81db081 227 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT);
36adaf06
JB
228 waitChargingStationEvents(
229 this,
230 ChargingStationWorkerMessageEvents.stopped,
231 this.numberOfChargingStations,
5b2721db
JB
232 )
233 .then(() => {
234 resolve('Charging stations stopped');
235 })
b7ee97c1 236 .catch(reject)
5b2721db
JB
237 .finally(() => {
238 clearTimeout(waitTimeout);
239 });
240 });
36adaf06
JB
241 }
242
864e5f8d 243 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
e1d9a0f4 244 let elementsPerWorker: number | undefined;
487f0dfd
JB
245 switch (workerConfiguration?.elementsPerWorker) {
246 case 'auto':
247 elementsPerWorker =
248 this.numberOfChargingStations > availableParallelism()
249 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
250 : 1;
251 break;
c20d5d72 252 case 'all':
487f0dfd
JB
253 elementsPerWorker = this.numberOfChargingStations;
254 break;
8603c1ca 255 }
6d2b7d01
JB
256 this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
257 join(
258 dirname(fileURLToPath(import.meta.url)),
259 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`,
260 ),
261 workerConfiguration.processType!,
262 {
263 workerStartDelay: workerConfiguration.startDelay,
264 elementStartDelay: workerConfiguration.elementStartDelay,
265 poolMaxSize: workerConfiguration.poolMaxSize!,
266 poolMinSize: workerConfiguration.poolMinSize!,
267 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
268 poolOptions: {
269 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
487f0dfd 270 workerOptions: { resourceLimits: workerConfiguration.resourceLimits },
5edd8ba0 271 },
6d2b7d01
JB
272 },
273 );
ded13d97 274 }
81797102 275
32de5a57 276 private messageHandler(
5edd8ba0 277 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
32de5a57
LM
278 ): void {
279 // logger.debug(
280 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
281 // msg,
4ed03b6e 282 // undefined,
e1d9a0f4
JB
283 // 2,
284 // )}`,
32de5a57
LM
285 // );
286 try {
8cc482a9 287 switch (msg.event) {
721646e9 288 case ChargingStationWorkerMessageEvents.started:
f130b8e6 289 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
32de5a57 290 break;
721646e9 291 case ChargingStationWorkerMessageEvents.stopped:
f130b8e6 292 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
32de5a57 293 break;
721646e9 294 case ChargingStationWorkerMessageEvents.updated:
f130b8e6 295 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
32de5a57 296 break;
721646e9 297 case ChargingStationWorkerMessageEvents.performanceStatistics:
f130b8e6
JB
298 this.emit(
299 ChargingStationWorkerMessageEvents.performanceStatistics,
5edd8ba0 300 msg.data as Statistics,
f130b8e6 301 );
32de5a57 302 break;
2bb7a73e
JB
303 case ChargingStationWorkerMessageEvents.startWorkerElementError:
304 logger.error(
305 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
306 msg.data,
307 );
308 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data);
309 break;
310 case ChargingStationWorkerMessageEvents.startedWorkerElement:
311 break;
32de5a57
LM
312 default:
313 throw new BaseError(
f93dda6a
JB
314 `Unknown charging station worker event: '${
315 msg.event
4ed03b6e 316 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
32de5a57
LM
317 );
318 }
319 } catch (error) {
320 logger.error(
321 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
8cc482a9 322 msg.event
32de5a57 323 }' event:`,
5edd8ba0 324 error,
32de5a57
LM
325 );
326 }
327 }
328
e2c77f10 329 private workerEventStarted = (data: ChargingStationData) => {
51c83d6f 330 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 331 ++this.numberOfStartedChargingStations;
56eb297e 332 logger.info(
e6159ce8 333 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
56eb297e 334 data.stationInfo.chargingStationId
e6159ce8 335 } (hashId: ${data.stationInfo.hashId}) started (${
56eb297e 336 this.numberOfStartedChargingStations
5edd8ba0 337 } started from ${this.numberOfChargingStations})`,
56eb297e 338 );
e2c77f10 339 };
32de5a57 340
e2c77f10 341 private workerEventStopped = (data: ChargingStationData) => {
51c83d6f 342 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 343 --this.numberOfStartedChargingStations;
56eb297e 344 logger.info(
e6159ce8 345 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
56eb297e 346 data.stationInfo.chargingStationId
e6159ce8 347 } (hashId: ${data.stationInfo.hashId}) stopped (${
56eb297e 348 this.numberOfStartedChargingStations
5edd8ba0 349 } started from ${this.numberOfChargingStations})`,
56eb297e 350 );
e2c77f10 351 };
32de5a57 352
e2c77f10 353 private workerEventUpdated = (data: ChargingStationData) => {
51c83d6f 354 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
e2c77f10 355 };
32de5a57
LM
356
357 private workerEventPerformanceStatistics = (data: Statistics) => {
6d2b7d01 358 this.storage?.storePerformanceStatistics(data) as void;
32de5a57
LM
359 };
360
326cec2d 361 private initializeCounters() {
a596d200 362 if (this.initializedCounters === false) {
0f040ac0 363 this.resetCounters();
e1d9a0f4 364 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
9bf0ef23 365 if (isNotEmptyArray(stationTemplateUrls)) {
41bda658 366 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
7436ee0d 367 for (const stationTemplateUrl of stationTemplateUrls) {
a596d200 368 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
7436ee0d 369 }
a596d200
JB
370 } else {
371 console.warn(
5edd8ba0 372 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
a596d200 373 );
10687422 374 exit(exitCodes.missingChargingStationsConfiguration);
a596d200
JB
375 }
376 if (this.numberOfChargingStations === 0) {
377 console.warn(
5edd8ba0 378 chalk.yellow('No charging station template enabled in configuration, exiting'),
a596d200 379 );
10687422 380 exit(exitCodes.noChargingStationTemplates);
a596d200 381 }
a596d200 382 this.initializedCounters = true;
846d2851 383 }
7c72977b
JB
384 }
385
0f040ac0
JB
386 private resetCounters(): void {
387 this.numberOfChargingStationTemplates = 0;
388 this.numberOfChargingStations = 0;
389 this.numberOfStartedChargingStations = 0;
390 }
391
e7aeea18
JB
392 private async startChargingStation(
393 index: number,
5edd8ba0 394 stationTemplateUrl: StationTemplateUrl,
e7aeea18 395 ): Promise<void> {
6ed3c845 396 await this.workerImplementation?.addElement({
717c1e56 397 index,
d972af76
JB
398 templateFile: join(
399 dirname(fileURLToPath(import.meta.url)),
e7aeea18
JB
400 'assets',
401 'station-templates',
5edd8ba0 402 stationTemplateUrl.file,
e7aeea18 403 ),
6ed3c845 404 });
717c1e56
JB
405 }
406
36adaf06 407 private gracefulShutdown(): void {
f130b8e6
JB
408 this.stop()
409 .then(() => {
83a36f14 410 console.info(`${chalk.green('Graceful shutdown')}`);
73edcc94 411 this.uiServer?.stop();
36adaf06
JB
412 // stop() asks for charging stations to stop by default
413 this.waitChargingStationsStopped()
414 .then(() => {
415 exit(exitCodes.succeeded);
416 })
5b2721db 417 .catch(() => {
36adaf06
JB
418 exit(exitCodes.gracefulShutdownError);
419 });
f130b8e6
JB
420 })
421 .catch((error) => {
fca8bc64 422 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
10687422 423 exit(exitCodes.gracefulShutdownError);
f130b8e6 424 });
36adaf06 425 }
f130b8e6 426
8b7072dc 427 private logPrefix = (): string => {
9bf0ef23 428 return logPrefix(' Bootstrap |');
8b7072dc 429 };
ded13d97 430}