1 // Partial Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
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'
10 import chalk from
'chalk'
11 import { type MessageHandler
, availableParallelism
} from
'poolifier'
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'
20 type ChargingStationData
,
21 type ChargingStationOptions
,
22 type ChargingStationWorkerData
,
23 type ChargingStationWorkerEventError
,
24 type ChargingStationWorkerMessage
,
25 type ChargingStationWorkerMessageData
,
26 ChargingStationWorkerMessageEvents
,
28 type InternalTemplateStatistics
,
32 type StorageConfiguration
,
33 type UIServerConfiguration
,
34 type WorkerConfiguration
35 } from
'../types/index.js'
39 buildTemplateStatisticsPayload
,
40 formatDurationMilliSeconds
,
42 handleUncaughtException
,
43 handleUnhandledRejection
,
49 } from
'../utils/index.js'
50 import { type WorkerAbstract
, WorkerFactory
} from
'../worker/index.js'
52 const moduleName
= 'Bootstrap'
56 missingChargingStationsConfiguration
= 1,
57 duplicateChargingStationTemplateUrls
= 2,
58 noChargingStationTemplates
= 3,
59 gracefulShutdownError
= 4
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
75 private constructor () {
77 for (const signal
of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
78 process
.on(signal
, this.gracefulShutdown
.bind(this))
80 // Enable unconditionally for now
81 handleUnhandledRejection()
82 handleUncaughtException()
86 this.uiServerStarted
= false
87 this.uiServer
= UIServerFactory
.getUIServerImplementation(
88 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
90 this.templateStatistics
= new Map
<string, InternalTemplateStatistics
>()
91 this.initializedCounters
= false
92 this.initializeCounters()
93 Configuration
.configurationChangeCallback
= async () => {
95 await Bootstrap
.getInstance().restart()
100 public static getInstance (): Bootstrap
{
101 if (Bootstrap
.instance
=== null) {
102 Bootstrap
.instance
= new Bootstrap()
104 return Bootstrap
.instance
107 public get
numberOfChargingStationTemplates (): number {
108 return this.templateStatistics
.size
111 public get
numberOfConfiguredChargingStations (): number {
112 return [...this.templateStatistics
.values()].reduce(
113 (accumulator
, value
) => accumulator
+ value
.configured
,
118 public getState (): SimulatorState
{
120 version
: this.version
,
121 started
: this.started
,
122 templateStatistics
: buildTemplateStatisticsPayload(this.templateStatistics
)
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
]
130 .sort((a
, b
) => a
- b
)
131 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
132 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
136 return indexes
[indexes
.length
- 1]
139 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
140 return this.storage
?.getPerformanceStatistics()
143 private get
numberOfAddedChargingStations (): number {
144 return [...this.templateStatistics
.values()].reduce(
145 (accumulator
, value
) => accumulator
+ value
.added
,
150 private get
numberOfStartedChargingStations (): number {
151 return [...this.templateStatistics
.values()].reduce(
152 (accumulator
, value
) => accumulator
+ value
.started
,
157 public async start (): Promise
<void> {
159 if (!this.starting
) {
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
)
167 ChargingStationWorkerMessageEvents
.performanceStatistics
,
168 this.workerEventPerformanceStatistics
171 ChargingStationWorkerMessageEvents
.workerElementError
,
172 (eventError
: ChargingStationWorkerEventError
) => {
174 `${this.logPrefix()} ${moduleName}.start: Error occurred while handling '${eventError.event}' event on worker:`,
179 this.initializeCounters()
180 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
181 ConfigurationSection
.worker
183 this.initializeWorkerImplementation(workerConfiguration
)
184 await this.workerImplementation
?.start()
185 const performanceStorageConfiguration
=
186 Configuration
.getConfigurationSection
<StorageConfiguration
>(
187 ConfigurationSection
.performanceStorage
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
!,
197 await this.storage
?.open()
200 !this.uiServerStarted
&&
201 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
202 ConfigurationSection
.uiServer
205 this.uiServer
.start()
206 this.uiServerStarted
= true
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()!) {
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
)
221 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
229 `Charging stations simulator ${
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
)`
242 Configuration
.workerDynamicPoolInUse() &&
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'
248 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
250 this.starting
= false
252 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
255 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
259 public async stop (): Promise
<void> {
261 if (!this.stopping
) {
263 await this.uiServer
.sendInternalRequest(
264 this.uiServer
.buildProtocolRequest(
266 ProcedureName
.STOP_CHARGING_STATION
,
267 Constants
.EMPTY_FROZEN_OBJECT
271 await this.waitChargingStationsStopped()
273 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
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()
283 this.stopping
= false
285 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
288 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
292 private async restart (): Promise
<void> {
295 this.uiServerStarted
&&
296 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
300 this.uiServerStarted
= false
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(
316 ChargingStationWorkerMessageEvents
.stopped
,
317 this.numberOfStartedChargingStations
320 resolve('Charging stations stopped')
324 clearTimeout(waitTimeout
)
329 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
333 let elementsPerWorker
: number
334 switch (workerConfiguration
.elementsPerWorker
) {
336 elementsPerWorker
= this.numberOfConfiguredChargingStations
341 this.numberOfConfiguredChargingStations
> availableParallelism()
342 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
346 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<ChargingStationWorkerData
>(
348 dirname(fileURLToPath(import.meta
.url
)),
349 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
351 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
352 workerConfiguration
.processType
!,
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
!,
362 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
363 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
369 private messageHandler (
370 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
373 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
381 case ChargingStationWorkerMessageEvents
.added
:
382 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
384 case ChargingStationWorkerMessageEvents
.deleted
:
385 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
387 case ChargingStationWorkerMessageEvents
.started
:
388 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
390 case ChargingStationWorkerMessageEvents
.stopped
:
391 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
393 case ChargingStationWorkerMessageEvents
.updated
:
394 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
396 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
397 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
399 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
400 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
402 case ChargingStationWorkerMessageEvents
.workerElementError
:
403 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
407 `Unknown charging station worker event: '${
409 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
414 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
422 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
423 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
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))`
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
)
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))`
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
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))`
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
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))`
474 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
475 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
478 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
479 // eslint-disable-next-line @typescript-eslint/unbound-method
480 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
482 this.storage
.storePerformanceStatistics
as (
483 performanceStatistics
: Statistics
485 )(data
).catch(Constants
.EMPTY_FUNCTION
)
487 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
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,
504 indexes: new Set<number>()
506 this.uiServer.chargingStationTemplates.add(templateName)
508 if (this.templateStatistics.size !== stationTemplateUrls.length) {
511 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
514 exit(exitCodes.duplicateChargingStationTemplateUrls)
518 chalk.red("'stationTemplateUrls' not defined or empty, please check your configuration")
520 exit(exitCodes.missingChargingStationsConfiguration)
523 this.numberOfConfiguredChargingStations === 0 &&
524 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
529 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
532 exit(exitCodes.noChargingStationTemplates)
534 this.initializedCounters = true
538 public async addChargingStation (
540 stationTemplateFile: string,
541 options?: ChargingStationOptions
543 await this.workerImplementation?.addElement({
546 dirname(fileURLToPath(import.meta.url)),
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)
559 private gracefulShutdown (): void {
562 console.info(chalk.green('Graceful shutdown'))
564 this.uiServerStarted = false
565 this.waitChargingStationsStopped()
567 exit(exitCodes.succeeded)
570 exit(exitCodes.gracefulShutdownError)
574 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error)
575 exit(exitCodes.gracefulShutdownError)
579 private readonly logPrefix = (): string => {
580 return logPrefix(' Bootstrap |')