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