fix: disable simulator restart at spurious configuration file change
[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
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);
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 const uiServerConfiguration = Configuration.getConfigurationSection<UIServerConfiguration>(
88 ConfigurationSection.uiServer,
89 );
90 uiServerConfiguration.enabled === true &&
91 (this.uiServer = UIServerFactory.getUIServerImplementation(uiServerConfiguration));
92 const performanceStorageConfiguration =
93 Configuration.getConfigurationSection<StorageConfiguration>(
94 ConfigurationSection.performanceStorage,
95 );
96 performanceStorageConfiguration.enabled === true &&
97 (this.storage = StorageFactory.getStorage(
98 performanceStorageConfiguration.type!,
99 performanceStorageConfiguration.uri!,
100 this.logPrefix(),
101 ));
102 // FIXME: Disabled until the spurious configuration file change detection on MacOS is identified
103 // Configuration.configurationChangeCallback = async () => Bootstrap.getInstance().restart(false);
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 (this.started === false) {
115 if (this.starting === false) {
116 this.starting = true;
117 this.initializeCounters();
118 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
119 ConfigurationSection.worker,
120 );
121 this.initializeWorkerImplementation(workerConfiguration);
122 await this.workerImplementation?.start();
123 await this.storage?.open();
124 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(waitChargingStationsStopped = true): Promise<void> {
178 if (this.started === true) {
179 if (this.stopping === false) {
180 this.stopping = true;
181 await this.uiServer?.sendInternalRequest(
182 this.uiServer.buildProtocolRequest(
183 generateUUID(),
184 ProcedureName.STOP_CHARGING_STATION,
185 Constants.EMPTY_FROZEN_OBJECT,
186 ),
187 );
188 if (waitChargingStationsStopped === true) {
189 await Promise.race([
190 waitChargingStationEvents(
191 this,
192 ChargingStationWorkerMessageEvents.stopped,
193 this.numberOfChargingStations,
194 ),
195 new Promise<string>((resolve) => {
196 setTimeout(() => {
197 const message = `Timeout ${formatDurationMilliSeconds(
198 Constants.STOP_SIMULATOR_TIMEOUT,
199 )} reached at stopping charging stations simulator`;
200 console.warn(chalk.yellow(message));
201 resolve(message);
202 }, Constants.STOP_SIMULATOR_TIMEOUT);
203 }),
204 ]);
205 }
206 await this.workerImplementation?.stop();
207 this.workerImplementation = null;
208 this.uiServer?.stop();
209 await this.storage?.close();
210 this.resetCounters();
211 this.initializedCounters = false;
212 this.started = false;
213 this.stopping = false;
214 } else {
215 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'));
216 }
217 } else {
218 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'));
219 }
220 }
221
222 public async restart(waitChargingStationsStopped?: boolean): Promise<void> {
223 await this.stop(waitChargingStationsStopped);
224 await this.start();
225 }
226
227 private initializeWorkerImplementation(workerConfiguration: WorkerConfiguration): void {
228 let elementsPerWorker: number | undefined;
229 if (workerConfiguration?.elementsPerWorker === 'auto') {
230 elementsPerWorker =
231 this.numberOfChargingStations > availableParallelism()
232 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
233 : 1;
234 }
235 this.workerImplementation === null &&
236 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
237 this.workerScript,
238 workerConfiguration.processType!,
239 {
240 workerStartDelay: workerConfiguration.startDelay,
241 elementStartDelay: workerConfiguration.elementStartDelay,
242 poolMaxSize: workerConfiguration.poolMaxSize!,
243 poolMinSize: workerConfiguration.poolMinSize!,
244 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
245 poolOptions: {
246 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
247 },
248 },
249 ));
250 }
251
252 private messageHandler(
253 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
254 ): void {
255 // logger.debug(
256 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
257 // msg,
258 // undefined,
259 // 2,
260 // )}`,
261 // );
262 try {
263 switch (msg.event) {
264 case ChargingStationWorkerMessageEvents.started:
265 this.workerEventStarted(msg.data as ChargingStationData);
266 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
267 break;
268 case ChargingStationWorkerMessageEvents.stopped:
269 this.workerEventStopped(msg.data as ChargingStationData);
270 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
271 break;
272 case ChargingStationWorkerMessageEvents.updated:
273 this.workerEventUpdated(msg.data as ChargingStationData);
274 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
275 break;
276 case ChargingStationWorkerMessageEvents.performanceStatistics:
277 this.workerEventPerformanceStatistics(msg.data as Statistics);
278 this.emit(
279 ChargingStationWorkerMessageEvents.performanceStatistics,
280 msg.data as Statistics,
281 );
282 break;
283 case ChargingStationWorkerMessageEvents.startWorkerElementError:
284 logger.error(
285 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
286 msg.data,
287 );
288 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data);
289 break;
290 case ChargingStationWorkerMessageEvents.startedWorkerElement:
291 break;
292 default:
293 throw new BaseError(
294 `Unknown charging station worker event: '${
295 msg.event
296 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`,
297 );
298 }
299 } catch (error) {
300 logger.error(
301 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
302 msg.event
303 }' event:`,
304 error,
305 );
306 }
307 }
308
309 private workerEventStarted = (data: ChargingStationData) => {
310 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
311 ++this.numberOfStartedChargingStations;
312 logger.info(
313 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
314 data.stationInfo.chargingStationId
315 } (hashId: ${data.stationInfo.hashId}) started (${
316 this.numberOfStartedChargingStations
317 } started from ${this.numberOfChargingStations})`,
318 );
319 };
320
321 private workerEventStopped = (data: ChargingStationData) => {
322 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
323 --this.numberOfStartedChargingStations;
324 logger.info(
325 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
326 data.stationInfo.chargingStationId
327 } (hashId: ${data.stationInfo.hashId}) stopped (${
328 this.numberOfStartedChargingStations
329 } started from ${this.numberOfChargingStations})`,
330 );
331 };
332
333 private workerEventUpdated = (data: ChargingStationData) => {
334 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
335 };
336
337 private workerEventPerformanceStatistics = (data: Statistics) => {
338 this.storage.storePerformanceStatistics(data) as void;
339 };
340
341 private initializeCounters() {
342 if (this.initializedCounters === false) {
343 this.resetCounters();
344 const stationTemplateUrls = Configuration.getStationTemplateUrls()!;
345 if (isNotEmptyArray(stationTemplateUrls)) {
346 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
347 for (const stationTemplateUrl of stationTemplateUrls) {
348 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
349 }
350 } else {
351 console.warn(
352 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
353 );
354 exit(exitCodes.missingChargingStationsConfiguration);
355 }
356 if (this.numberOfChargingStations === 0) {
357 console.warn(
358 chalk.yellow('No charging station template enabled in configuration, exiting'),
359 );
360 exit(exitCodes.noChargingStationTemplates);
361 }
362 this.initializedCounters = true;
363 }
364 }
365
366 private resetCounters(): void {
367 this.numberOfChargingStationTemplates = 0;
368 this.numberOfChargingStations = 0;
369 this.numberOfStartedChargingStations = 0;
370 }
371
372 private async startChargingStation(
373 index: number,
374 stationTemplateUrl: StationTemplateUrl,
375 ): Promise<void> {
376 await this.workerImplementation?.addElement({
377 index,
378 templateFile: join(
379 dirname(fileURLToPath(import.meta.url)),
380 'assets',
381 'station-templates',
382 stationTemplateUrl.file,
383 ),
384 });
385 }
386
387 private gracefulShutdown = (): void => {
388 this.stop()
389 .then(() => {
390 console.info(`${chalk.green('Graceful shutdown')}`);
391 exit(exitCodes.succeeded);
392 })
393 .catch((error) => {
394 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
395 exit(exitCodes.gracefulShutdownError);
396 });
397 };
398
399 private logPrefix = (): string => {
400 return logPrefix(' Bootstrap |');
401 };
402 }