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(
379 const { event
, data
} = msg
382 case ChargingStationWorkerMessageEvents
.added
:
383 this.emit(ChargingStationWorkerMessageEvents
.added
, data
)
385 case ChargingStationWorkerMessageEvents
.deleted
:
386 this.emit(ChargingStationWorkerMessageEvents
.deleted
, data
)
388 case ChargingStationWorkerMessageEvents
.started
:
389 this.emit(ChargingStationWorkerMessageEvents
.started
, data
)
391 case ChargingStationWorkerMessageEvents
.stopped
:
392 this.emit(ChargingStationWorkerMessageEvents
.stopped
, data
)
394 case ChargingStationWorkerMessageEvents
.updated
:
395 this.emit(ChargingStationWorkerMessageEvents
.updated
, data
)
397 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
398 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, data
)
402 `Unknown charging station worker event: '${event}' received with data: ${JSON.stringify(data, undefined, 2)}`
407 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${event}' event:`,
413 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
414 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
416 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
417 data.stationInfo.chargingStationId
418 } (hashId: ${data.stationInfo.hashId}) added (${
419 this.numberOfAddedChargingStations
420 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
424 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
425 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
426 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
427 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
428 --templateStatistics
.added
429 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
431 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
432 data.stationInfo.chargingStationId
433 } (hashId: ${data.stationInfo.hashId}) deleted (${
434 this.numberOfAddedChargingStations
435 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
439 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
440 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
441 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
442 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
444 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
445 data.stationInfo.chargingStationId
446 } (hashId: ${data.stationInfo.hashId}) started (${
447 this.numberOfStartedChargingStations
448 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
452 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
453 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
454 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
455 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
457 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
458 data.stationInfo.chargingStationId
459 } (hashId: ${data.stationInfo.hashId}) stopped (${
460 this.numberOfStartedChargingStations
461 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
465 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
466 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
469 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
470 // eslint-disable-next-line @typescript-eslint/unbound-method
471 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
473 this.storage
.storePerformanceStatistics
as (
474 performanceStatistics
: Statistics
476 )(data
).catch(Constants
.EMPTY_FUNCTION
)
478 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
484 private initializeCounters (): void {
485 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
486 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
487 if (isNotEmptyArray(stationTemplateUrls
)) {
488 for (const stationTemplateUrl
of stationTemplateUrls
) {
489 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
490 this.templateStatistics
.set(templateName
, {
491 configured
: stationTemplateUrl
.numberOfStations
,
494 indexes
: new Set
<number>()
496 this.uiServer
.chargingStationTemplates
.add(templateName
)
498 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
501 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
504 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
508 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
510 exit(exitCodes
.missingChargingStationsConfiguration
)
513 this.numberOfConfiguredChargingStations
=== 0 &&
514 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
519 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
522 exit(exitCodes
.noChargingStationTemplates
)
526 public async addChargingStation (
528 templateFile
: string,
529 options
?: ChargingStationOptions
530 ): Promise
<ChargingStationInfo
| undefined> {
531 if (!this.started
&& !this.starting
) {
533 'Cannot add charging station while the charging stations simulator is not started'
536 const stationInfo
= await this.workerImplementation
?.addElement({
539 dirname(fileURLToPath(import.meta
.url
)),
546 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
547 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
548 ++templateStatistics
.added
549 templateStatistics
.indexes
.add(index
)
553 private gracefulShutdown (): void {
556 console
.info(chalk
.green('Graceful shutdown'))
558 this.uiServerStarted
= false
559 this.waitChargingStationsStopped()
561 exit(exitCodes
.succeeded
)
564 exit(exitCodes
.gracefulShutdownError
)
567 .catch((error
: unknown
) => {
568 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
569 exit(exitCodes
.gracefulShutdownError
)
573 private readonly logPrefix
= (): string => {
574 return logPrefix(' Bootstrap |')