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