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