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