fix: ensure UI server remains active 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 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.removeAllListeners();
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 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
215 .enabled === false && this.uiServer?.stop();
216 await this.start();
217 }
218
219 private async waitChargingStationsStopped(): Promise<string> {
220 return new Promise<string>((resolve, reject) => {
221 const waitTimeout = setTimeout(() => {
222 const message = `Timeout ${formatDurationMilliSeconds(
223 Constants.STOP_CHARGING_STATIONS_TIMEOUT,
224 )} reached at stopping charging stations`;
225 console.warn(chalk.yellow(message));
226 reject(new Error(message));
227 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT);
228 waitChargingStationEvents(
229 this,
230 ChargingStationWorkerMessageEvents.stopped,
231 this.numberOfChargingStations,
232 )
233 .then(() => {
234 resolve('Charging stations stopped');
235 })
236 .catch(reject)
237 .finally(() => {
238 clearTimeout(waitTimeout);
239 });
240 });
241 }
242
243 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
244 let elementsPerWorker: number | undefined;
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;
252 case 'all':
253 elementsPerWorker = this.numberOfChargingStations;
254 break;
255 }
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,
270 workerOptions: { resourceLimits: workerConfiguration.resourceLimits },
271 },
272 },
273 );
274 }
275
276 private messageHandler(
277 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
278 ): void {
279 // logger.debug(
280 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
281 // msg,
282 // undefined,
283 // 2,
284 // )}`,
285 // );
286 try {
287 switch (msg.event) {
288 case ChargingStationWorkerMessageEvents.started:
289 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
290 break;
291 case ChargingStationWorkerMessageEvents.stopped:
292 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
293 break;
294 case ChargingStationWorkerMessageEvents.updated:
295 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
296 break;
297 case ChargingStationWorkerMessageEvents.performanceStatistics:
298 this.emit(
299 ChargingStationWorkerMessageEvents.performanceStatistics,
300 msg.data as Statistics,
301 );
302 break;
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;
312 default:
313 throw new BaseError(
314 `Unknown charging station worker event: '${
315 msg.event
316 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
317 );
318 }
319 } catch (error) {
320 logger.error(
321 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
322 msg.event
323 }' event:`,
324 error,
325 );
326 }
327 }
328
329 private workerEventStarted = (data: ChargingStationData) => {
330 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
331 ++this.numberOfStartedChargingStations;
332 logger.info(
333 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
334 data.stationInfo.chargingStationId
335 } (hashId: ${data.stationInfo.hashId}) started (${
336 this.numberOfStartedChargingStations
337 } started from ${this.numberOfChargingStations})`,
338 );
339 };
340
341 private workerEventStopped = (data: ChargingStationData) => {
342 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
343 --this.numberOfStartedChargingStations;
344 logger.info(
345 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
346 data.stationInfo.chargingStationId
347 } (hashId: ${data.stationInfo.hashId}) stopped (${
348 this.numberOfStartedChargingStations
349 } started from ${this.numberOfChargingStations})`,
350 );
351 };
352
353 private workerEventUpdated = (data: ChargingStationData) => {
354 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
355 };
356
357 private workerEventPerformanceStatistics = (data: Statistics) => {
358 this.storage?.storePerformanceStatistics(data) as void;
359 };
360
361 private initializeCounters() {
362 if (this.initializedCounters === false) {
363 this.resetCounters();
364 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
365 if (isNotEmptyArray(stationTemplateUrls)) {
366 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
367 for (const stationTemplateUrl of stationTemplateUrls) {
368 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
369 }
370 } else {
371 console.warn(
372 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
373 );
374 exit(exitCodes.missingChargingStationsConfiguration);
375 }
376 if (this.numberOfChargingStations === 0) {
377 console.warn(
378 chalk.yellow('No charging station template enabled in configuration, exiting'),
379 );
380 exit(exitCodes.noChargingStationTemplates);
381 }
382 this.initializedCounters = true;
383 }
384 }
385
386 private resetCounters(): void {
387 this.numberOfChargingStationTemplates = 0;
388 this.numberOfChargingStations = 0;
389 this.numberOfStartedChargingStations = 0;
390 }
391
392 private async startChargingStation(
393 index: number,
394 stationTemplateUrl: StationTemplateUrl,
395 ): Promise<void> {
396 await this.workerImplementation?.addElement({
397 index,
398 templateFile: join(
399 dirname(fileURLToPath(import.meta.url)),
400 'assets',
401 'station-templates',
402 stationTemplateUrl.file,
403 ),
404 });
405 }
406
407 private gracefulShutdown(): void {
408 this.stop()
409 .then(() => {
410 console.info(`${chalk.green('Graceful shutdown')}`);
411 this.uiServer?.stop();
412 // stop() asks for charging stations to stop by default
413 this.waitChargingStationsStopped()
414 .then(() => {
415 exit(exitCodes.succeeded);
416 })
417 .catch(() => {
418 exit(exitCodes.gracefulShutdownError);
419 });
420 })
421 .catch((error) => {
422 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
423 exit(exitCodes.gracefulShutdownError);
424 });
425 }
426
427 private logPrefix = (): string => {
428 return logPrefix(' Bootstrap |');
429 };
430 }