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 started
: boolean
68 private starting
: boolean
69 private stopping
: boolean
70 private uiServerStarted
: boolean
72 private constructor () {
74 for (const signal
of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
75 process
.on(signal
, this.gracefulShutdown
.bind(this))
77 // Enable unconditionally for now
78 handleUnhandledRejection()
79 handleUncaughtException()
83 this.uiServerStarted
= false
84 this.templateStatistics
= new Map
<string, TemplateStatistics
>()
85 this.uiServer
= UIServerFactory
.getUIServerImplementation(
86 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
88 this.initializeCounters()
89 this.initializeWorkerImplementation(
90 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
92 Configuration
.configurationChangeCallback
= async () => {
94 await Bootstrap
.getInstance().restart()
99 public static getInstance (): Bootstrap
{
100 if (Bootstrap
.instance
=== null) {
101 Bootstrap
.instance
= new Bootstrap()
103 return Bootstrap
.instance
106 public get
numberOfChargingStationTemplates (): number {
107 return this.templateStatistics
.size
110 public get
numberOfConfiguredChargingStations (): number {
111 return [...this.templateStatistics
.values()].reduce(
112 (accumulator
, value
) => accumulator
+ value
.configured
,
117 public getState (): SimulatorState
{
119 version
: this.version
,
120 started
: this.started
,
121 templateStatistics
: this.templateStatistics
125 public getLastIndex (templateName
: string): number {
126 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
127 const indexes
= [...this.templateStatistics
.get(templateName
)!.indexes
]
129 .sort((a
, b
) => a
- b
)
130 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
131 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
135 return indexes
[indexes
.length
- 1]
138 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
139 return this.storage
?.getPerformanceStatistics()
142 private get
numberOfAddedChargingStations (): number {
143 return [...this.templateStatistics
.values()].reduce(
144 (accumulator
, value
) => accumulator
+ value
.added
,
149 private get
numberOfStartedChargingStations (): number {
150 return [...this.templateStatistics
.values()].reduce(
151 (accumulator
, value
) => accumulator
+ value
.started
,
156 public async start (): Promise
<void> {
158 if (!this.starting
) {
160 this.on(ChargingStationWorkerMessageEvents
.added
, this.workerEventAdded
)
161 this.on(ChargingStationWorkerMessageEvents
.deleted
, this.workerEventDeleted
)
162 this.on(ChargingStationWorkerMessageEvents
.started
, this.workerEventStarted
)
163 this.on(ChargingStationWorkerMessageEvents
.stopped
, this.workerEventStopped
)
164 this.on(ChargingStationWorkerMessageEvents
.updated
, this.workerEventUpdated
)
166 ChargingStationWorkerMessageEvents
.performanceStatistics
,
167 this.workerEventPerformanceStatistics
170 ChargingStationWorkerMessageEvents
.workerElementError
,
171 (eventError
: ChargingStationWorkerEventError
) => {
173 `${this.logPrefix()} ${moduleName}.start: Error occurred while handling '${eventError.event}' event on worker:`,
178 // eslint-disable-next-line @typescript-eslint/unbound-method
179 if (isAsyncFunction(this.workerImplementation
?.start
)) {
180 await this.workerImplementation
.start()
182 (this.workerImplementation
?.start
as () => void)()
184 const performanceStorageConfiguration
=
185 Configuration
.getConfigurationSection
<StorageConfiguration
>(
186 ConfigurationSection
.performanceStorage
188 if (performanceStorageConfiguration
.enabled
=== true) {
189 this.storage
= StorageFactory
.getStorage(
190 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
191 performanceStorageConfiguration
.type!,
192 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
193 performanceStorageConfiguration
.uri
!,
196 await this.storage
?.open()
199 !this.uiServerStarted
&&
200 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
201 ConfigurationSection
.uiServer
204 this.uiServer
.start()
205 this.uiServerStarted
= true
207 // Start ChargingStation object instance in worker thread
208 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
209 for (const stationTemplateUrl
of Configuration
.getStationTemplateUrls()!) {
211 const nbStations
= stationTemplateUrl
.numberOfStations
212 for (let index
= 1; index
<= nbStations
; index
++) {
213 await this.addChargingStation(index
, stationTemplateUrl
.file
)
218 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
224 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
225 ConfigurationSection
.worker
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 this.removeAllListeners()
277 this.uiServer
.clearCaches()
278 await this.storage
?.close()
281 this.stopping
= false
283 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
286 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
290 private async restart (): Promise
<void> {
293 this.uiServerStarted
&&
294 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
298 this.uiServerStarted
= false
300 this.initializeCounters()
301 // FIXME: initialize worker implementation only if the worker section has changed
302 this.initializeWorkerImplementation(
303 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
308 private async waitChargingStationsStopped (): Promise
<string> {
309 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
310 const waitTimeout
= setTimeout(() => {
311 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
312 Constants.STOP_CHARGING_STATIONS_TIMEOUT
313 )} reached at stopping charging stations`
314 console
.warn(chalk
.yellow(timeoutMessage
))
315 reject(new Error(timeoutMessage
))
316 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
317 waitChargingStationEvents(
319 ChargingStationWorkerMessageEvents
.stopped
,
320 this.numberOfStartedChargingStations
323 resolve('Charging stations stopped')
327 clearTimeout(waitTimeout
)
332 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
336 let elementsPerWorker
: number
337 switch (workerConfiguration
.elementsPerWorker
) {
339 elementsPerWorker
= this.numberOfConfiguredChargingStations
344 this.numberOfConfiguredChargingStations
> availableParallelism()
345 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
349 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<ChargingStationWorkerData
>(
351 dirname(fileURLToPath(import.meta
.url
)),
352 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
354 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
355 workerConfiguration
.processType
!,
357 workerStartDelay
: workerConfiguration
.startDelay
,
358 elementAddDelay
: workerConfiguration
.elementAddDelay
,
359 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
360 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
361 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
362 poolMinSize
: workerConfiguration
.poolMinSize
!,
365 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
366 ...(workerConfiguration
.resourceLimits
!= null && {
367 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
374 private messageHandler (
375 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
378 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
386 case ChargingStationWorkerMessageEvents
.added
:
387 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
389 case ChargingStationWorkerMessageEvents
.deleted
:
390 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
392 case ChargingStationWorkerMessageEvents
.started
:
393 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
395 case ChargingStationWorkerMessageEvents
.stopped
:
396 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
398 case ChargingStationWorkerMessageEvents
.updated
:
399 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
401 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
402 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
404 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
405 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
407 case ChargingStationWorkerMessageEvents
.workerElementError
:
408 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
412 `Unknown charging station worker event: '${
414 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
419 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
427 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
428 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
430 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
431 data.stationInfo.chargingStationId
432 } (hashId: ${data.stationInfo.hashId}) added (${
433 this.numberOfAddedChargingStations
434 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
438 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
439 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
440 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
441 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
442 --templateStatistics
.added
443 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
445 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
446 data.stationInfo.chargingStationId
447 } (hashId: ${data.stationInfo.hashId}) deleted (${
448 this.numberOfAddedChargingStations
449 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
453 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
454 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
455 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
456 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
458 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
459 data.stationInfo.chargingStationId
460 } (hashId: ${data.stationInfo.hashId}) started (${
461 this.numberOfStartedChargingStations
462 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
466 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
467 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
468 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
469 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
471 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
472 data.stationInfo.chargingStationId
473 } (hashId: ${data.stationInfo.hashId}) stopped (${
474 this.numberOfStartedChargingStations
475 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
479 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
480 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
483 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
484 // eslint-disable-next-line @typescript-eslint/unbound-method
485 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
487 this.storage
.storePerformanceStatistics
as (
488 performanceStatistics
: Statistics
490 )(data
).catch(Constants
.EMPTY_FUNCTION
)
492 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
498 private initializeCounters (): void {
499 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
500 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
501 if (isNotEmptyArray(stationTemplateUrls
)) {
502 for (const stationTemplateUrl
of stationTemplateUrls
) {
503 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
504 this.templateStatistics
.set(templateName
, {
505 configured
: stationTemplateUrl
.numberOfStations
,
508 indexes
: new Set
<number>()
510 this.uiServer
.chargingStationTemplates
.add(templateName
)
512 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
515 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
518 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
522 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
524 exit(exitCodes
.missingChargingStationsConfiguration
)
527 this.numberOfConfiguredChargingStations
=== 0 &&
528 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
533 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
536 exit(exitCodes
.noChargingStationTemplates
)
540 public async addChargingStation (
542 templateFile
: string,
543 options
?: ChargingStationOptions
545 if (!this.started
&& !this.starting
) {
547 'Cannot add charging station while the charging stations simulator is not started'
550 await this.workerImplementation
?.addElement({
553 dirname(fileURLToPath(import.meta
.url
)),
560 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
561 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
562 ++templateStatistics
.added
563 templateStatistics
.indexes
.add(index
)
566 private gracefulShutdown (): void {
569 console
.info(chalk
.green('Graceful shutdown'))
571 this.uiServerStarted
= false
572 this.waitChargingStationsStopped()
574 exit(exitCodes
.succeeded
)
577 exit(exitCodes
.gracefulShutdownError
)
580 .catch((error
: unknown
) => {
581 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
582 exit(exitCodes
.gracefulShutdownError
)
586 private readonly logPrefix
= (): string => {
587 return logPrefix(' Bootstrap |')