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.uiServerStarted
= false
85 this.uiServer
= UIServerFactory
.getUIServerImplementation(
86 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
88 this.templateStatistics
= new Map
<string, TemplateStatistics
>()
89 this.initializedCounters
= false
90 this.initializeCounters()
91 Configuration
.configurationChangeCallback
= async () => {
93 await Bootstrap
.getInstance().restart()
98 public static getInstance (): Bootstrap
{
99 if (Bootstrap
.instance
=== null) {
100 Bootstrap
.instance
= new Bootstrap()
102 return Bootstrap
.instance
105 public get
numberOfChargingStationTemplates (): number {
106 return this.templateStatistics
.size
109 public get
numberOfConfiguredChargingStations (): number {
110 return [...this.templateStatistics
.values()].reduce(
111 (accumulator
, value
) => accumulator
+ value
.configured
,
116 public getState (): SimulatorState
{
118 version
: this.version
,
119 started
: this.started
,
120 templateStatistics
: this.templateStatistics
124 public getLastIndex (templateName
: string): number {
125 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
126 const indexes
= [...this.templateStatistics
.get(templateName
)!.indexes
]
128 .sort((a
, b
) => a
- b
)
129 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
130 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
134 return indexes
[indexes
.length
- 1]
137 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
138 return this.storage
?.getPerformanceStatistics()
141 private get
numberOfAddedChargingStations (): number {
142 return [...this.templateStatistics
.values()].reduce(
143 (accumulator
, value
) => accumulator
+ value
.added
,
148 private get
numberOfStartedChargingStations (): number {
149 return [...this.templateStatistics
.values()].reduce(
150 (accumulator
, value
) => accumulator
+ value
.started
,
155 public async start (): Promise
<void> {
157 if (!this.starting
) {
159 this.on(ChargingStationWorkerMessageEvents
.added
, this.workerEventAdded
)
160 this.on(ChargingStationWorkerMessageEvents
.deleted
, this.workerEventDeleted
)
161 this.on(ChargingStationWorkerMessageEvents
.started
, this.workerEventStarted
)
162 this.on(ChargingStationWorkerMessageEvents
.stopped
, this.workerEventStopped
)
163 this.on(ChargingStationWorkerMessageEvents
.updated
, this.workerEventUpdated
)
165 ChargingStationWorkerMessageEvents
.performanceStatistics
,
166 this.workerEventPerformanceStatistics
169 ChargingStationWorkerMessageEvents
.workerElementError
,
170 (eventError
: ChargingStationWorkerEventError
) => {
172 `${this.logPrefix()} ${moduleName}.start: Error occurred while handling '${eventError.event}' event on worker:`,
177 this.initializeCounters()
178 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
179 ConfigurationSection
.worker
181 this.initializeWorkerImplementation(workerConfiguration
)
182 await this.workerImplementation
?.start()
183 const performanceStorageConfiguration
=
184 Configuration
.getConfigurationSection
<StorageConfiguration
>(
185 ConfigurationSection
.performanceStorage
187 if (performanceStorageConfiguration
.enabled
=== true) {
188 this.storage
= StorageFactory
.getStorage(
189 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
190 performanceStorageConfiguration
.type!,
191 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
192 performanceStorageConfiguration
.uri
!,
195 await this.storage
?.open()
198 !this.uiServerStarted
&&
199 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
200 ConfigurationSection
.uiServer
203 this.uiServer
.start()
204 this.uiServerStarted
= true
206 // Start ChargingStation object instance in worker thread
207 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
208 for (const stationTemplateUrl
of Configuration
.getStationTemplateUrls()!) {
210 const nbStations
= stationTemplateUrl
.numberOfStations
211 for (let index
= 1; index
<= nbStations
; index
++) {
212 await this.addChargingStation(index
, stationTemplateUrl
.file
)
217 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
225 `Charging stations simulator ${
227 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
228 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}
/` : ''
229 }${this.workerImplementation?.size}${
230 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}
` : ''
231 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
232 this.workerImplementation?.maxElementsPerWorker != null
233 ? ` (${this.workerImplementation.maxElementsPerWorker} charging
station(s
) per worker
)`
238 Configuration
.workerDynamicPoolInUse() &&
241 '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 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
246 this.starting
= false
248 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
251 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
255 public async stop (): Promise
<void> {
257 if (!this.stopping
) {
259 await this.uiServer
.sendInternalRequest(
260 this.uiServer
.buildProtocolRequest(
262 ProcedureName
.STOP_CHARGING_STATION
,
263 Constants
.EMPTY_FROZEN_OBJECT
267 await this.waitChargingStationsStopped()
269 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
271 await this.workerImplementation
?.stop()
272 delete this.workerImplementation
273 this.removeAllListeners()
274 this.uiServer
.clearCaches()
275 this.initializedCounters
= false
276 await this.storage
?.close()
279 this.stopping
= false
281 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
284 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
288 private async restart (): Promise
<void> {
291 this.uiServerStarted
&&
292 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
296 this.uiServerStarted
= false
301 private async waitChargingStationsStopped (): Promise
<string> {
302 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
303 const waitTimeout
= setTimeout(() => {
304 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
305 Constants.STOP_CHARGING_STATIONS_TIMEOUT
306 )} reached at stopping charging stations`
307 console
.warn(chalk
.yellow(timeoutMessage
))
308 reject(new Error(timeoutMessage
))
309 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
310 waitChargingStationEvents(
312 ChargingStationWorkerMessageEvents
.stopped
,
313 this.numberOfStartedChargingStations
316 resolve('Charging stations stopped')
320 clearTimeout(waitTimeout
)
325 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
329 let elementsPerWorker
: number
330 switch (workerConfiguration
.elementsPerWorker
) {
332 elementsPerWorker
= this.numberOfConfiguredChargingStations
337 this.numberOfConfiguredChargingStations
> availableParallelism()
338 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
342 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<ChargingStationWorkerData
>(
344 dirname(fileURLToPath(import.meta
.url
)),
345 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
347 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
348 workerConfiguration
.processType
!,
350 workerStartDelay
: workerConfiguration
.startDelay
,
351 elementAddDelay
: workerConfiguration
.elementAddDelay
,
352 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
353 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
354 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
355 poolMinSize
: workerConfiguration
.poolMinSize
!,
358 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
359 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
365 private messageHandler (
366 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
369 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
377 case ChargingStationWorkerMessageEvents
.added
:
378 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
380 case ChargingStationWorkerMessageEvents
.deleted
:
381 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
383 case ChargingStationWorkerMessageEvents
.started
:
384 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
386 case ChargingStationWorkerMessageEvents
.stopped
:
387 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
389 case ChargingStationWorkerMessageEvents
.updated
:
390 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
392 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
393 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
395 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
396 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
398 case ChargingStationWorkerMessageEvents
.workerElementError
:
399 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
403 `Unknown charging station worker event: '${
405 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
410 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
418 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
419 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
421 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
422 data.stationInfo.chargingStationId
423 } (hashId: ${data.stationInfo.hashId}) added (${
424 this.numberOfAddedChargingStations
425 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
429 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
430 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
431 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
432 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
433 --templateStatistics
.added
434 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
436 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
437 data.stationInfo.chargingStationId
438 } (hashId: ${data.stationInfo.hashId}) deleted (${
439 this.numberOfAddedChargingStations
440 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
444 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
445 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
446 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
447 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
449 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
450 data.stationInfo.chargingStationId
451 } (hashId: ${data.stationInfo.hashId}) started (${
452 this.numberOfStartedChargingStations
453 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
457 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
458 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
459 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
460 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
462 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
463 data.stationInfo.chargingStationId
464 } (hashId: ${data.stationInfo.hashId}) stopped (${
465 this.numberOfStartedChargingStations
466 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
470 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
471 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
474 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
475 // eslint-disable-next-line @typescript-eslint/unbound-method
476 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
478 this.storage
.storePerformanceStatistics
as (
479 performanceStatistics
: Statistics
481 )(data
).catch(Constants
.EMPTY_FUNCTION
)
483 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
489 private initializeCounters (): void {
490 if (!this.initializedCounters
) {
491 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
492 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
493 if (isNotEmptyArray(stationTemplateUrls
)) {
494 for (const stationTemplateUrl
of stationTemplateUrls
) {
495 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
496 this.templateStatistics
.set(templateName
, {
497 configured
: stationTemplateUrl
.numberOfStations
,
500 indexes
: new Set
<number>()
502 this.uiServer
.chargingStationTemplates
.add(templateName
)
504 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
507 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
510 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
514 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
516 exit(exitCodes
.missingChargingStationsConfiguration
)
519 this.numberOfConfiguredChargingStations
=== 0 &&
520 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
525 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
528 exit(exitCodes
.noChargingStationTemplates
)
530 this.initializedCounters
= true
534 public async addChargingStation (
536 templateFile
: string,
537 options
?: ChargingStationOptions
539 if (!this.started
&& !this.starting
) {
541 'Cannot add charging station while the charging stations simulator is not started'
544 await this.workerImplementation
?.addElement({
547 dirname(fileURLToPath(import.meta
.url
)),
554 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
555 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
556 ++templateStatistics
.added
557 templateStatistics
.indexes
.add(index
)
560 private gracefulShutdown (): void {
563 console
.info(chalk
.green('Graceful shutdown'))
565 this.uiServerStarted
= false
566 this.waitChargingStationsStopped()
568 exit(exitCodes
.succeeded
)
571 exit(exitCodes
.gracefulShutdownError
)
574 .catch((error
: unknown
) => {
575 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
576 exit(exitCodes
.gracefulShutdownError
)
580 private readonly logPrefix
= (): string => {
581 return logPrefix(' Bootstrap |')