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