refactor: stop !== shutdown semantic
[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 path from 'node:path';
5 import { fileURLToPath } from 'node:url';
6 import { type Worker, isMainThread } from 'node:worker_threads';
7
8 import chalk from 'chalk';
9
10 import type { AbstractUIServer } from './ui-server/AbstractUIServer';
11 import { UIServerFactory } from './ui-server/UIServerFactory';
12 import packageJson from '../../package.json' assert { type: 'json' };
13 import { BaseError } from '../exception';
14 import { type Storage, StorageFactory } from '../performance';
15 import {
16 type ChargingStationData,
17 type ChargingStationWorkerData,
18 type ChargingStationWorkerMessage,
19 type ChargingStationWorkerMessageData,
20 ChargingStationWorkerMessageEvents,
21 ProcedureName,
22 type StationTemplateUrl,
23 type Statistics,
24 } from '../types';
25 import { Configuration, Constants, ErrorUtils, Utils, logger } from '../utils';
26 import { type MessageHandler, type WorkerAbstract, WorkerFactory } from '../worker';
27
28 const moduleName = 'Bootstrap';
29
30 enum exitCodes {
31 missingChargingStationsConfiguration = 1,
32 noChargingStationTemplates = 2,
33 }
34
35 export class Bootstrap extends EventEmitter {
36 private static instance: Bootstrap | null = null;
37 public numberOfChargingStations!: number;
38 public numberOfChargingStationTemplates!: number;
39 private workerImplementation: WorkerAbstract<ChargingStationWorkerData> | null;
40 private readonly uiServer!: AbstractUIServer | null;
41 private readonly storage!: Storage;
42 private numberOfStartedChargingStations!: number;
43 private readonly version: string = packageJson.version;
44 private initializedCounters: boolean;
45 private started: boolean;
46 private readonly workerScript: string;
47
48 private constructor() {
49 super();
50 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
51 process.on(signal, this.gracefulShutdown);
52 }
53 // Enable unconditionally for now
54 ErrorUtils.handleUnhandledRejection();
55 ErrorUtils.handleUncaughtException();
56 this.initializedCounters = false;
57 this.started = false;
58 this.initializeCounters();
59 this.workerImplementation = null;
60 this.workerScript = path.join(
61 path.dirname(fileURLToPath(import.meta.url)),
62 `ChargingStationWorker${path.extname(fileURLToPath(import.meta.url))}`
63 );
64 Configuration.getUIServer().enabled === true &&
65 (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer()));
66 Configuration.getPerformanceStorage().enabled === true &&
67 (this.storage = StorageFactory.getStorage(
68 Configuration.getPerformanceStorage().type,
69 Configuration.getPerformanceStorage().uri,
70 this.logPrefix()
71 ));
72 Configuration.setConfigurationChangeCallback(async () => Bootstrap.getInstance().restart());
73 }
74
75 public static getInstance(): Bootstrap {
76 if (Bootstrap.instance === null) {
77 Bootstrap.instance = new Bootstrap();
78 }
79 return Bootstrap.instance;
80 }
81
82 public async start(): Promise<void> {
83 if (isMainThread && this.started === false) {
84 this.initializeCounters();
85 this.initializeWorkerImplementation();
86 await this.workerImplementation?.start();
87 await this.storage?.open();
88 this.uiServer?.start();
89 // Start ChargingStation object instance in worker thread
90 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()) {
91 try {
92 const nbStations = stationTemplateUrl.numberOfStations ?? 0;
93 for (let index = 1; index <= nbStations; index++) {
94 await this.startChargingStation(index, stationTemplateUrl);
95 }
96 } catch (error) {
97 console.error(
98 chalk.red(
99 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
100 ),
101 error
102 );
103 }
104 }
105 console.info(
106 chalk.green(
107 `Charging stations simulator ${
108 this.version
109 } started with ${this.numberOfChargingStations.toString()} charging station(s) from ${this.numberOfChargingStationTemplates.toString()} configured charging station template(s) and ${
110 Configuration.workerDynamicPoolInUse()
111 ? `${Configuration.getWorker().poolMinSize?.toString()}/`
112 : ''
113 }${this.workerImplementation?.size}${
114 Configuration.workerPoolInUse()
115 ? `/${Configuration.getWorker().poolMaxSize?.toString()}`
116 : ''
117 } worker(s) concurrently running in '${Configuration.getWorker().processType}' mode${
118 !Utils.isNullOrUndefined(this.workerImplementation?.maxElementsPerWorker)
119 ? ` (${this.workerImplementation?.maxElementsPerWorker} charging station(s) per worker)`
120 : ''
121 }`
122 )
123 );
124 this.started = true;
125 } else {
126 console.error(chalk.red('Cannot start an already started charging stations simulator'));
127 }
128 }
129
130 public async stop(): Promise<void> {
131 if (isMainThread && this.started === true) {
132 await this.uiServer?.sendRequestOnBroadcastChannel(
133 this.uiServer.buildProtocolRequest(
134 Utils.generateUUID(),
135 ProcedureName.STOP_CHARGING_STATION,
136 Constants.EMPTY_FREEZED_OBJECT
137 )
138 );
139 await this.waitForChargingStationsStopped();
140 await this.workerImplementation?.stop();
141 this.workerImplementation = null;
142 this.uiServer?.stop();
143 await this.storage?.close();
144 this.initializedCounters = false;
145 this.started = false;
146 } else {
147 console.error(chalk.red('Cannot stop a not started charging stations simulator'));
148 }
149 }
150
151 public async restart(): Promise<void> {
152 await this.stop();
153 await this.start();
154 }
155
156 private initializeWorkerImplementation(): void {
157 this.workerImplementation === null &&
158 (this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
159 this.workerScript,
160 Configuration.getWorker().processType,
161 {
162 workerStartDelay: Configuration.getWorker().startDelay,
163 elementStartDelay: Configuration.getWorker().elementStartDelay,
164 poolMaxSize: Configuration.getWorker().poolMaxSize,
165 poolMinSize: Configuration.getWorker().poolMinSize,
166 elementsPerWorker: Configuration.getWorker().elementsPerWorker,
167 poolOptions: {
168 workerChoiceStrategy: Configuration.getWorker().poolStrategy,
169 },
170 messageHandler: this.messageHandler.bind(this) as MessageHandler<Worker>,
171 }
172 ));
173 }
174
175 private messageHandler(
176 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>
177 ): void {
178 // logger.debug(
179 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
180 // msg,
181 // null,
182 // 2
183 // )}`
184 // );
185 try {
186 switch (msg.id) {
187 case ChargingStationWorkerMessageEvents.started:
188 this.workerEventStarted(msg.data as ChargingStationData);
189 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData);
190 break;
191 case ChargingStationWorkerMessageEvents.stopped:
192 this.workerEventStopped(msg.data as ChargingStationData);
193 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData);
194 break;
195 case ChargingStationWorkerMessageEvents.updated:
196 this.workerEventUpdated(msg.data as ChargingStationData);
197 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData);
198 break;
199 case ChargingStationWorkerMessageEvents.performanceStatistics:
200 this.workerEventPerformanceStatistics(msg.data as Statistics);
201 this.emit(
202 ChargingStationWorkerMessageEvents.performanceStatistics,
203 msg.data as Statistics
204 );
205 break;
206 default:
207 throw new BaseError(
208 `Unknown event type: '${msg.id}' for data: ${JSON.stringify(msg.data, null, 2)}`
209 );
210 }
211 } catch (error) {
212 logger.error(
213 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
214 msg.id
215 }' event:`,
216 error
217 );
218 }
219 }
220
221 private workerEventStarted = (data: ChargingStationData) => {
222 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
223 ++this.numberOfStartedChargingStations;
224 logger.info(
225 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
226 data.stationInfo.chargingStationId
227 } (hashId: ${data.stationInfo.hashId}) started (${
228 this.numberOfStartedChargingStations
229 } started from ${this.numberOfChargingStations})`
230 );
231 };
232
233 private workerEventStopped = (data: ChargingStationData) => {
234 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
235 --this.numberOfStartedChargingStations;
236 logger.info(
237 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
238 data.stationInfo.chargingStationId
239 } (hashId: ${data.stationInfo.hashId}) stopped (${
240 this.numberOfStartedChargingStations
241 } started from ${this.numberOfChargingStations})`
242 );
243 };
244
245 private workerEventUpdated = (data: ChargingStationData) => {
246 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data);
247 };
248
249 private workerEventPerformanceStatistics = (data: Statistics) => {
250 this.storage.storePerformanceStatistics(data) as void;
251 };
252
253 private initializeCounters() {
254 if (this.initializedCounters === false) {
255 this.numberOfChargingStationTemplates = 0;
256 this.numberOfChargingStations = 0;
257 const stationTemplateUrls = Configuration.getStationTemplateUrls();
258 if (Utils.isNotEmptyArray(stationTemplateUrls)) {
259 this.numberOfChargingStationTemplates = stationTemplateUrls.length;
260 for (const stationTemplateUrl of stationTemplateUrls) {
261 this.numberOfChargingStations += stationTemplateUrl.numberOfStations ?? 0;
262 }
263 } else {
264 console.warn(
265 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting")
266 );
267 process.exit(exitCodes.missingChargingStationsConfiguration);
268 }
269 if (this.numberOfChargingStations === 0) {
270 console.warn(
271 chalk.yellow('No charging station template enabled in configuration, exiting')
272 );
273 process.exit(exitCodes.noChargingStationTemplates);
274 }
275 this.numberOfStartedChargingStations = 0;
276 this.initializedCounters = true;
277 }
278 }
279
280 private async startChargingStation(
281 index: number,
282 stationTemplateUrl: StationTemplateUrl
283 ): Promise<void> {
284 await this.workerImplementation?.addElement({
285 index,
286 templateFile: path.join(
287 path.dirname(fileURLToPath(import.meta.url)),
288 'assets',
289 'station-templates',
290 stationTemplateUrl.file
291 ),
292 });
293 }
294
295 private gracefulShutdown = (): void => {
296 console.info(`${chalk.green('Graceful shutdown')}`);
297 this.stop()
298 .then(() => {
299 process.exit(0);
300 })
301 .catch((error) => {
302 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error);
303 process.exit(1);
304 });
305 };
306
307 private waitForChargingStationsStopped = async (
308 stoppedEventsToWait = this.numberOfStartedChargingStations
309 ): Promise<number> => {
310 return new Promise((resolve) => {
311 let stoppedEvents = 0;
312 if (stoppedEventsToWait === 0) {
313 resolve(stoppedEvents);
314 }
315 this.on(ChargingStationWorkerMessageEvents.stopped, () => {
316 ++stoppedEvents;
317 if (stoppedEvents === stoppedEventsToWait) {
318 resolve(stoppedEvents);
319 }
320 });
321 });
322 };
323
324 private logPrefix = (): string => {
325 return Utils.logPrefix(' Bootstrap |');
326 };
327 }