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