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 ${this.version} started with ${
229 this.numberOfConfiguredChargingStations
231 this.numberOfProvisionedChargingStations
232 } provisioned charging station(s) from ${
233 this.numberOfChargingStationTemplates
234 } charging station template(s) and ${
235 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}
/` : ''
236 }${this.workerImplementation?.size}${
237 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}
` : ''
238 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
239 this.workerImplementation?.maxElementsPerWorker != null
240 ? ` (${this.workerImplementation.maxElementsPerWorker} charging
station(s
) per worker
)`
245 Configuration
.workerDynamicPoolInUse() &&
248 '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'
251 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
253 this.starting
= false
255 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
258 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
262 public async stop (): Promise
<void> {
264 if (!this.stopping
) {
266 await this.uiServer
.sendInternalRequest(
267 this.uiServer
.buildProtocolRequest(
269 ProcedureName
.STOP_CHARGING_STATION
,
270 Constants
.EMPTY_FROZEN_OBJECT
274 await this.waitChargingStationsStopped()
276 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
278 await this.workerImplementation
?.stop()
279 this.removeAllListeners()
280 this.uiServer
.clearCaches()
281 await this.storage
?.close()
284 this.stopping
= false
286 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
289 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
293 private async restart (): Promise
<void> {
296 this.uiServerStarted
&&
297 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
301 this.uiServerStarted
= false
303 this.initializeCounters()
304 // FIXME: initialize worker implementation only if the worker section has changed
305 this.initializeWorkerImplementation(
306 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
311 private async waitChargingStationsStopped (): Promise
<string> {
312 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
313 const waitTimeout
= setTimeout(() => {
314 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
315 Constants.STOP_CHARGING_STATIONS_TIMEOUT
316 )} reached at stopping charging stations`
317 console
.warn(chalk
.yellow(timeoutMessage
))
318 reject(new Error(timeoutMessage
))
319 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
320 waitChargingStationEvents(
322 ChargingStationWorkerMessageEvents
.stopped
,
323 this.numberOfStartedChargingStations
326 resolve('Charging stations stopped')
330 clearTimeout(waitTimeout
)
335 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
339 let elementsPerWorker
: number
340 switch (workerConfiguration
.elementsPerWorker
) {
343 this.numberOfConfiguredChargingStations
+ this.numberOfProvisionedChargingStations
347 this.numberOfConfiguredChargingStations
+ this.numberOfProvisionedChargingStations
>
348 availableParallelism()
350 (this.numberOfConfiguredChargingStations
+
351 this.numberOfProvisionedChargingStations
) /
352 (availableParallelism() * 1.5)
357 elementsPerWorker
= workerConfiguration
.elementsPerWorker
?? DEFAULT_ELEMENTS_PER_WORKER
359 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<
360 ChargingStationWorkerData
,
364 dirname(fileURLToPath(import.meta
.url
)),
365 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
367 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
368 workerConfiguration
.processType
!,
370 workerStartDelay
: workerConfiguration
.startDelay
,
371 elementAddDelay
: workerConfiguration
.elementAddDelay
,
372 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
373 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
374 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
375 poolMinSize
: workerConfiguration
.poolMinSize
!,
378 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
379 ...(workerConfiguration
.resourceLimits
!= null && {
381 resourceLimits
: workerConfiguration
.resourceLimits
389 private messageHandler (
390 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
393 // `${this.logPrefix()} ${moduleName}.messageHandler: Charging station worker message received: ${JSON.stringify(
399 // Skip worker message events processing
400 // eslint-disable-next-line @typescript-eslint/dot-notation
401 if (msg
['uuid'] != null) {
404 const { event
, data
} = msg
407 case ChargingStationWorkerMessageEvents
.added
:
408 this.emit(ChargingStationWorkerMessageEvents
.added
, data
)
410 case ChargingStationWorkerMessageEvents
.deleted
:
411 this.emit(ChargingStationWorkerMessageEvents
.deleted
, data
)
413 case ChargingStationWorkerMessageEvents
.started
:
414 this.emit(ChargingStationWorkerMessageEvents
.started
, data
)
416 case ChargingStationWorkerMessageEvents
.stopped
:
417 this.emit(ChargingStationWorkerMessageEvents
.stopped
, data
)
419 case ChargingStationWorkerMessageEvents
.updated
:
420 this.emit(ChargingStationWorkerMessageEvents
.updated
, data
)
422 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
423 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, data
)
427 `Unknown charging station worker message event: '${event}' received with data: ${JSON.stringify(
436 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling charging station worker message event '${event}':`,
442 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
443 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
445 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
446 data.stationInfo.chargingStationId
447 } (hashId: ${data.stationInfo.hashId}) added (${
448 this.numberOfAddedChargingStations
449 } added from ${this.numberOfConfiguredChargingStations} configured and ${
450 this.numberOfProvisionedChargingStations
451 } provisioned charging station(s))`
455 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
456 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
457 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
458 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
459 --templateStatistics
.added
460 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
462 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
463 data.stationInfo.chargingStationId
464 } (hashId: ${data.stationInfo.hashId}) deleted (${
465 this.numberOfAddedChargingStations
466 } added from ${this.numberOfConfiguredChargingStations} configured and ${
467 this.numberOfProvisionedChargingStations
468 } provisioned charging station(s))`
472 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
473 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
474 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
475 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
477 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
478 data.stationInfo.chargingStationId
479 } (hashId: ${data.stationInfo.hashId}) started (${
480 this.numberOfStartedChargingStations
481 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
485 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
486 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
487 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
488 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
490 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
491 data.stationInfo.chargingStationId
492 } (hashId: ${data.stationInfo.hashId}) stopped (${
493 this.numberOfStartedChargingStations
494 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
498 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
499 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
502 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
503 // eslint-disable-next-line @typescript-eslint/unbound-method
504 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
506 this.storage
.storePerformanceStatistics
as (
507 performanceStatistics
: Statistics
509 )(data
).catch(Constants
.EMPTY_FUNCTION
)
511 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
517 private initializeCounters (): void {
518 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
519 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
520 if (isNotEmptyArray(stationTemplateUrls
)) {
521 for (const stationTemplateUrl
of stationTemplateUrls
) {
522 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
523 this.templateStatistics
.set(templateName
, {
524 configured
: stationTemplateUrl
.numberOfStations
,
525 provisioned
: stationTemplateUrl
.provisionedNumberOfStations
?? 0,
528 indexes
: new Set
<number>()
530 this.uiServer
.chargingStationTemplates
.add(templateName
)
532 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
535 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
538 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
542 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
544 exit(exitCodes
.missingChargingStationsConfiguration
)
547 this.numberOfConfiguredChargingStations
=== 0 &&
548 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
553 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
556 exit(exitCodes
.noChargingStationTemplates
)
560 public async addChargingStation (
562 templateFile
: string,
563 options
?: ChargingStationOptions
564 ): Promise
<ChargingStationInfo
| undefined> {
565 if (!this.started
&& !this.starting
) {
567 'Cannot add charging station while the charging stations simulator is not started'
570 const stationInfo
= await this.workerImplementation
?.addElement({
573 dirname(fileURLToPath(import.meta
.url
)),
580 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
581 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
582 ++templateStatistics
.added
583 templateStatistics
.indexes
.add(index
)
587 private gracefulShutdown (): void {
590 console
.info(chalk
.green('Graceful shutdown'))
592 this.uiServerStarted
= false
593 this.waitChargingStationsStopped()
595 exit(exitCodes
.succeeded
)
598 exit(exitCodes
.gracefulShutdownError
)
601 .catch((error
: unknown
) => {
602 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
603 exit(exitCodes
.gracefulShutdownError
)
607 private readonly logPrefix
= (): string => {
608 return logPrefix(' Bootstrap |')