1 // Partial Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
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'
9 import chalk from
'chalk'
10 import { availableParallelism
, type MessageHandler
} from
'poolifier'
11 import type { Worker
} from
'worker_threads'
13 import { version
} from
'../../package.json'
14 import { BaseError
} from
'../exception/index.js'
15 import { type Storage
, StorageFactory
} from
'../performance/index.js'
17 type ChargingStationData
,
18 type ChargingStationOptions
,
19 type ChargingStationWorkerData
,
20 type ChargingStationWorkerEventError
,
21 type ChargingStationWorkerMessage
,
22 type ChargingStationWorkerMessageData
,
23 ChargingStationWorkerMessageEvents
,
28 type StorageConfiguration
,
29 type TemplateStatistics
,
30 type UIServerConfiguration
,
31 type WorkerConfiguration
32 } from
'../types/index.js'
36 formatDurationMilliSeconds
,
38 handleUncaughtException
,
39 handleUnhandledRejection
,
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'
50 const moduleName
= 'Bootstrap'
54 missingChargingStationsConfiguration
= 1,
55 duplicateChargingStationTemplateUrls
= 2,
56 noChargingStationTemplates
= 3,
57 gracefulShutdownError
= 4
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
73 private constructor () {
75 for (const signal
of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
76 process
.on(signal
, this.gracefulShutdown
.bind(this))
78 // Enable unconditionally for now
79 handleUnhandledRejection()
80 handleUncaughtException()
84 this.initializedCounters
= false
85 this.uiServerStarted
= false
86 this.templateStatistics
= new Map
<string, TemplateStatistics
>()
87 this.initializeWorkerImplementation(
88 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
90 this.uiServer
= UIServerFactory
.getUIServerImplementation(
91 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
93 this.initializeCounters()
94 Configuration
.configurationChangeCallback
= async () => {
96 await Bootstrap
.getInstance().restart()
101 public static getInstance (): Bootstrap
{
102 if (Bootstrap
.instance
=== null) {
103 Bootstrap
.instance
= new Bootstrap()
105 return Bootstrap
.instance
108 public get
numberOfChargingStationTemplates (): number {
109 return this.templateStatistics
.size
112 public get
numberOfConfiguredChargingStations (): number {
113 return [...this.templateStatistics
.values()].reduce(
114 (accumulator
, value
) => accumulator
+ value
.configured
,
119 public getState (): SimulatorState
{
121 version
: this.version
,
122 started
: this.started
,
123 templateStatistics
: this.templateStatistics
127 public getLastIndex (templateName
: string): number {
128 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
129 const indexes
= [...this.templateStatistics
.get(templateName
)!.indexes
]
131 .sort((a
, b
) => a
- b
)
132 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
133 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
137 return indexes
[indexes
.length
- 1]
140 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
141 return this.storage
?.getPerformanceStatistics()
144 private get
numberOfAddedChargingStations (): number {
145 return [...this.templateStatistics
.values()].reduce(
146 (accumulator
, value
) => accumulator
+ value
.added
,
151 private get
numberOfStartedChargingStations (): number {
152 return [...this.templateStatistics
.values()].reduce(
153 (accumulator
, value
) => accumulator
+ value
.started
,
158 public async start (): Promise
<void> {
160 if (!this.starting
) {
162 this.on(ChargingStationWorkerMessageEvents
.added
, this.workerEventAdded
)
163 this.on(ChargingStationWorkerMessageEvents
.deleted
, this.workerEventDeleted
)
164 this.on(ChargingStationWorkerMessageEvents
.started
, this.workerEventStarted
)
165 this.on(ChargingStationWorkerMessageEvents
.stopped
, this.workerEventStopped
)
166 this.on(ChargingStationWorkerMessageEvents
.updated
, this.workerEventUpdated
)
168 ChargingStationWorkerMessageEvents
.performanceStatistics
,
169 this.workerEventPerformanceStatistics
172 ChargingStationWorkerMessageEvents
.workerElementError
,
173 (eventError
: ChargingStationWorkerEventError
) => {
175 `${this.logPrefix()} ${moduleName}.start: Error occurred while handling '${eventError.event}' event on worker:`,
180 this.initializeCounters()
181 // eslint-disable-next-line @typescript-eslint/unbound-method
182 if (isAsyncFunction(this.workerImplementation
?.start
)) {
183 await this.workerImplementation
.start()
185 (this.workerImplementation
?.start
as () => void)()
187 const performanceStorageConfiguration
=
188 Configuration
.getConfigurationSection
<StorageConfiguration
>(
189 ConfigurationSection
.performanceStorage
191 if (performanceStorageConfiguration
.enabled
=== true) {
192 this.storage
= StorageFactory
.getStorage(
193 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
194 performanceStorageConfiguration
.type!,
195 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
196 performanceStorageConfiguration
.uri
!,
199 await this.storage
?.open()
202 !this.uiServerStarted
&&
203 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
204 ConfigurationSection
.uiServer
207 this.uiServer
.start()
208 this.uiServerStarted
= true
210 // Start ChargingStation object instance in worker thread
211 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
212 for (const stationTemplateUrl
of Configuration
.getStationTemplateUrls()!) {
214 const nbStations
= stationTemplateUrl
.numberOfStations
215 for (let index
= 1; index
<= nbStations
; index
++) {
216 await this.addChargingStation(index
, stationTemplateUrl
.file
)
221 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
227 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
228 ConfigurationSection
.worker
232 `Charging stations simulator ${
234 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} 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
)`
245 Configuration
.workerDynamicPoolInUse() &&
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'
251 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
253 this.starting
= false
255 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
258 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
262 public async stop (): Promise
<void> {
264 if (!this.stopping
) {
266 await this.uiServer
.sendInternalRequest(
267 this.uiServer
.buildProtocolRequest(
269 ProcedureName
.STOP_CHARGING_STATION
,
270 Constants
.EMPTY_FROZEN_OBJECT
274 await this.waitChargingStationsStopped()
276 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
278 await this.workerImplementation
?.stop()
279 this.removeAllListeners()
280 this.uiServer
.clearCaches()
281 this.initializedCounters
= false
282 await this.storage
?.close()
285 this.stopping
= false
287 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
290 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
294 private async restart (): Promise
<void> {
296 // FIXME: initialize worker implementation only if the worker section has changed
297 this.initializeWorkerImplementation(
298 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
301 this.uiServerStarted
&&
302 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
306 this.uiServerStarted
= false
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(
322 ChargingStationWorkerMessageEvents
.stopped
,
323 this.numberOfStartedChargingStations
326 resolve('Charging stations stopped')
330 clearTimeout(waitTimeout
)
335 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
339 let elementsPerWorker
: number
340 switch (workerConfiguration
.elementsPerWorker
) {
342 elementsPerWorker
= this.numberOfConfiguredChargingStations
347 this.numberOfConfiguredChargingStations
> availableParallelism()
348 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
352 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<ChargingStationWorkerData
>(
354 dirname(fileURLToPath(import.meta
.url
)),
355 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
357 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
358 workerConfiguration
.processType
!,
360 workerStartDelay
: workerConfiguration
.startDelay
,
361 elementAddDelay
: workerConfiguration
.elementAddDelay
,
362 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
363 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
364 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
365 poolMinSize
: workerConfiguration
.poolMinSize
!,
368 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
369 ...(workerConfiguration
.resourceLimits
!= null && {
370 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
377 private messageHandler (
378 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
381 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
389 case ChargingStationWorkerMessageEvents
.added
:
390 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
392 case ChargingStationWorkerMessageEvents
.deleted
:
393 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
395 case ChargingStationWorkerMessageEvents
.started
:
396 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
398 case ChargingStationWorkerMessageEvents
.stopped
:
399 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
401 case ChargingStationWorkerMessageEvents
.updated
:
402 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
404 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
405 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
407 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
408 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
410 case ChargingStationWorkerMessageEvents
.workerElementError
:
411 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
415 `Unknown charging station worker event: '${
417 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
422 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
430 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
431 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
433 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
434 data.stationInfo.chargingStationId
435 } (hashId: ${data.stationInfo.hashId}) added (${
436 this.numberOfAddedChargingStations
437 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
441 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
442 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
443 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
444 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
445 --templateStatistics
.added
446 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
448 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
449 data.stationInfo.chargingStationId
450 } (hashId: ${data.stationInfo.hashId}) deleted (${
451 this.numberOfAddedChargingStations
452 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
456 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
457 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
458 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
459 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
461 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
462 data.stationInfo.chargingStationId
463 } (hashId: ${data.stationInfo.hashId}) started (${
464 this.numberOfStartedChargingStations
465 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
469 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
470 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
471 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
472 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
474 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
475 data.stationInfo.chargingStationId
476 } (hashId: ${data.stationInfo.hashId}) stopped (${
477 this.numberOfStartedChargingStations
478 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
482 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
483 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
486 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
487 // eslint-disable-next-line @typescript-eslint/unbound-method
488 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
490 this.storage
.storePerformanceStatistics
as (
491 performanceStatistics
: Statistics
493 )(data
).catch(Constants
.EMPTY_FUNCTION
)
495 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
501 private initializeCounters (): void {
502 if (!this.initializedCounters
) {
503 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
504 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
505 if (isNotEmptyArray(stationTemplateUrls
)) {
506 for (const stationTemplateUrl
of stationTemplateUrls
) {
507 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
508 this.templateStatistics
.set(templateName
, {
509 configured
: stationTemplateUrl
.numberOfStations
,
512 indexes
: new Set
<number>()
514 this.uiServer
.chargingStationTemplates
.add(templateName
)
516 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
519 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
522 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
526 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
528 exit(exitCodes
.missingChargingStationsConfiguration
)
531 this.numberOfConfiguredChargingStations
=== 0 &&
532 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
537 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
540 exit(exitCodes
.noChargingStationTemplates
)
542 this.initializedCounters
= true
546 public async addChargingStation (
548 templateFile
: string,
549 options
?: ChargingStationOptions
551 if (!this.started
&& !this.starting
) {
553 'Cannot add charging station while the charging stations simulator is not started'
556 await this.workerImplementation
?.addElement({
559 dirname(fileURLToPath(import.meta
.url
)),
566 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
567 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
568 ++templateStatistics
.added
569 templateStatistics
.indexes
.add(index
)
572 private gracefulShutdown (): void {
575 console
.info(chalk
.green('Graceful shutdown'))
577 this.uiServerStarted
= false
578 this.waitChargingStationsStopped()
580 exit(exitCodes
.succeeded
)
583 exit(exitCodes
.gracefulShutdownError
)
586 .catch((error
: unknown
) => {
587 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
588 exit(exitCodes
.gracefulShutdownError
)
592 private readonly logPrefix
= (): string => {
593 return logPrefix(' Bootstrap |')