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