feat: add elementsPerWorker automatic calculation
[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';
130783a7 5import { fileURLToPath } from 'node:url';
be245fda 6import { isMainThread } from 'node:worker_threads';
8114d10e
JB
7
8import chalk from 'chalk';
8603c1ca 9import { availableParallelism } from 'poolifier';
8114d10e 10
b2b60626 11import { waitChargingStationEvents } from './ChargingStationUtils';
4c3c0d59
JB
12import type { AbstractUIServer } from './ui-server/AbstractUIServer';
13import { UIServerFactory } from './ui-server/UIServerFactory';
628c30e5 14import { version } from '../../package.json' assert { type: '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,
6bd808fd 23 ProcedureName,
268a74bb
JB
24 type StationTemplateUrl,
25 type Statistics,
26} from '../types';
fa5995d6
JB
27import {
28 Configuration,
29 Constants,
9bf0ef23
JB
30 formatDurationMilliSeconds,
31 generateUUID,
fa5995d6
JB
32 handleUncaughtException,
33 handleUnhandledRejection,
9bf0ef23
JB
34 isNotEmptyArray,
35 isNullOrUndefined,
36 logPrefix,
fa5995d6
JB
37 logger,
38} from '../utils';
8603c1ca 39import { type WorkerAbstract, WorkerConstants, WorkerFactory } from '../worker';
ded13d97 40
32de5a57
LM
41const moduleName = 'Bootstrap';
42
a307349b
JB
43enum exitCodes {
44 missingChargingStationsConfiguration = 1,
45 noChargingStationTemplates = 2,
46}
e4cb2c14 47
f130b8e6 48export class Bootstrap extends EventEmitter {
535aaa27 49 private static instance: Bootstrap | null = null;
d1c99c59
JB
50 public numberOfChargingStations!: number;
51 public numberOfChargingStationTemplates!: number;
aa428a31 52 private workerImplementation: WorkerAbstract<ChargingStationWorkerData> | null;
551e477c 53 private readonly uiServer!: AbstractUIServer | null;
6a49ad23 54 private readonly storage!: Storage;
89b7a234 55 private numberOfStartedChargingStations!: number;
628c30e5 56 private readonly version: string = version;
a596d200 57 private initializedCounters: boolean;
eb87fe87 58 private started: boolean;
82e9c15a
JB
59 private starting: boolean;
60 private stopping: boolean;
9e23580d 61 private readonly workerScript: string;
ded13d97
JB
62
63 private constructor() {
f130b8e6 64 super();
6bd808fd 65 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
f130b8e6 66 process.on(signal, this.gracefulShutdown);
6bd808fd 67 }
4724a293 68 // Enable unconditionally for now
fa5995d6
JB
69 handleUnhandledRejection();
70 handleUncaughtException();
af8e02ca 71 this.started = false;
82e9c15a
JB
72 this.starting = false;
73 this.stopping = false;
0f040ac0 74 this.initializedCounters = false;
a596d200 75 this.initializeCounters();
af8e02ca 76 this.workerImplementation = null;
d972af76
JB
77 this.workerScript = join(
78 dirname(fileURLToPath(import.meta.url)),
5edd8ba0 79 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`,
e7aeea18 80 );
5af9aa8a 81 Configuration.getUIServer().enabled === true &&
976d11ec 82 (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer()));
eb3abc4f 83 Configuration.getPerformanceStorage().enabled === true &&
e7aeea18
JB
84 (this.storage = StorageFactory.getStorage(
85 Configuration.getPerformanceStorage().type,
86 Configuration.getPerformanceStorage().uri,
5edd8ba0 87 this.logPrefix(),
e7aeea18 88 ));
7874b0b1 89 Configuration.setConfigurationChangeCallback(async () => Bootstrap.getInstance().restart());
ded13d97
JB
90 }
91
92 public static getInstance(): Bootstrap {
1ca780f9 93 if (Bootstrap.instance === null) {
ded13d97
JB
94 Bootstrap.instance = new Bootstrap();
95 }
96 return Bootstrap.instance;
97 }
98
99 public async start(): Promise<void> {
ee60150f
JB
100 if (!isMainThread) {
101 throw new Error('Cannot start charging stations simulator from worker thread');
102 }
103 if (this.started === false) {
82e9c15a
JB
104 if (this.starting === false) {
105 this.starting = true;
106 this.initializeCounters();
107 this.initializeWorkerImplementation();
108 await this.workerImplementation?.start();
109 await this.storage?.open();
110 this.uiServer?.start();
111 // Start ChargingStation object instance in worker thread
112 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()) {
113 try {
114 const nbStations = stationTemplateUrl.numberOfStations ?? 0;
115 for (let index = 1; index <= nbStations; index++) {
116 await this.startChargingStation(index, stationTemplateUrl);
117 }
118 } catch (error) {
119 console.error(
120 chalk.red(
5edd8ba0 121 `Error at starting charging station with template file ${stationTemplateUrl.file}: `,
82e9c15a 122 ),
5edd8ba0 123 error,
82e9c15a 124 );
ded13d97 125 }
ded13d97 126 }
82e9c15a
JB
127 console.info(
128 chalk.green(
129 `Charging stations simulator ${
130 this.version
131 } started with ${this.numberOfChargingStations.toString()} charging station(s) from ${this.numberOfChargingStationTemplates.toString()} configured charging station template(s) and ${
132 Configuration.workerDynamicPoolInUse()
133 ? `${Configuration.getWorker().poolMinSize?.toString()}/`
134 : ''
135 }${this.workerImplementation?.size}${
136 Configuration.workerPoolInUse()
137 ? `/${Configuration.getWorker().poolMaxSize?.toString()}`
138 : ''
139 } worker(s) concurrently running in '${Configuration.getWorker().processType}' mode${
9bf0ef23 140 !isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
82e9c15a
JB
141 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
142 : ''
5edd8ba0
JB
143 }`,
144 ),
82e9c15a 145 );
56e2e1ab
JB
146 Configuration.workerDynamicPoolInUse() &&
147 console.warn(
148 chalk.yellow(
5edd8ba0
JB
149 'Charging stations simulator is using dynamic pool mode. This is an experimental feature with known issues.\nPlease consider using static pool or worker set mode instead',
150 ),
56e2e1ab 151 );
0bde1ea1 152 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info);
82e9c15a
JB
153 this.started = true;
154 this.starting = false;
155 } else {
156 console.error(chalk.red('Cannot start an already starting charging stations simulator'));
ded13d97 157 }
b322b8b4
JB
158 } else {
159 console.error(chalk.red('Cannot start an already started charging stations simulator'));
ded13d97
JB
160 }
161 }
162
163 public async stop(): Promise<void> {
ee60150f
JB
164 if (!isMainThread) {
165 throw new Error('Cannot stop charging stations simulator from worker thread');
166 }
167 if (this.started === true) {
82e9c15a
JB
168 if (this.stopping === false) {
169 this.stopping = true;
170 await this.uiServer?.sendInternalRequest(
171 this.uiServer.buildProtocolRequest(
9bf0ef23 172 generateUUID(),
82e9c15a 173 ProcedureName.STOP_CHARGING_STATION,
5edd8ba0
JB
174 Constants.EMPTY_FREEZED_OBJECT,
175 ),
82e9c15a 176 );
1832a986 177 await Promise.race([
b2b60626 178 waitChargingStationEvents(
1832a986
JB
179 this,
180 ChargingStationWorkerMessageEvents.stopped,
5edd8ba0 181 this.numberOfChargingStations,
1832a986
JB
182 ),
183 new Promise<string>((resolve) => {
184 setTimeout(() => {
9bf0ef23 185 const message = `Timeout reached ${formatDurationMilliSeconds(
5edd8ba0 186 Constants.STOP_SIMULATOR_TIMEOUT,
1832a986
JB
187 )} at stopping charging stations simulator`;
188 console.warn(chalk.yellow(message));
189 resolve(message);
190 }, Constants.STOP_SIMULATOR_TIMEOUT);
191 }),
192 ]);
82e9c15a
JB
193 await this.workerImplementation?.stop();
194 this.workerImplementation = null;
195 this.uiServer?.stop();
196 await this.storage?.close();
197 this.resetCounters();
198 this.initializedCounters = false;
199 this.started = false;
200 this.stopping = false;
201 } else {
202 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'));
203 }
b322b8b4 204 } else {
82e9c15a 205 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
ded13d97 206 }
ded13d97
JB
207 }
208
209 public async restart(): Promise<void> {
210 await this.stop();
211 await this.start();
212 }
213
ec7f4dce 214 private initializeWorkerImplementation(): void {
8603c1ca
JB
215 let elementsPerWorker = WorkerConstants.DEFAULT_ELEMENTS_PER_WORKER;
216 if (
217 Configuration.getWorker()?.elementsPerWorker === 'auto' &&
218 this.numberOfChargingStations > availableParallelism()
219 ) {
220 elementsPerWorker = Math.round(this.numberOfChargingStations / availableParallelism());
221 }
e2c77f10 222 this.workerImplementation === null &&
ec7f4dce
JB
223 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
224 this.workerScript,
cf2a5d9b 225 Configuration.getWorker().processType,
ec7f4dce 226 {
cf2a5d9b
JB
227 workerStartDelay: Configuration.getWorker().startDelay,
228 elementStartDelay: Configuration.getWorker().elementStartDelay,
229 poolMaxSize: Configuration.getWorker().poolMaxSize,
230 poolMinSize: Configuration.getWorker().poolMinSize,
8603c1ca 231 elementsPerWorker,
ec7f4dce 232 poolOptions: {
cf2a5d9b 233 workerChoiceStrategy: Configuration.getWorker().poolStrategy,
be245fda 234 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
ec7f4dce 235 },
5edd8ba0 236 },
ec7f4dce 237 ));
ded13d97 238 }
81797102 239
32de5a57 240 private messageHandler(
5edd8ba0 241 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
32de5a57
LM
242 ): void {
243 // logger.debug(
244 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
245 // msg,
246 // null,
247 // 2
248 // )}`
249 // );
250 try {
251 switch (msg.id) {
721646e9 252 case ChargingStationWorkerMessageEvents.started:
32de5a57 253 this.workerEventStarted(msg.data as ChargingStationData);
f130b8e6 254 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
32de5a57 255 break;
721646e9 256 case ChargingStationWorkerMessageEvents.stopped:
32de5a57 257 this.workerEventStopped(msg.data as ChargingStationData);
f130b8e6 258 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
32de5a57 259 break;
721646e9 260 case ChargingStationWorkerMessageEvents.updated:
32de5a57 261 this.workerEventUpdated(msg.data as ChargingStationData);
f130b8e6 262 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
32de5a57 263 break;
721646e9 264 case ChargingStationWorkerMessageEvents.performanceStatistics:
32de5a57 265 this.workerEventPerformanceStatistics(msg.data as Statistics);
f130b8e6
JB
266 this.emit(
267 ChargingStationWorkerMessageEvents.performanceStatistics,
5edd8ba0 268 msg.data as Statistics,
f130b8e6 269 );
32de5a57
LM
270 break;
271 default:
272 throw new BaseError(
5edd8ba0 273 `Unknown event type: '${msg.id}' for data: ${JSON.stringify(msg.data, null, 2)}`,
32de5a57
LM
274 );
275 }
276 } catch (error) {
277 logger.error(
278 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
279 msg.id
280 }' event:`,
5edd8ba0 281 error,
32de5a57
LM
282 );
283 }
284 }
285
e2c77f10 286 private workerEventStarted = (data: ChargingStationData) => {
51c83d6f 287 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 288 ++this.numberOfStartedChargingStations;
56eb297e 289 logger.info(
e6159ce8 290 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
56eb297e 291 data.stationInfo.chargingStationId
e6159ce8 292 } (hashId: ${data.stationInfo.hashId}) started (${
56eb297e 293 this.numberOfStartedChargingStations
5edd8ba0 294 } started from ${this.numberOfChargingStations})`,
56eb297e 295 );
e2c77f10 296 };
32de5a57 297
e2c77f10 298 private workerEventStopped = (data: ChargingStationData) => {
51c83d6f 299 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
89b7a234 300 --this.numberOfStartedChargingStations;
56eb297e 301 logger.info(
e6159ce8 302 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
56eb297e 303 data.stationInfo.chargingStationId
e6159ce8 304 } (hashId: ${data.stationInfo.hashId}) stopped (${
56eb297e 305 this.numberOfStartedChargingStations
5edd8ba0 306 } started from ${this.numberOfChargingStations})`,
56eb297e 307 );
e2c77f10 308 };
32de5a57 309
e2c77f10 310 private workerEventUpdated = (data: ChargingStationData) => {
51c83d6f 311 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
e2c77f10 312 };
32de5a57
LM
313
314 private workerEventPerformanceStatistics = (data: Statistics) => {
315 this.storage.storePerformanceStatistics(data) as void;
316 };
317
326cec2d 318 private initializeCounters() {
a596d200 319 if (this.initializedCounters === false) {
0f040ac0 320 this.resetCounters();
a596d200 321 const stationTemplateUrls = Configuration.getStationTemplateUrls();
9bf0ef23 322 if (isNotEmptyArray(stationTemplateUrls)) {
41bda658 323 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
7436ee0d 324 for (const stationTemplateUrl of stationTemplateUrls) {
a596d200 325 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
7436ee0d 326 }
a596d200
JB
327 } else {
328 console.warn(
5edd8ba0 329 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
a596d200
JB
330 );
331 process.exit(exitCodes.missingChargingStationsConfiguration);
332 }
333 if (this.numberOfChargingStations === 0) {
334 console.warn(
5edd8ba0 335 chalk.yellow('No charging station template enabled in configuration, exiting'),
a596d200
JB
336 );
337 process.exit(exitCodes.noChargingStationTemplates);
338 }
a596d200 339 this.initializedCounters = true;
846d2851 340 }
7c72977b
JB
341 }
342
0f040ac0
JB
343 private resetCounters(): void {
344 this.numberOfChargingStationTemplates = 0;
345 this.numberOfChargingStations = 0;
346 this.numberOfStartedChargingStations = 0;
347 }
348
e7aeea18
JB
349 private async startChargingStation(
350 index: number,
5edd8ba0 351 stationTemplateUrl: StationTemplateUrl,
e7aeea18 352 ): Promise<void> {
6ed3c845 353 await this.workerImplementation?.addElement({
717c1e56 354 index,
d972af76
JB
355 templateFile: join(
356 dirname(fileURLToPath(import.meta.url)),
e7aeea18
JB
357 'assets',
358 'station-templates',
5edd8ba0 359 stationTemplateUrl.file,
e7aeea18 360 ),
6ed3c845 361 });
717c1e56
JB
362 }
363
f130b8e6 364 private gracefulShutdown = (): void => {
6bd808fd 365 console.info(`${chalk.green('Graceful shutdown')}`);
f130b8e6
JB
366 this.stop()
367 .then(() => {
368 process.exit(0);
369 })
370 .catch((error) => {
fca8bc64 371 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
f130b8e6
JB
372 process.exit(1);
373 });
374 };
375
8b7072dc 376 private logPrefix = (): string => {
9bf0ef23 377 return logPrefix(' Bootstrap |');
8b7072dc 378 };
ded13d97 379}