refactor: use 'join' to build template relative path
[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 type InternalTemplateStatistics,
29 ProcedureName,
30 type SimulatorState,
31 type Statistics,
32 type StorageConfiguration,
33 type UIServerConfiguration,
34 type WorkerConfiguration
35 } from '../types/index.js'
36 import {
37 Configuration,
38 Constants,
39 buildTemplateStatisticsPayload,
40 formatDurationMilliSeconds,
41 generateUUID,
42 handleUncaughtException,
43 handleUnhandledRejection,
44 isAsyncFunction,
45 isNotEmptyArray,
46 logPrefix,
47 logger
48 } from '../utils/index.js'
49 import { type WorkerAbstract, WorkerFactory } from '../worker/index.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 =
212 this.templateStatistics.get(parse(stationTemplateUrl.file).name)?.configured ??
213 stationTemplateUrl.numberOfStations
214 for (let index = 1; index <= nbStations; index++) {
215 await this.addChargingStation(index, stationTemplateUrl.file)
216 }
217 } catch (error) {
218 console.error(
219 chalk.red(
220 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
221 ),
222 error
223 )
224 }
225 }
226 console.info(
227 chalk.green(
228 `Charging stations simulator ${
229 this.version
230 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
231 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}/` : ''
232 }${this.workerImplementation?.size}${
233 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}` : ''
234 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
235 this.workerImplementation?.maxElementsPerWorker != null
236 ? ` (${this.workerImplementation.maxElementsPerWorker} charging station(s) per worker)`
237 : ''
238 }`
239 )
240 )
241 Configuration.workerDynamicPoolInUse() &&
242 console.warn(
243 chalk.yellow(
244 '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'
245 )
246 )
247 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info)
248 this.started = true
249 this.starting = false
250 } else {
251 console.error(chalk.red('Cannot start an already starting charging stations simulator'))
252 }
253 } else {
254 console.error(chalk.red('Cannot start an already started charging stations simulator'))
255 }
256 }
257
258 public async stop (): Promise<void> {
259 if (this.started) {
260 if (!this.stopping) {
261 this.stopping = true
262 await this.uiServer.sendInternalRequest(
263 this.uiServer.buildProtocolRequest(
264 generateUUID(),
265 ProcedureName.STOP_CHARGING_STATION,
266 Constants.EMPTY_FROZEN_OBJECT
267 )
268 )
269 try {
270 await this.waitChargingStationsStopped()
271 } catch (error) {
272 console.error(chalk.red('Error while waiting for charging stations to stop: '), error)
273 }
274 await this.workerImplementation?.stop()
275 delete this.workerImplementation
276 this.removeAllListeners()
277 this.uiServer.clearCaches()
278 this.initializedCounters = false
279 await this.storage?.close()
280 delete this.storage
281 this.started = false
282 this.stopping = false
283 } else {
284 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'))
285 }
286 } else {
287 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'))
288 }
289 }
290
291 private async restart (): Promise<void> {
292 await this.stop()
293 if (
294 this.uiServerStarted &&
295 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
296 .enabled !== true
297 ) {
298 this.uiServer.stop()
299 this.uiServerStarted = false
300 }
301 await this.start()
302 }
303
304 private async waitChargingStationsStopped (): Promise<string> {
305 return await new Promise<string>((resolve, reject) => {
306 const waitTimeout = setTimeout(() => {
307 const timeoutMessage = `Timeout ${formatDurationMilliSeconds(
308 Constants.STOP_CHARGING_STATIONS_TIMEOUT
309 )} reached at stopping charging stations`
310 console.warn(chalk.yellow(timeoutMessage))
311 reject(new Error(timeoutMessage))
312 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT)
313 waitChargingStationEvents(
314 this,
315 ChargingStationWorkerMessageEvents.stopped,
316 this.numberOfStartedChargingStations
317 )
318 .then(() => {
319 resolve('Charging stations stopped')
320 })
321 .catch(reject)
322 .finally(() => {
323 clearTimeout(waitTimeout)
324 })
325 })
326 }
327
328 private initializeWorkerImplementation (workerConfiguration: WorkerConfiguration): void {
329 if (!isMainThread) {
330 return
331 }
332 let elementsPerWorker: number
333 switch (workerConfiguration.elementsPerWorker) {
334 case 'all':
335 elementsPerWorker = this.numberOfConfiguredChargingStations
336 break
337 case 'auto':
338 default:
339 elementsPerWorker =
340 this.numberOfConfiguredChargingStations > availableParallelism()
341 ? Math.round(this.numberOfConfiguredChargingStations / (availableParallelism() * 1.5))
342 : 1
343 break
344 }
345 this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
346 join(
347 dirname(fileURLToPath(import.meta.url)),
348 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
349 ),
350 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
351 workerConfiguration.processType!,
352 {
353 workerStartDelay: workerConfiguration.startDelay,
354 elementStartDelay: workerConfiguration.elementStartDelay,
355 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
356 poolMaxSize: workerConfiguration.poolMaxSize!,
357 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
358 poolMinSize: workerConfiguration.poolMinSize!,
359 elementsPerWorker,
360 poolOptions: {
361 messageHandler: this.messageHandler.bind(this) as MessageHandler<Worker>,
362 workerOptions: { resourceLimits: workerConfiguration.resourceLimits }
363 }
364 }
365 )
366 }
367
368 private messageHandler (
369 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>
370 ): void {
371 // logger.debug(
372 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
373 // msg,
374 // undefined,
375 // 2
376 // )}`
377 // )
378 try {
379 switch (msg.event) {
380 case ChargingStationWorkerMessageEvents.added:
381 this.emit(ChargingStationWorkerMessageEvents.added, msg.data)
382 break
383 case ChargingStationWorkerMessageEvents.deleted:
384 this.emit(ChargingStationWorkerMessageEvents.deleted, msg.data)
385 break
386 case ChargingStationWorkerMessageEvents.started:
387 this.emit(ChargingStationWorkerMessageEvents.started, msg.data)
388 break
389 case ChargingStationWorkerMessageEvents.stopped:
390 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data)
391 break
392 case ChargingStationWorkerMessageEvents.updated:
393 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data)
394 break
395 case ChargingStationWorkerMessageEvents.performanceStatistics:
396 this.emit(ChargingStationWorkerMessageEvents.performanceStatistics, msg.data)
397 break
398 case ChargingStationWorkerMessageEvents.addedWorkerElement:
399 this.emit(ChargingStationWorkerMessageEvents.addWorkerElement, msg.data)
400 break
401 case ChargingStationWorkerMessageEvents.workerElementError:
402 this.emit(ChargingStationWorkerMessageEvents.workerElementError, msg.data)
403 break
404 default:
405 throw new BaseError(
406 `Unknown charging station worker event: '${
407 msg.event
408 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
409 )
410 }
411 } catch (error) {
412 logger.error(
413 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
414 msg.event
415 }' event:`,
416 error
417 )
418 }
419 }
420
421 private readonly workerEventAdded = (data: ChargingStationData): void => {
422 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
423 logger.info(
424 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
425 data.stationInfo.chargingStationId
426 } (hashId: ${data.stationInfo.hashId}) added (${
427 this.numberOfAddedChargingStations
428 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
429 )
430 }
431
432 private readonly workerEventDeleted = (data: ChargingStationData): void => {
433 this.uiServer.chargingStations.delete(data.stationInfo.hashId)
434 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
435 const templateStatistics = this.templateStatistics.get(data.stationInfo.templateName)!
436 --templateStatistics.added
437 templateStatistics.indexes.delete(data.stationInfo.templateIndex)
438 logger.info(
439 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
440 data.stationInfo.chargingStationId
441 } (hashId: ${data.stationInfo.hashId}) deleted (${
442 this.numberOfAddedChargingStations
443 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
444 )
445 }
446
447 private readonly workerEventStarted = (data: ChargingStationData): void => {
448 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
449 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
450 ++this.templateStatistics.get(data.stationInfo.templateName)!.started
451 logger.info(
452 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
453 data.stationInfo.chargingStationId
454 } (hashId: ${data.stationInfo.hashId}) started (${
455 this.numberOfStartedChargingStations
456 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
457 )
458 }
459
460 private readonly workerEventStopped = (data: ChargingStationData): void => {
461 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
462 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
463 --this.templateStatistics.get(data.stationInfo.templateName)!.started
464 logger.info(
465 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
466 data.stationInfo.chargingStationId
467 } (hashId: ${data.stationInfo.hashId}) stopped (${
468 this.numberOfStartedChargingStations
469 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
470 )
471 }
472
473 private readonly workerEventUpdated = (data: ChargingStationData): void => {
474 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
475 }
476
477 private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
478 // eslint-disable-next-line @typescript-eslint/unbound-method
479 if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
480 (
481 this.storage.storePerformanceStatistics as (
482 performanceStatistics: Statistics
483 ) => Promise<void>
484 )(data).catch(Constants.EMPTY_FUNCTION)
485 } else {
486 (this.storage?.storePerformanceStatistics as (performanceStatistics: Statistics) => void)(
487 data
488 )
489 }
490 }
491
492 private initializeCounters (): void {
493 if (!this.initializedCounters) {
494 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
495 const stationTemplateUrls = Configuration.getStationTemplateUrls()!
496 if (isNotEmptyArray(stationTemplateUrls)) {
497 for (const stationTemplateUrl of stationTemplateUrls) {
498 const templateName = join(
499 parse(stationTemplateUrl.file).dir,
500 parse(stationTemplateUrl.file).name
501 )
502 this.templateStatistics.set(templateName, {
503 configured: stationTemplateUrl.numberOfStations,
504 added: 0,
505 started: 0,
506 indexes: new Set<number>()
507 })
508 this.uiServer.chargingStationTemplates.add(templateName)
509 }
510 if (this.templateStatistics.size !== stationTemplateUrls.length) {
511 console.error(
512 chalk.red(
513 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
514 )
515 )
516 exit(exitCodes.duplicateChargingStationTemplateUrls)
517 }
518 } else {
519 console.error(
520 chalk.red("'stationTemplateUrls' not defined or empty, please check your configuration")
521 )
522 exit(exitCodes.missingChargingStationsConfiguration)
523 }
524 if (
525 this.numberOfConfiguredChargingStations === 0 &&
526 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
527 .enabled !== true
528 ) {
529 console.error(
530 chalk.red(
531 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
532 )
533 )
534 exit(exitCodes.noChargingStationTemplates)
535 }
536 this.initializedCounters = true
537 }
538 }
539
540 public async addChargingStation (
541 index: number,
542 stationTemplateFile: string,
543 options?: ChargingStationOptions
544 ): Promise<void> {
545 await this.workerImplementation?.addElement({
546 index,
547 templateFile: join(
548 dirname(fileURLToPath(import.meta.url)),
549 'assets',
550 'station-templates',
551 stationTemplateFile
552 ),
553 options
554 })
555 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
556 const templateStatistics = this.templateStatistics.get(parse(stationTemplateFile).name)!
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 }