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: Worker channel message received: ${JSON.stringify(
381 case ChargingStationWorkerMessageEvents
.added
:
382 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
384 case ChargingStationWorkerMessageEvents
.deleted
:
385 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
387 case ChargingStationWorkerMessageEvents
.started
:
388 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
390 case ChargingStationWorkerMessageEvents
.stopped
:
391 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
393 case ChargingStationWorkerMessageEvents
.updated
:
394 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
396 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
397 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
401 `Unknown charging station worker event: '${
403 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
408 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
416 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
417 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
419 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
420 data.stationInfo.chargingStationId
421 } (hashId: ${data.stationInfo.hashId}) added (${
422 this.numberOfAddedChargingStations
423 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
427 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
428 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
429 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
430 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
431 --templateStatistics
.added
432 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
434 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
435 data.stationInfo.chargingStationId
436 } (hashId: ${data.stationInfo.hashId}) deleted (${
437 this.numberOfAddedChargingStations
438 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
442 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
443 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
444 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
445 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
447 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
448 data.stationInfo.chargingStationId
449 } (hashId: ${data.stationInfo.hashId}) started (${
450 this.numberOfStartedChargingStations
451 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
455 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
456 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
457 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
458 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
460 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
461 data.stationInfo.chargingStationId
462 } (hashId: ${data.stationInfo.hashId}) stopped (${
463 this.numberOfStartedChargingStations
464 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
468 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
469 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
472 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
473 // eslint-disable-next-line @typescript-eslint/unbound-method
474 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
476 this.storage
.storePerformanceStatistics
as (
477 performanceStatistics
: Statistics
479 )(data
).catch(Constants
.EMPTY_FUNCTION
)
481 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
487 private initializeCounters (): void {
488 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
489 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
490 if (isNotEmptyArray(stationTemplateUrls
)) {
491 for (const stationTemplateUrl
of stationTemplateUrls
) {
492 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
493 this.templateStatistics
.set(templateName
, {
494 configured
: stationTemplateUrl
.numberOfStations
,
497 indexes
: new Set
<number>()
499 this.uiServer
.chargingStationTemplates
.add(templateName
)
501 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
504 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
507 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
511 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
513 exit(exitCodes
.missingChargingStationsConfiguration
)
516 this.numberOfConfiguredChargingStations
=== 0 &&
517 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
522 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
525 exit(exitCodes
.noChargingStationTemplates
)
529 public async addChargingStation (
531 templateFile
: string,
532 options
?: ChargingStationOptions
533 ): Promise
<ChargingStationInfo
| undefined> {
534 if (!this.started
&& !this.starting
) {
536 'Cannot add charging station while the charging stations simulator is not started'
539 const stationInfo
= await this.workerImplementation
?.addElement({
542 dirname(fileURLToPath(import.meta
.url
)),
549 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
550 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
551 ++templateStatistics
.added
552 templateStatistics
.indexes
.add(index
)
556 private gracefulShutdown (): void {
559 console
.info(chalk
.green('Graceful shutdown'))
561 this.uiServerStarted
= false
562 this.waitChargingStationsStopped()
564 exit(exitCodes
.succeeded
)
567 exit(exitCodes
.gracefulShutdownError
)
570 .catch((error
: unknown
) => {
571 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
572 exit(exitCodes
.gracefulShutdownError
)
576 private readonly logPrefix
= (): string => {
577 return logPrefix(' Bootstrap |')