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 ChargingStationInfo
,
19 type ChargingStationOptions
,
20 type ChargingStationWorkerData
,
21 type ChargingStationWorkerEventError
,
22 type ChargingStationWorkerMessage
,
23 type ChargingStationWorkerMessageData
,
24 ChargingStationWorkerMessageEvents
,
29 type StorageConfiguration
,
30 type TemplateStatistics
,
31 type UIServerConfiguration
,
32 type WorkerConfiguration
33 } from
'../types/index.js'
37 formatDurationMilliSeconds
,
39 handleUncaughtException
,
40 handleUnhandledRejection
,
45 } from
'../utils/index.js'
46 import { type WorkerAbstract
, WorkerFactory
} from
'../worker/index.js'
47 import { buildTemplateName
, waitChargingStationEvents
} from
'./Helpers.js'
48 import type { AbstractUIServer
} from
'./ui-server/AbstractUIServer.js'
49 import { UIServerFactory
} from
'./ui-server/UIServerFactory.js'
51 const moduleName
= 'Bootstrap'
55 missingChargingStationsConfiguration
= 1,
56 duplicateChargingStationTemplateUrls
= 2,
57 noChargingStationTemplates
= 3,
58 gracefulShutdownError
= 4
61 export class Bootstrap
extends EventEmitter
{
62 private static instance
: Bootstrap
| null = null
63 private workerImplementation
?: WorkerAbstract
<ChargingStationWorkerData
, ChargingStationInfo
>
64 private readonly uiServer
: AbstractUIServer
65 private storage
?: Storage
66 private readonly templateStatistics
: Map
<string, TemplateStatistics
>
67 private readonly version
: string = version
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.templateStatistics
= new Map
<string, TemplateStatistics
>()
86 this.uiServer
= UIServerFactory
.getUIServerImplementation(
87 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
89 this.initializeCounters()
90 this.initializeWorkerImplementation(
91 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
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
: 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 // eslint-disable-next-line @typescript-eslint/unbound-method
180 if (isAsyncFunction(this.workerImplementation
?.start
)) {
181 await this.workerImplementation
.start()
183 (this.workerImplementation
?.start
as () => void)()
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()!) {
212 const nbStations
= stationTemplateUrl
.numberOfStations
213 for (let index
= 1; index
<= nbStations
; index
++) {
214 await this.addChargingStation(index
, stationTemplateUrl
.file
)
219 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
225 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
226 ConfigurationSection
.worker
230 `Charging stations simulator ${
232 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
233 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}
/` : ''
234 }${this.workerImplementation?.size}${
235 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}
` : ''
236 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
237 this.workerImplementation?.maxElementsPerWorker != null
238 ? ` (${this.workerImplementation.maxElementsPerWorker} charging
station(s
) per worker
)`
243 Configuration
.workerDynamicPoolInUse() &&
246 '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'
249 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
251 this.starting
= false
253 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
256 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
260 public async stop (): Promise
<void> {
262 if (!this.stopping
) {
264 await this.uiServer
.sendInternalRequest(
265 this.uiServer
.buildProtocolRequest(
267 ProcedureName
.STOP_CHARGING_STATION
,
268 Constants
.EMPTY_FROZEN_OBJECT
272 await this.waitChargingStationsStopped()
274 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
276 await this.workerImplementation
?.stop()
277 this.removeAllListeners()
278 this.uiServer
.clearCaches()
279 await this.storage
?.close()
282 this.stopping
= false
284 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
287 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
291 private async restart (): Promise
<void> {
294 this.uiServerStarted
&&
295 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
299 this.uiServerStarted
= false
301 this.initializeCounters()
302 // FIXME: initialize worker implementation only if the worker section has changed
303 this.initializeWorkerImplementation(
304 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
309 private async waitChargingStationsStopped (): Promise
<string> {
310 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
311 const waitTimeout
= setTimeout(() => {
312 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
313 Constants.STOP_CHARGING_STATIONS_TIMEOUT
314 )} reached at stopping charging stations`
315 console
.warn(chalk
.yellow(timeoutMessage
))
316 reject(new Error(timeoutMessage
))
317 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
318 waitChargingStationEvents(
320 ChargingStationWorkerMessageEvents
.stopped
,
321 this.numberOfStartedChargingStations
324 resolve('Charging stations stopped')
328 clearTimeout(waitTimeout
)
333 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
337 let elementsPerWorker
: number
338 switch (workerConfiguration
.elementsPerWorker
) {
340 elementsPerWorker
= this.numberOfConfiguredChargingStations
345 this.numberOfConfiguredChargingStations
> availableParallelism()
346 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
350 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<
351 ChargingStationWorkerData
,
355 dirname(fileURLToPath(import.meta
.url
)),
356 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
358 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
359 workerConfiguration
.processType
!,
361 workerStartDelay
: workerConfiguration
.startDelay
,
362 elementAddDelay
: workerConfiguration
.elementAddDelay
,
363 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
364 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
365 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
366 poolMinSize
: workerConfiguration
.poolMinSize
!,
369 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
370 ...(workerConfiguration
.resourceLimits
!= null && {
371 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
378 private messageHandler (
379 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
382 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
390 case ChargingStationWorkerMessageEvents
.added
:
391 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
393 case ChargingStationWorkerMessageEvents
.deleted
:
394 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
396 case ChargingStationWorkerMessageEvents
.started
:
397 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
399 case ChargingStationWorkerMessageEvents
.stopped
:
400 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
402 case ChargingStationWorkerMessageEvents
.updated
:
403 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
405 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
406 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
408 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
409 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
411 case ChargingStationWorkerMessageEvents
.workerElementError
:
412 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
416 `Unknown charging station worker event: '${
418 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
423 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
431 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
432 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
434 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
435 data.stationInfo.chargingStationId
436 } (hashId: ${data.stationInfo.hashId}) added (${
437 this.numberOfAddedChargingStations
438 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
442 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
443 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
444 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
445 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
446 --templateStatistics
.added
447 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
449 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
450 data.stationInfo.chargingStationId
451 } (hashId: ${data.stationInfo.hashId}) deleted (${
452 this.numberOfAddedChargingStations
453 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
457 private readonly workerEventStarted
= (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}.workerEventStarted: Charging station ${
463 data.stationInfo.chargingStationId
464 } (hashId: ${data.stationInfo.hashId}) started (${
465 this.numberOfStartedChargingStations
466 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
470 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
471 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
472 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
473 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
475 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
476 data.stationInfo.chargingStationId
477 } (hashId: ${data.stationInfo.hashId}) stopped (${
478 this.numberOfStartedChargingStations
479 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
483 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
484 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
487 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
488 // eslint-disable-next-line @typescript-eslint/unbound-method
489 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
491 this.storage
.storePerformanceStatistics
as (
492 performanceStatistics
: Statistics
494 )(data
).catch(Constants
.EMPTY_FUNCTION
)
496 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
502 private initializeCounters (): void {
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
)
544 public async addChargingStation (
546 templateFile
: string,
547 options
?: ChargingStationOptions
548 ): Promise
<ChargingStationInfo
| undefined> {
549 if (!this.started
&& !this.starting
) {
551 'Cannot add charging station while the charging stations simulator is not started'
554 const stationInfo
= await this.workerImplementation
?.addElement({
557 dirname(fileURLToPath(import.meta
.url
)),
564 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
565 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
566 ++templateStatistics
.added
567 templateStatistics
.indexes
.add(index
)
571 private gracefulShutdown (): void {
574 console
.info(chalk
.green('Graceful shutdown'))
576 this.uiServerStarted
= false
577 this.waitChargingStationsStopped()
579 exit(exitCodes
.succeeded
)
582 exit(exitCodes
.gracefulShutdownError
)
585 .catch((error
: unknown
) => {
586 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
587 exit(exitCodes
.gracefulShutdownError
)
591 private readonly logPrefix
= (): string => {
592 return logPrefix(' Bootstrap |')