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