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 ChargingStationOptions
,
19 type ChargingStationWorkerData
,
20 type ChargingStationWorkerEventError
,
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 { 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
>
63 private readonly uiServer
: AbstractUIServer
64 private storage
?: Storage
65 private readonly templateStatistics
: Map
<string, TemplateStatistics
>
66 private readonly version
: string = version
67 private initializedCounters
: boolean
68 private started
: boolean
69 private starting
: boolean
70 private stopping
: boolean
71 private uiServerStarted
: boolean
73 private constructor () {
75 for (const signal
of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
76 process
.on(signal
, this.gracefulShutdown
.bind(this))
78 // Enable unconditionally for now
79 handleUnhandledRejection()
80 handleUncaughtException()
84 this.initializedCounters
= false
85 this.uiServerStarted
= false
86 this.templateStatistics
= new Map
<string, TemplateStatistics
>()
87 this.initializeWorkerImplementation(
88 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
90 this.uiServer
= UIServerFactory
.getUIServerImplementation(
91 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
93 this.initializeCounters()
94 Configuration
.configurationChangeCallback
= async () => {
96 await Bootstrap
.getInstance().restart()
101 public static getInstance (): Bootstrap
{
102 if (Bootstrap
.instance
=== null) {
103 Bootstrap
.instance
= new Bootstrap()
105 return Bootstrap
.instance
108 public get
numberOfChargingStationTemplates (): number {
109 return this.templateStatistics
.size
112 public get
numberOfConfiguredChargingStations (): number {
113 return [...this.templateStatistics
.values()].reduce(
114 (accumulator
, value
) => accumulator
+ value
.configured
,
119 public getState (): SimulatorState
{
121 version
: this.version
,
122 started
: this.started
,
123 templateStatistics
: this.templateStatistics
127 public getLastIndex (templateName
: string): number {
128 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
129 const indexes
= [...this.templateStatistics
.get(templateName
)!.indexes
]
131 .sort((a
, b
) => a
- b
)
132 for (let i
= 0; i
< indexes
.length
- 1; i
++) {
133 if (indexes
[i
+ 1] - indexes
[i
] !== 1) {
137 return indexes
[indexes
.length
- 1]
140 public getPerformanceStatistics (): IterableIterator
<Statistics
> | undefined {
141 return this.storage
?.getPerformanceStatistics()
144 private get
numberOfAddedChargingStations (): number {
145 return [...this.templateStatistics
.values()].reduce(
146 (accumulator
, value
) => accumulator
+ value
.added
,
151 private get
numberOfStartedChargingStations (): number {
152 return [...this.templateStatistics
.values()].reduce(
153 (accumulator
, value
) => accumulator
+ value
.started
,
158 public async start (): Promise
<void> {
160 if (!this.starting
) {
162 this.on(ChargingStationWorkerMessageEvents
.added
, this.workerEventAdded
)
163 this.on(ChargingStationWorkerMessageEvents
.deleted
, this.workerEventDeleted
)
164 this.on(ChargingStationWorkerMessageEvents
.started
, this.workerEventStarted
)
165 this.on(ChargingStationWorkerMessageEvents
.stopped
, this.workerEventStopped
)
166 this.on(ChargingStationWorkerMessageEvents
.updated
, this.workerEventUpdated
)
168 ChargingStationWorkerMessageEvents
.performanceStatistics
,
169 this.workerEventPerformanceStatistics
172 ChargingStationWorkerMessageEvents
.workerElementError
,
173 (eventError
: ChargingStationWorkerEventError
) => {
175 `${this.logPrefix()} ${moduleName}.start: Error occurred while handling '${eventError.event}' event on worker:`,
180 this.initializeCounters()
181 // eslint-disable-next-line @typescript-eslint/unbound-method
182 if (isAsyncFunction(this.workerImplementation
?.start
)) {
183 await this.workerImplementation
.start()
185 (this.workerImplementation
?.start
as () => void)()
187 const performanceStorageConfiguration
=
188 Configuration
.getConfigurationSection
<StorageConfiguration
>(
189 ConfigurationSection
.performanceStorage
191 if (performanceStorageConfiguration
.enabled
=== true) {
192 this.storage
= StorageFactory
.getStorage(
193 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
194 performanceStorageConfiguration
.type!,
195 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
196 performanceStorageConfiguration
.uri
!,
199 await this.storage
?.open()
202 !this.uiServerStarted
&&
203 Configuration
.getConfigurationSection
<UIServerConfiguration
>(
204 ConfigurationSection
.uiServer
207 this.uiServer
.start()
208 this.uiServerStarted
= true
210 // Start ChargingStation object instance in worker thread
211 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
212 for (const stationTemplateUrl
of Configuration
.getStationTemplateUrls()!) {
214 const nbStations
= stationTemplateUrl
.numberOfStations
215 for (let index
= 1; index
<= nbStations
; index
++) {
216 await this.addChargingStation(index
, stationTemplateUrl
.file
)
221 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
227 const workerConfiguration
= Configuration
.getConfigurationSection
<WorkerConfiguration
>(
228 ConfigurationSection
.worker
232 `Charging stations simulator ${
234 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} 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 this.initializedCounters
= false
282 await this.storage
?.close()
285 this.stopping
= false
287 console
.error(chalk
.red('Cannot stop an already stopping charging stations simulator'))
290 console
.error(chalk
.red('Cannot stop an already stopped charging stations simulator'))
294 private async restart (): Promise
<void> {
296 // FIXME: initialize worker implementation only if the worker section has changed
297 this.initializeWorkerImplementation(
298 Configuration
.getConfigurationSection
<WorkerConfiguration
>(ConfigurationSection
.worker
)
301 this.uiServerStarted
&&
302 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
306 this.uiServerStarted
= false
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
) {
342 elementsPerWorker
= this.numberOfConfiguredChargingStations
347 this.numberOfConfiguredChargingStations
> availableParallelism()
348 ? Math.round(this.numberOfConfiguredChargingStations
/ (availableParallelism() * 1.5))
352 this.workerImplementation
= WorkerFactory
.getWorkerImplementation
<ChargingStationWorkerData
>(
354 dirname(fileURLToPath(import.meta
.url
)),
355 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
357 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
358 workerConfiguration
.processType
!,
360 workerStartDelay
: workerConfiguration
.startDelay
,
361 elementAddDelay
: workerConfiguration
.elementAddDelay
,
362 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
363 poolMaxSize
: workerConfiguration
.poolMaxSize
!,
364 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
365 poolMinSize
: workerConfiguration
.poolMinSize
!,
368 messageHandler
: this.messageHandler
.bind(this) as MessageHandler
<Worker
>,
369 workerOptions
: { resourceLimits
: workerConfiguration
.resourceLimits
}
375 private messageHandler (
376 msg
: ChargingStationWorkerMessage
<ChargingStationWorkerMessageData
>
379 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
387 case ChargingStationWorkerMessageEvents
.added
:
388 this.emit(ChargingStationWorkerMessageEvents
.added
, msg
.data
)
390 case ChargingStationWorkerMessageEvents
.deleted
:
391 this.emit(ChargingStationWorkerMessageEvents
.deleted
, msg
.data
)
393 case ChargingStationWorkerMessageEvents
.started
:
394 this.emit(ChargingStationWorkerMessageEvents
.started
, msg
.data
)
396 case ChargingStationWorkerMessageEvents
.stopped
:
397 this.emit(ChargingStationWorkerMessageEvents
.stopped
, msg
.data
)
399 case ChargingStationWorkerMessageEvents
.updated
:
400 this.emit(ChargingStationWorkerMessageEvents
.updated
, msg
.data
)
402 case ChargingStationWorkerMessageEvents
.performanceStatistics
:
403 this.emit(ChargingStationWorkerMessageEvents
.performanceStatistics
, msg
.data
)
405 case ChargingStationWorkerMessageEvents
.addedWorkerElement
:
406 this.emit(ChargingStationWorkerMessageEvents
.addWorkerElement
, msg
.data
)
408 case ChargingStationWorkerMessageEvents
.workerElementError
:
409 this.emit(ChargingStationWorkerMessageEvents
.workerElementError
, msg
.data
)
413 `Unknown charging station worker event: '${
415 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
420 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
428 private readonly workerEventAdded
= (data
: ChargingStationData
): void => {
429 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
431 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
432 data.stationInfo.chargingStationId
433 } (hashId: ${data.stationInfo.hashId}) added (${
434 this.numberOfAddedChargingStations
435 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
439 private readonly workerEventDeleted
= (data
: ChargingStationData
): void => {
440 this.uiServer
.chargingStations
.delete(data
.stationInfo
.hashId
)
441 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
442 const templateStatistics
= this.templateStatistics
.get(data
.stationInfo
.templateName
)!
443 --templateStatistics
.added
444 templateStatistics
.indexes
.delete(data
.stationInfo
.templateIndex
)
446 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
447 data.stationInfo.chargingStationId
448 } (hashId: ${data.stationInfo.hashId}) deleted (${
449 this.numberOfAddedChargingStations
450 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
454 private readonly workerEventStarted
= (data
: ChargingStationData
): void => {
455 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
456 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
457 ++this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
459 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
460 data.stationInfo.chargingStationId
461 } (hashId: ${data.stationInfo.hashId}) started (${
462 this.numberOfStartedChargingStations
463 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
467 private readonly workerEventStopped
= (data
: ChargingStationData
): void => {
468 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
469 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
470 --this.templateStatistics
.get(data
.stationInfo
.templateName
)!.started
472 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
473 data.stationInfo.chargingStationId
474 } (hashId: ${data.stationInfo.hashId}) stopped (${
475 this.numberOfStartedChargingStations
476 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
480 private readonly workerEventUpdated
= (data
: ChargingStationData
): void => {
481 this.uiServer
.chargingStations
.set(data
.stationInfo
.hashId
, data
)
484 private readonly workerEventPerformanceStatistics
= (data
: Statistics
): void => {
485 // eslint-disable-next-line @typescript-eslint/unbound-method
486 if (isAsyncFunction(this.storage
?.storePerformanceStatistics
)) {
488 this.storage
.storePerformanceStatistics
as (
489 performanceStatistics
: Statistics
491 )(data
).catch(Constants
.EMPTY_FUNCTION
)
493 (this.storage
?.storePerformanceStatistics
as (performanceStatistics
: Statistics
) => void)(
499 private initializeCounters (): void {
500 if (!this.initializedCounters
) {
501 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
502 const stationTemplateUrls
= Configuration
.getStationTemplateUrls()!
503 if (isNotEmptyArray(stationTemplateUrls
)) {
504 for (const stationTemplateUrl
of stationTemplateUrls
) {
505 const templateName
= buildTemplateName(stationTemplateUrl
.file
)
506 this.templateStatistics
.set(templateName
, {
507 configured
: stationTemplateUrl
.numberOfStations
,
510 indexes
: new Set
<number>()
512 this.uiServer
.chargingStationTemplates
.add(templateName
)
514 if (this.templateStatistics
.size
!== stationTemplateUrls
.length
) {
517 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
520 exit(exitCodes
.duplicateChargingStationTemplateUrls
)
524 chalk
.red("'stationTemplateUrls' not defined or empty, please check your configuration")
526 exit(exitCodes
.missingChargingStationsConfiguration
)
529 this.numberOfConfiguredChargingStations
=== 0 &&
530 Configuration
.getConfigurationSection
<UIServerConfiguration
>(ConfigurationSection
.uiServer
)
535 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
538 exit(exitCodes
.noChargingStationTemplates
)
540 this.initializedCounters
= true
544 public async addChargingStation (
546 templateFile
: string,
547 options
?: ChargingStationOptions
549 if (!this.started
&& !this.starting
) {
551 'Cannot add charging station while the charging stations simulator is not started'
554 await this.workerImplementation
?.addElement({
557 dirname(fileURLToPath(import.meta
.url
)),
564 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
565 const templateStatistics
= this.templateStatistics
.get(buildTemplateName(templateFile
))!
566 ++templateStatistics
.added
567 templateStatistics
.indexes
.add(index
)
570 private gracefulShutdown (): void {
573 console
.info(chalk
.green('Graceful shutdown'))
575 this.uiServerStarted
= false
576 this.waitChargingStationsStopped()
578 exit(exitCodes
.succeeded
)
581 exit(exitCodes
.gracefulShutdownError
)
584 .catch((error
: unknown
) => {
585 console
.error(chalk
.red('Error while shutdowning charging stations simulator: '), error
)
586 exit(exitCodes
.gracefulShutdownError
)
590 private readonly logPrefix
= (): string => {
591 return logPrefix(' Bootstrap |')