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