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 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
169 // eslint-disable-next-line @typescript-eslint/unbound-method
170 if (isAsyncFunction(this.workerImplementation
?.start
)) {
171 await this.workerImplementation
.start()
173 (this.workerImplementation
?.start
as () => void)()
175 const performanceStorageConfiguration
=
176 Configuration
.getConfigurationSection
<StorageConfiguration
>(
177 ConfigurationSection
.performanceStorage
179 if (performanceStorageConfiguration
.enabled
=== true) {
180 this.storage
= StorageFactory
.getStorage(
181 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
182 performanceStorageConfiguration
.type!,
183 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
184 performanceStorageConfiguration
.uri
!,
187 await this.storage
?.open()
190 !this.uiServerStarted
&&
191 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
192 ConfigurationSection
.uiServer
195 this.uiServer
.start()
196 this.uiServerStarted
= true
198 // Start ChargingStation object instance in worker thread
199 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
200 for (const stationTemplateUrl
of Configuration
.getStationTemplateUrls()!) {
202 const nbStations
= stationTemplateUrl
.numberOfStations
203 for (let index
= 1; index
<= nbStations
; index
++) {
204 await this.addChargingStation(index
, stationTemplateUrl
.file
)
209 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
215 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
216 ConfigurationSection
.worker
220 `Charging stations simulator ${
222 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
223 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}
/` : ''
224 }${this.workerImplementation?.size}${
225 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}
` : ''
226 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
227 this.workerImplementation?.maxElementsPerWorker != null
228 ? ` (${this.workerImplementation.maxElementsPerWorker} charging
station(s
) per worker
)`
233 Configuration
.workerDynamicPoolInUse() &&
236 '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'
239 console
.info(chalk
.green('Worker set/pool information:'), this.workerImplementation
?.info
)
241 this.starting
= false
243 console
.error(chalk
.red('Cannot start an already starting charging stations simulator'))
246 console
.error(chalk
.red('Cannot start an already started charging stations simulator'))
250 public async stop (): Promise
<void> {
252 if (!this.stopping
) {
254 await this.uiServer
.sendInternalRequest(
255 this.uiServer
.buildProtocolRequest(
257 ProcedureName
.STOP_CHARGING_STATION
,
258 Constants
.EMPTY_FROZEN_OBJECT
262 await this.waitChargingStationsStopped()
264 console
.error(chalk
.red('Error while waiting for charging stations to stop: '), error
)
266 await this.workerImplementation
?.stop()
267 this.removeAllListeners()
268 this.uiServer
.clearCaches()
269 await this.storage
?.close()
272 this.stopping
= false
274 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
277 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
281 private async restart (): Promise
<void> {
284 this.uiServerStarted
&&
285 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
289 this.uiServerStarted
= false
291 this.initializeCounters()
292 // FIXME: initialize worker implementation only if the worker section has changed
293 this.initializeWorkerImplementation(
294 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
299 private async waitChargingStationsStopped (): Promise
<string> {
300 return await new Promise
<string>((resolve
, reject
: (reason
?: unknown
) => void) => {
301 const waitTimeout
= setTimeout(() => {
302 const timeoutMessage
= `Timeout ${formatDurationMilliSeconds(
303 Constants.STOP_CHARGING_STATIONS_TIMEOUT
304 )} reached at stopping charging stations`
305 console
.warn(chalk
.yellow(timeoutMessage
))
306 reject(new Error(timeoutMessage
))
307 }, Constants
.STOP_CHARGING_STATIONS_TIMEOUT
)
308 waitChargingStationEvents(
310 ChargingStationWorkerMessageEvents
.stopped
,
311 this.numberOfStartedChargingStations
314 resolve('Charging stations stopped')
318 clearTimeout(waitTimeout
)
323 private initializeWorkerImplementation (workerConfiguration
: WorkerConfiguration
): void {
327 let elementsPerWorker
: number
328 switch (workerConfiguration
.elementsPerWorker
) {
330 elementsPerWorker
= this.numberOfConfiguredChargingStations
334 this.numberOfConfiguredChargingStations
> availableParallelism()
335 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
339 elementsPerWorker
= workerConfiguration
.elementsPerWorker
?? DEFAULT_ELEMENTS_PER_WORKER
341 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<
342 ChargingStationWorkerData
,
346 dirname(fileURLToPath(import.meta
.url
)),
347 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
349 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
350 workerConfiguration
.processType
!,
352 workerStartDelay
: workerConfiguration
.startDelay
,
353 elementAddDelay
: workerConfiguration
.elementAddDelay
,
354 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
355 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
356 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
357 poolMinSize
: workerConfiguration
.poolMinSize
!,
360 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
361 ...(workerConfiguration
.resourceLimits
!= null && {
362 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
369 private messageHandler (
370 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
373 // `${this.logPrefix()} ${moduleName}.messageHandler: Charging station worker message received: ${JSON.stringify(
379 // Skip worker message events processing
380 // eslint-disable-next-line @typescript-eslint/dot-notation
381 if (msg
['uuid'] != null) {
384 const { event
, data
} = msg
387 case ChargingStationWorkerMessageEvents
.added
:
388 this.emit(ChargingStationWorkerMessageEvents
.added
, data
)
390 case ChargingStationWorkerMessageEvents
.deleted
:
391 this.emit(ChargingStationWorkerMessageEvents
.deleted
, data
)
393 case ChargingStationWorkerMessageEvents
.started
:
394 this.emit(ChargingStationWorkerMessageEvents
.started
, data
)
396 case ChargingStationWorkerMessageEvents
.stopped
:
397 this.emit(ChargingStationWorkerMessageEvents
.stopped
, data
)
399 case ChargingStationWorkerMessageEvents
.updated
:
400 this.emit(ChargingStationWorkerMessageEvents
.updated
, data
)
402 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
403 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, data
)
407 `Unknown charging station worker message event: '${event}' received with data: ${JSON.stringify(data, undefined, 2)}`
412 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling charging station worker message event '${event}':`,
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 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
491 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
492 if (isNotEmptyArray(stationTemplateUrls
)) {
493 for (const stationTemplateUrl
of stationTemplateUrls
) {
494 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
495 this.templateStatistics
.set(templateName
, {
496 configured
: stationTemplateUrl
.numberOfStations
,
499 indexes
: new Set
<number>()
501 this.uiServer
.chargingStationTemplates
.add(templateName
)
503 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
506 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
509 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
513 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
515 exit(exitCodes
.missingChargingStationsConfiguration
)
518 this.numberOfConfiguredChargingStations
=== 0 &&
519 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
524 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
527 exit(exitCodes
.noChargingStationTemplates
)
531 public async addChargingStation (
533 templateFile
: string,
534 options
?: ChargingStationOptions
535 ): Promise
<ChargingStationInfo
| undefined> {
536 if (!this.started
&& !this.starting
) {
538 'Cannot add charging station while the charging stations simulator is not started'
541 const stationInfo
= await this.workerImplementation
?.addElement({
544 dirname(fileURLToPath(import.meta
.url
)),
551 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
552 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
553 ++templateStatistics
.added
554 templateStatistics
.indexes
.add(index
)
558 private gracefulShutdown (): void {
561 console
.info(chalk
.green('Graceful shutdown'))
563 this.uiServerStarted
= false
564 this.waitChargingStationsStopped()
566 exit(exitCodes
.succeeded
)
569 exit(exitCodes
.gracefulShutdownError
)
572 .catch((error
: unknown
) => {
573 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
574 exit(exitCodes
.gracefulShutdownError
)
578 private readonly logPrefix
= (): string => {
579 return logPrefix(' Bootstrap |')