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