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 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 { DEFAULT_ELEMENTS_PER_WORKER
, 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
, ChargingStationInfo
>
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 get
numberOfProvisionedChargingStations (): number {
118 return [...this.templateStatistics
.values()].reduce(
119 (accumulator
, value
) => accumulator
+ value
.provisioned
,
124 public getState (): SimulatorState
{
126 version
: this.version
,
127 configuration
: Configuration
.getConfigurationData(),
128 started
: this.started
,
129 templateStatistics
: this.templateStatistics
133 public getLastIndex (templateName
: string): number {
134 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
135 const indexes
= [...this.templateStatistics
.get(templateName
)!.indexes
]
137 .sort((a
, b
) => a
- b
)
138 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
139 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
143 return indexes
[indexes
.length
- 1]
146 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
147 return this.storage
?.getPerformanceStatistics()
150 private get
numberOfAddedChargingStations (): number {
151 return [...this.templateStatistics
.values()].reduce(
152 (accumulator
, value
) => accumulator
+ value
.added
,
157 private get
numberOfStartedChargingStations (): number {
158 return [...this.templateStatistics
.values()].reduce(
159 (accumulator
, value
) => accumulator
+ value
.started
,
164 public async start (): Promise
<void> {
166 if (!this.starting
) {
168 this.on(ChargingStationWorkerMessageEvents
.added
, this.workerEventAdded
)
169 this.on(ChargingStationWorkerMessageEvents
.deleted
, this.workerEventDeleted
)
170 this.on(ChargingStationWorkerMessageEvents
.started
, this.workerEventStarted
)
171 this.on(ChargingStationWorkerMessageEvents
.stopped
, this.workerEventStopped
)
172 this.on(ChargingStationWorkerMessageEvents
.updated
, this.workerEventUpdated
)
174 ChargingStationWorkerMessageEvents
.performanceStatistics
,
175 this.workerEventPerformanceStatistics
177 // eslint-disable-next-line @typescript-eslint/unbound-method
178 if (isAsyncFunction(this.workerImplementation
?.start
)) {
179 await this.workerImplementation
.start()
181 (this.workerImplementation
?.start
as () => void)()
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}: `
223 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
224 ConfigurationSection
.worker
228 `Charging stations simulator ${
230 } started with ${this.numberOfConfiguredChargingStations} configured and ${this.numberOfProvisionedChargingStations} provisioned charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
231 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}
/` : ''
232 }${this.workerImplementation?.size}${
233 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}
` : ''
234 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
235 this.workerImplementation?.maxElementsPerWorker != null
236 ? ` (${this.workerImplementation.maxElementsPerWorker} charging
station(s
) per worker
)`
241 Configuration
.workerDynamicPoolInUse() &&
244 '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'
247 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
249 this.starting
= false
251 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
254 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
258 public async stop (): Promise
<void> {
260 if (!this.stopping
) {
262 await this.uiServer
.sendInternalRequest(
263 this.uiServer
.buildProtocolRequest(
265 ProcedureName
.STOP_CHARGING_STATION
,
266 Constants
.EMPTY_FROZEN_OBJECT
270 await this.waitChargingStationsStopped()
272 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
274 await this.workerImplementation
?.stop()
275 this.removeAllListeners()
276 this.uiServer
.clearCaches()
277 await this.storage
?.close()
280 this.stopping
= false
282 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
285 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
289 private async restart (): Promise
<void> {
292 this.uiServerStarted
&&
293 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
297 this.uiServerStarted
= false
299 this.initializeCounters()
300 // FIXME: initialize worker implementation only if the worker section has changed
301 this.initializeWorkerImplementation(
302 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
307 private async waitChargingStationsStopped (): Promise
<string> {
308 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
309 const waitTimeout
= setTimeout(() => {
310 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
311 Constants.STOP_CHARGING_STATIONS_TIMEOUT
312 )} reached at stopping charging stations`
313 console
.warn(chalk
.yellow(timeoutMessage
))
314 reject(new Error(timeoutMessage
))
315 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
316 waitChargingStationEvents(
318 ChargingStationWorkerMessageEvents
.stopped
,
319 this.numberOfStartedChargingStations
322 resolve('Charging stations stopped')
326 clearTimeout(waitTimeout
)
331 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
335 let elementsPerWorker
: number
336 switch (workerConfiguration
.elementsPerWorker
) {
339 this.numberOfConfiguredChargingStations
+ this.numberOfProvisionedChargingStations
343 this.numberOfConfiguredChargingStations
+ this.numberOfProvisionedChargingStations
>
344 availableParallelism()
346 (this.numberOfConfiguredChargingStations
+
347 this.numberOfProvisionedChargingStations
) /
348 (availableParallelism() * 1.5)
353 elementsPerWorker
= workerConfiguration
.elementsPerWorker
?? DEFAULT_ELEMENTS_PER_WORKER
355 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<
356 ChargingStationWorkerData
,
360 dirname(fileURLToPath(import.meta
.url
)),
361 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
363 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
364 workerConfiguration
.processType
!,
366 workerStartDelay
: workerConfiguration
.startDelay
,
367 elementAddDelay
: workerConfiguration
.elementAddDelay
,
368 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
369 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
370 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
371 poolMinSize
: workerConfiguration
.poolMinSize
!,
374 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
375 ...(workerConfiguration
.resourceLimits
!= null && {
376 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
383 private messageHandler (
384 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
387 // `${this.logPrefix()} ${moduleName}.messageHandler: Charging station worker message received: ${JSON.stringify(
393 // Skip worker message events processing
394 // eslint-disable-next-line @typescript-eslint/dot-notation
395 if (msg
['uuid'] != null) {
398 const { event
, data
} = msg
401 case ChargingStationWorkerMessageEvents
.added
:
402 this.emit(ChargingStationWorkerMessageEvents
.added
, data
)
404 case ChargingStationWorkerMessageEvents
.deleted
:
405 this.emit(ChargingStationWorkerMessageEvents
.deleted
, data
)
407 case ChargingStationWorkerMessageEvents
.started
:
408 this.emit(ChargingStationWorkerMessageEvents
.started
, data
)
410 case ChargingStationWorkerMessageEvents
.stopped
:
411 this.emit(ChargingStationWorkerMessageEvents
.stopped
, data
)
413 case ChargingStationWorkerMessageEvents
.updated
:
414 this.emit(ChargingStationWorkerMessageEvents
.updated
, data
)
416 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
417 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, data
)
421 `Unknown charging station worker message event: '${event}' received with data: ${JSON.stringify(data, undefined, 2)}`
426 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling charging station worker message event '${event}':`,
432 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
433 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
435 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
436 data.stationInfo.chargingStationId
437 } (hashId: ${data.stationInfo.hashId}) added (${
438 this.numberOfAddedChargingStations
439 } added from ${this.numberOfConfiguredChargingStations} configured and ${this.numberOfProvisionedChargingStations} provisioned charging station(s))`
443 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
444 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
445 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
446 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
447 --templateStatistics
.added
448 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
450 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
451 data.stationInfo.chargingStationId
452 } (hashId: ${data.stationInfo.hashId}) deleted (${
453 this.numberOfAddedChargingStations
454 } added from ${this.numberOfConfiguredChargingStations} configured and ${this.numberOfProvisionedChargingStations} provisioned charging station(s))`
458 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
459 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
460 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
461 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
463 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
464 data.stationInfo.chargingStationId
465 } (hashId: ${data.stationInfo.hashId}) started (${
466 this.numberOfStartedChargingStations
467 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
471 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
472 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
473 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
474 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
476 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
477 data.stationInfo.chargingStationId
478 } (hashId: ${data.stationInfo.hashId}) stopped (${
479 this.numberOfStartedChargingStations
480 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
484 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
485 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
488 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
489 // eslint-disable-next-line @typescript-eslint/unbound-method
490 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
492 this.storage
.storePerformanceStatistics
as (
493 performanceStatistics
: Statistics
495 )(data
).catch(Constants
.EMPTY_FUNCTION
)
497 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
503 private initializeCounters (): void {
504 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
505 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
506 if (isNotEmptyArray(stationTemplateUrls
)) {
507 for (const stationTemplateUrl
of stationTemplateUrls
) {
508 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
509 this.templateStatistics
.set(templateName
, {
510 configured
: stationTemplateUrl
.numberOfStations
,
511 provisioned
: stationTemplateUrl
.provisionedNumberOfStations
?? 0,
514 indexes
: new Set
<number>()
516 this.uiServer
.chargingStationTemplates
.add(templateName
)
518 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
521 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
524 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
528 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
530 exit(exitCodes
.missingChargingStationsConfiguration
)
533 this.numberOfConfiguredChargingStations
=== 0 &&
534 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
539 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
542 exit(exitCodes
.noChargingStationTemplates
)
546 public async addChargingStation (
548 templateFile
: string,
549 options
?: ChargingStationOptions
550 ): Promise
<ChargingStationInfo
| undefined> {
551 if (!this.started
&& !this.starting
) {
553 'Cannot add charging station while the charging stations simulator is not started'
556 const stationInfo
= 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
)
573 private gracefulShutdown (): void {
576 console
.info(chalk
.green('Graceful shutdown'))
578 this.uiServerStarted
= false
579 this.waitChargingStationsStopped()
581 exit(exitCodes
.succeeded
)
584 exit(exitCodes
.gracefulShutdownError
)
587 .catch((error
: unknown
) => {
588 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
589 exit(exitCodes
.gracefulShutdownError
)
593 private readonly logPrefix
= (): string => {
594 return logPrefix(' Bootstrap |')