feat: add elementsPerWorker automatic calculation
[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 = WorkerConstants.DEFAULT_ELEMENTS_PER_WORKER;
216 if (
217 Configuration.getWorker()?.elementsPerWorker === 'auto' &&
218 this.numberOfChargingStations > availableParallelism()
219 ) {
220 elementsPerWorker = Math.round(this.numberOfChargingStations / availableParallelism());
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 poolOptions: {
233 workerChoiceStrategy: Configuration.getWorker().poolStrategy,
234 messageHandler: this.messageHandler.bind(this) as (message: unknown) => void,
235 },
236 },
237 ));
238 }
239
240 private messageHandler(
241 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>,
242 ): void {
243 // logger.debug(
244 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
245 // msg,
246 // null,
247 // 2
248 // )}`
249 // );
250 try {
251 switch (msg.id) {
252 case ChargingStationWorkerMessageEvents.started:
253 this.workerEventStarted(msg.data as ChargingStationData);
254 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
255 break;
256 case ChargingStationWorkerMessageEvents.stopped:
257 this.workerEventStopped(msg.data as ChargingStationData);
258 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
259 break;
260 case ChargingStationWorkerMessageEvents.updated:
261 this.workerEventUpdated(msg.data as ChargingStationData);
262 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
263 break;
264 case ChargingStationWorkerMessageEvents.performanceStatistics:
265 this.workerEventPerformanceStatistics(msg.data as Statistics);
266 this.emit(
267 ChargingStationWorkerMessageEvents.performanceStatistics,
268 msg.data as Statistics,
269 );
270 break;
271 default:
272 throw new BaseError(
273 `Unknown event type: '${msg.id}' for data: ${JSON.stringify(msg.data, null, 2)}`,
274 );
275 }
276 } catch (error) {
277 logger.error(
278 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
279 msg.id
280 }' event:`,
281 error,
282 );
283 }
284 }
285
286 private workerEventStarted = (data: ChargingStationData) => {
287 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
288 ++this.numberOfStartedChargingStations;
289 logger.info(
290 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
291 data.stationInfo.chargingStationId
292 } (hashId: ${data.stationInfo.hashId}) started (${
293 this.numberOfStartedChargingStations
294 } started from ${this.numberOfChargingStations})`,
295 );
296 };
297
298 private workerEventStopped = (data: ChargingStationData) => {
299 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
300 --this.numberOfStartedChargingStations;
301 logger.info(
302 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
303 data.stationInfo.chargingStationId
304 } (hashId: ${data.stationInfo.hashId}) stopped (${
305 this.numberOfStartedChargingStations
306 } started from ${this.numberOfChargingStations})`,
307 );
308 };
309
310 private workerEventUpdated = (data: ChargingStationData) => {
311 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
312 };
313
314 private workerEventPerformanceStatistics = (data: Statistics) => {
315 this.storage.storePerformanceStatistics(data) as void;
316 };
317
318 private initializeCounters() {
319 if (this.initializedCounters === false) {
320 this.resetCounters();
321 const stationTemplateUrls = Configuration.getStationTemplateUrls();
322 if (isNotEmptyArray(stationTemplateUrls)) {
323 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
324 for (const stationTemplateUrl of stationTemplateUrls) {
325 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
326 }
327 } else {
328 console.warn(
329 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting"),
330 );
331 process.exit(exitCodes.missingChargingStationsConfiguration);
332 }
333 if (this.numberOfChargingStations === 0) {
334 console.warn(
335 chalk.yellow('No charging station template enabled in configuration, exiting'),
336 );
337 process.exit(exitCodes.noChargingStationTemplates);
338 }
339 this.initializedCounters = true;
340 }
341 }
342
343 private resetCounters(): void {
344 this.numberOfChargingStationTemplates = 0;
345 this.numberOfChargingStations = 0;
346 this.numberOfStartedChargingStations = 0;
347 }
348
349 private async startChargingStation(
350 index: number,
351 stationTemplateUrl: StationTemplateUrl,
352 ): Promise<void> {
353 await this.workerImplementation?.addElement({
354 index,
355 templateFile: join(
356 dirname(fileURLToPath(import.meta.url)),
357 'assets',
358 'station-templates',
359 stationTemplateUrl.file,
360 ),
361 });
362 }
363
364 private gracefulShutdown = (): void => {
365 console.info(`${chalk.green('Graceful shutdown')}`);
366 this.stop()
367 .then(() => {
368 process.exit(0);
369 })
370 .catch((error) => {
371 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
372 process.exit(1);
373 });
374 };
375
376 private logPrefix = (): string => {
377 return logPrefix(' Bootstrap |');
378 };
379 }