refactor: cleanup unneeded type casting
[e-mobility-charging-stations-simulator.git] / src / charging-station / Bootstrap.ts
1 // Partial Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
2
3 import { EventEmitter } from 'node:events'
4 import { dirname, extname, join } from 'node:path'
5 import process, { exit } from 'node:process'
6 import { fileURLToPath } from 'node:url'
7 import type { Worker } from 'worker_threads'
8
9 import chalk from 'chalk'
10 import { type MessageHandler, availableParallelism } from 'poolifier'
11
12 import { waitChargingStationEvents } from './Helpers.js'
13 import type { AbstractUIServer } from './ui-server/AbstractUIServer.js'
14 import { UIServerFactory } from './ui-server/UIServerFactory.js'
15 import { version } from '../../package.json'
16 import { BaseError } from '../exception/index.js'
17 import { type Storage, StorageFactory } from '../performance/index.js'
18 import {
19 type ChargingStationData,
20 type ChargingStationWorkerData,
21 type ChargingStationWorkerMessage,
22 type ChargingStationWorkerMessageData,
23 ChargingStationWorkerMessageEvents,
24 ConfigurationSection,
25 ProcedureName,
26 type StationTemplateUrl,
27 type Statistics,
28 type StorageConfiguration,
29 type UIServerConfiguration,
30 type WorkerConfiguration
31 } from '../types/index.js'
32 import {
33 Configuration,
34 Constants,
35 formatDurationMilliSeconds,
36 generateUUID,
37 handleUncaughtException,
38 handleUnhandledRejection,
39 isAsyncFunction,
40 isNotEmptyArray,
41 logPrefix,
42 logger
43 } from '../utils/index.js'
44 import { type WorkerAbstract, WorkerFactory } from '../worker/index.js'
45
46 const moduleName = 'Bootstrap'
47
48 enum exitCodes {
49 succeeded = 0,
50 missingChargingStationsConfiguration = 1,
51 noChargingStationTemplates = 2,
52 gracefulShutdownError = 3
53 }
54
55 export class Bootstrap extends EventEmitter {
56 private static instance: Bootstrap | null = null
57 public numberOfChargingStations!: number
58 public numberOfChargingStationTemplates!: number
59 private workerImplementation?: WorkerAbstract<ChargingStationWorkerData>
60 private readonly uiServer?: AbstractUIServer
61 private storage?: Storage
62 private numberOfStartedChargingStations!: number
63 private readonly version: string = version
64 private initializedCounters: boolean
65 private started: boolean
66 private starting: boolean
67 private stopping: boolean
68
69 private constructor () {
70 super()
71 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
72 process.on(signal, this.gracefulShutdown.bind(this))
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.uiServer = UIServerFactory.getUIServerImplementation(
83 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
84 )
85 Configuration.configurationChangeCallback = async () => {
86 await Bootstrap.getInstance().restart(false)
87 }
88 }
89
90 public static getInstance (): Bootstrap {
91 if (Bootstrap.instance === null) {
92 Bootstrap.instance = new Bootstrap()
93 }
94 return Bootstrap.instance
95 }
96
97 public async start (): Promise<void> {
98 if (!this.started) {
99 if (!this.starting) {
100 this.starting = true
101 this.on(ChargingStationWorkerMessageEvents.started, this.workerEventStarted)
102 this.on(ChargingStationWorkerMessageEvents.stopped, this.workerEventStopped)
103 this.on(ChargingStationWorkerMessageEvents.updated, this.workerEventUpdated)
104 this.on(
105 ChargingStationWorkerMessageEvents.performanceStatistics,
106 this.workerEventPerformanceStatistics
107 )
108 this.initializeCounters()
109 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
110 ConfigurationSection.worker
111 )
112 this.initializeWorkerImplementation(workerConfiguration)
113 await this.workerImplementation?.start()
114 const performanceStorageConfiguration =
115 Configuration.getConfigurationSection<StorageConfiguration>(
116 ConfigurationSection.performanceStorage
117 )
118 if (performanceStorageConfiguration.enabled === true) {
119 this.storage = StorageFactory.getStorage(
120 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
121 performanceStorageConfiguration.type!,
122 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
123 performanceStorageConfiguration.uri!,
124 this.logPrefix()
125 )
126 await this.storage?.open()
127 }
128 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
129 .enabled === true && this.uiServer?.start()
130 // Start ChargingStation object instance in worker thread
131 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
132 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
133 try {
134 const nbStations = stationTemplateUrl.numberOfStations
135 for (let index = 1; index <= nbStations; index++) {
136 await this.startChargingStation(index, stationTemplateUrl)
137 }
138 } catch (error) {
139 console.error(
140 chalk.red(
141 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
142 ),
143 error
144 )
145 }
146 }
147 console.info(
148 chalk.green(
149 `Charging stations simulator ${
150 this.version
151 } started with ${this.numberOfChargingStations.toString()} charging station(s) from ${this.numberOfChargingStationTemplates.toString()} configured charging station template(s) and ${
152 Configuration.workerDynamicPoolInUse()
153 ? `${workerConfiguration.poolMinSize?.toString()}/`
154 : ''
155 }${this.workerImplementation?.size}${
156 Configuration.workerPoolInUse()
157 ? `/${workerConfiguration.poolMaxSize?.toString()}`
158 : ''
159 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
160 this.workerImplementation?.maxElementsPerWorker != null
161 ? ` (${this.workerImplementation.maxElementsPerWorker} charging station(s) per worker)`
162 : ''
163 }`
164 )
165 )
166 Configuration.workerDynamicPoolInUse() &&
167 console.warn(
168 chalk.yellow(
169 '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'
170 )
171 )
172 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info)
173 this.started = true
174 this.starting = false
175 } else {
176 console.error(chalk.red('Cannot start an already starting charging stations simulator'))
177 }
178 } else {
179 console.error(chalk.red('Cannot start an already started charging stations simulator'))
180 }
181 }
182
183 public async stop (stopChargingStations = true): Promise<void> {
184 if (this.started) {
185 if (!this.stopping) {
186 this.stopping = true
187 if (stopChargingStations) {
188 await this.uiServer?.sendInternalRequest(
189 this.uiServer.buildProtocolRequest(
190 generateUUID(),
191 ProcedureName.STOP_CHARGING_STATION,
192 Constants.EMPTY_FROZEN_OBJECT
193 )
194 )
195 try {
196 await this.waitChargingStationsStopped()
197 } catch (error) {
198 console.error(chalk.red('Error while waiting for charging stations to stop: '), error)
199 }
200 }
201 await this.workerImplementation?.stop()
202 delete this.workerImplementation
203 this.removeAllListeners()
204 await this.storage?.close()
205 delete this.storage
206 this.resetCounters()
207 this.initializedCounters = false
208 this.started = false
209 this.stopping = false
210 } else {
211 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'))
212 }
213 } else {
214 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'))
215 }
216 }
217
218 public async restart (stopChargingStations?: boolean): Promise<void> {
219 await this.stop(stopChargingStations)
220 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
221 .enabled === false && this.uiServer?.stop()
222 await this.start()
223 }
224
225 private async waitChargingStationsStopped (): Promise<string> {
226 return await new Promise<string>((resolve, reject) => {
227 const waitTimeout = setTimeout(() => {
228 const timeoutMessage = `Timeout ${formatDurationMilliSeconds(
229 Constants.STOP_CHARGING_STATIONS_TIMEOUT
230 )} reached at stopping charging stations`
231 console.warn(chalk.yellow(timeoutMessage))
232 reject(new Error(timeoutMessage))
233 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT)
234 waitChargingStationEvents(
235 this,
236 ChargingStationWorkerMessageEvents.stopped,
237 this.numberOfStartedChargingStations
238 )
239 .then(() => {
240 resolve('Charging stations stopped')
241 })
242 .catch(reject)
243 .finally(() => {
244 clearTimeout(waitTimeout)
245 })
246 })
247 }
248
249 private initializeWorkerImplementation (workerConfiguration: WorkerConfiguration): void {
250 let elementsPerWorker: number | undefined
251 switch (workerConfiguration.elementsPerWorker) {
252 case 'auto':
253 elementsPerWorker =
254 this.numberOfChargingStations > availableParallelism()
255 ? Math.round(this.numberOfChargingStations / (availableParallelism() * 1.5))
256 : 1
257 break
258 case 'all':
259 elementsPerWorker = this.numberOfChargingStations
260 break
261 }
262 this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
263 join(
264 dirname(fileURLToPath(import.meta.url)),
265 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
266 ),
267 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
268 workerConfiguration.processType!,
269 {
270 workerStartDelay: workerConfiguration.startDelay,
271 elementStartDelay: workerConfiguration.elementStartDelay,
272 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
273 poolMaxSize: workerConfiguration.poolMaxSize!,
274 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
275 poolMinSize: workerConfiguration.poolMinSize!,
276 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
277 poolOptions: {
278 messageHandler: this.messageHandler.bind(this) as MessageHandler<Worker>,
279 workerOptions: { resourceLimits: workerConfiguration.resourceLimits }
280 }
281 }
282 )
283 }
284
285 private messageHandler (
286 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>
287 ): void {
288 // logger.debug(
289 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
290 // msg,
291 // undefined,
292 // 2
293 // )}`
294 // )
295 try {
296 switch (msg.event) {
297 case ChargingStationWorkerMessageEvents.started:
298 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData)
299 break
300 case ChargingStationWorkerMessageEvents.stopped:
301 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData)
302 break
303 case ChargingStationWorkerMessageEvents.updated:
304 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData)
305 break
306 case ChargingStationWorkerMessageEvents.performanceStatistics:
307 this.emit(
308 ChargingStationWorkerMessageEvents.performanceStatistics,
309 msg.data as Statistics
310 )
311 break
312 case ChargingStationWorkerMessageEvents.startWorkerElementError:
313 logger.error(
314 `${this.logPrefix()} ${moduleName}.messageHandler: Error occured while starting worker element:`,
315 msg.data
316 )
317 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data)
318 break
319 case ChargingStationWorkerMessageEvents.startedWorkerElement:
320 break
321 default:
322 throw new BaseError(
323 `Unknown charging station worker event: '${
324 msg.event
325 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
326 )
327 }
328 } catch (error) {
329 logger.error(
330 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
331 msg.event
332 }' event:`,
333 error
334 )
335 }
336 }
337
338 private readonly workerEventStarted = (data: ChargingStationData): void => {
339 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
340 ++this.numberOfStartedChargingStations
341 logger.info(
342 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
343 data.stationInfo.chargingStationId
344 } (hashId: ${data.stationInfo.hashId}) started (${
345 this.numberOfStartedChargingStations
346 } started from ${this.numberOfChargingStations})`
347 )
348 }
349
350 private readonly workerEventStopped = (data: ChargingStationData): void => {
351 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
352 --this.numberOfStartedChargingStations
353 logger.info(
354 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
355 data.stationInfo.chargingStationId
356 } (hashId: ${data.stationInfo.hashId}) stopped (${
357 this.numberOfStartedChargingStations
358 } started from ${this.numberOfChargingStations})`
359 )
360 }
361
362 private readonly workerEventUpdated = (data: ChargingStationData): void => {
363 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
364 }
365
366 private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
367 // eslint-disable-next-line @typescript-eslint/unbound-method
368 if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
369 (
370 this.storage.storePerformanceStatistics as (
371 performanceStatistics: Statistics
372 ) => Promise<void>
373 )(data).catch(Constants.EMPTY_FUNCTION)
374 } else {
375 (this.storage?.storePerformanceStatistics as (performanceStatistics: Statistics) => void)(
376 data
377 )
378 }
379 }
380
381 private initializeCounters (): void {
382 if (!this.initializedCounters) {
383 this.resetCounters()
384 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
385 const stationTemplateUrls = Configuration.getStationTemplateUrls()!
386 if (isNotEmptyArray(stationTemplateUrls)) {
387 this.numberOfChargingStationTemplates = stationTemplateUrls.length
388 for (const stationTemplateUrl of stationTemplateUrls) {
389 this.numberOfChargingStations += stationTemplateUrl.numberOfStations
390 }
391 } else {
392 console.warn(
393 chalk.yellow("'stationTemplateUrls' not defined or empty in configuration, exiting")
394 )
395 exit(exitCodes.missingChargingStationsConfiguration)
396 }
397 if (this.numberOfChargingStations === 0) {
398 console.warn(chalk.yellow('No charging station template enabled in configuration, exiting'))
399 exit(exitCodes.noChargingStationTemplates)
400 }
401 this.initializedCounters = true
402 }
403 }
404
405 private resetCounters (): void {
406 this.numberOfChargingStationTemplates = 0
407 this.numberOfChargingStations = 0
408 this.numberOfStartedChargingStations = 0
409 }
410
411 private async startChargingStation (
412 index: number,
413 stationTemplateUrl: StationTemplateUrl
414 ): Promise<void> {
415 await this.workerImplementation?.addElement({
416 index,
417 templateFile: join(
418 dirname(fileURLToPath(import.meta.url)),
419 'assets',
420 'station-templates',
421 stationTemplateUrl.file
422 )
423 })
424 }
425
426 private gracefulShutdown (): void {
427 this.stop()
428 .then(() => {
429 console.info(chalk.green('Graceful shutdown'))
430 this.uiServer?.stop()
431 // stop() asks for charging stations to stop by default
432 this.waitChargingStationsStopped()
433 .then(() => {
434 exit(exitCodes.succeeded)
435 })
436 .catch(() => {
437 exit(exitCodes.gracefulShutdownError)
438 })
439 })
440 .catch(error => {
441 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error)
442 exit(exitCodes.gracefulShutdownError)
443 })
444 }
445
446 private readonly logPrefix = (): string => {
447 return logPrefix(' Bootstrap |')
448 }
449 }