feat: add addChargingStations command to UI API
[e-mobility-charging-stations-simulator.git] / src / charging-station / Bootstrap.ts
CommitLineData
a19b897d 1// Partial Copyright Jerome Benoit. 2021-2024. All Rights Reserved.
b4d34251 2
66a7748d 3import { EventEmitter } from 'node:events'
2f989136 4import { dirname, extname, join, parse } from 'node:path'
66a7748d
JB
5import process, { exit } from 'node:process'
6import { fileURLToPath } from 'node:url'
c5ecc04d 7import { isMainThread } from 'node:worker_threads'
ba9a56a6 8import type { Worker } from 'worker_threads'
8114d10e 9
66a7748d 10import chalk from 'chalk'
ba9a56a6 11import { type MessageHandler, availableParallelism } from 'poolifier'
8114d10e 12
66a7748d
JB
13import { waitChargingStationEvents } from './Helpers.js'
14import type { AbstractUIServer } from './ui-server/AbstractUIServer.js'
15import { UIServerFactory } from './ui-server/UIServerFactory.js'
16import { version } from '../../package.json'
17import { BaseError } from '../exception/index.js'
18import { type Storage, StorageFactory } from '../performance/index.js'
e7aeea18 19import {
bbe10d5f
JB
20 type ChargingStationData,
21 type ChargingStationWorkerData,
22 type ChargingStationWorkerMessage,
23 type ChargingStationWorkerMessageData,
e7aeea18 24 ChargingStationWorkerMessageEvents,
5d049829 25 ConfigurationSection,
6bd808fd 26 ProcedureName,
268a74bb 27 type Statistics,
5d049829
JB
28 type StorageConfiguration,
29 type UIServerConfiguration,
66a7748d
JB
30 type WorkerConfiguration
31} from '../types/index.js'
fa5995d6
JB
32import {
33 Configuration,
34 Constants,
9bf0ef23
JB
35 formatDurationMilliSeconds,
36 generateUUID,
fa5995d6
JB
37 handleUncaughtException,
38 handleUnhandledRejection,
be0a4d4d 39 isAsyncFunction,
9bf0ef23 40 isNotEmptyArray,
9bf0ef23 41 logPrefix,
c5ecc04d
JB
42 logger,
43 max
66a7748d
JB
44} from '../utils/index.js'
45import { type WorkerAbstract, WorkerFactory } from '../worker/index.js'
ded13d97 46
66a7748d 47const moduleName = 'Bootstrap'
32de5a57 48
a307349b 49enum exitCodes {
a51a4ead 50 succeeded = 0,
a307349b 51 missingChargingStationsConfiguration = 1,
2f989136
JB
52 duplicateChargingStationTemplateUrls = 2,
53 noChargingStationTemplates = 3,
54 gracefulShutdownError = 4
a307349b 55}
e4cb2c14 56
f130b8e6 57export class Bootstrap extends EventEmitter {
66a7748d 58 private static instance: Bootstrap | null = null
66a7748d
JB
59 private workerImplementation?: WorkerAbstract<ChargingStationWorkerData>
60 private readonly uiServer?: AbstractUIServer
61 private storage?: Storage
c5ecc04d
JB
62 private readonly chargingStationsByTemplate: Map<
63 string,
64 { configured: number, started: number, lastIndex: number }
65 >
66
66a7748d
JB
67 private readonly version: string = version
68 private initializedCounters: boolean
69 private started: boolean
70 private starting: boolean
71 private stopping: boolean
ded13d97 72
66a7748d
JB
73 private constructor () {
74 super()
6bd808fd 75 for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
66a7748d 76 process.on(signal, this.gracefulShutdown.bind(this))
6bd808fd 77 }
4724a293 78 // Enable unconditionally for now
66a7748d
JB
79 handleUnhandledRejection()
80 handleUncaughtException()
81 this.started = false
82 this.starting = false
83 this.stopping = false
2f989136
JB
84 this.chargingStationsByTemplate = new Map<
85 string,
c5ecc04d 86 { configured: number, started: number, lastIndex: number }
2f989136 87 >()
36adaf06 88 this.uiServer = UIServerFactory.getUIServerImplementation(
66a7748d
JB
89 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
90 )
42e341c4
JB
91 this.initializedCounters = false
92 this.initializeCounters()
66a7748d 93 Configuration.configurationChangeCallback = async () => {
c5ecc04d
JB
94 if (isMainThread) {
95 await Bootstrap.getInstance().restart()
96 }
66a7748d 97 }
ded13d97
JB
98 }
99
66a7748d 100 public static getInstance (): Bootstrap {
1ca780f9 101 if (Bootstrap.instance === null) {
66a7748d 102 Bootstrap.instance = new Bootstrap()
ded13d97 103 }
66a7748d 104 return Bootstrap.instance
ded13d97
JB
105 }
106
2f989136
JB
107 public get numberOfChargingStationTemplates (): number {
108 return this.chargingStationsByTemplate.size
109 }
110
111 public get numberOfConfiguredChargingStations (): number {
112 return [...this.chargingStationsByTemplate.values()].reduce(
113 (accumulator, value) => accumulator + value.configured,
114 0
115 )
116 }
117
c5ecc04d
JB
118 public getLastIndex (templateName: string): number {
119 return this.chargingStationsByTemplate.get(templateName)?.lastIndex ?? 0
120 }
121
2f989136
JB
122 private get numberOfStartedChargingStations (): number {
123 return [...this.chargingStationsByTemplate.values()].reduce(
124 (accumulator, value) => accumulator + value.started,
125 0
126 )
127 }
128
66a7748d
JB
129 public async start (): Promise<void> {
130 if (!this.started) {
131 if (!this.starting) {
132 this.starting = true
133 this.on(ChargingStationWorkerMessageEvents.started, this.workerEventStarted)
134 this.on(ChargingStationWorkerMessageEvents.stopped, this.workerEventStopped)
135 this.on(ChargingStationWorkerMessageEvents.updated, this.workerEventUpdated)
4354af5a
JB
136 this.on(
137 ChargingStationWorkerMessageEvents.performanceStatistics,
66a7748d
JB
138 this.workerEventPerformanceStatistics
139 )
140 this.initializeCounters()
5b373a23 141 const workerConfiguration = Configuration.getConfigurationSection<WorkerConfiguration>(
66a7748d
JB
142 ConfigurationSection.worker
143 )
144 this.initializeWorkerImplementation(workerConfiguration)
145 await this.workerImplementation?.start()
6d2b7d01
JB
146 const performanceStorageConfiguration =
147 Configuration.getConfigurationSection<StorageConfiguration>(
66a7748d
JB
148 ConfigurationSection.performanceStorage
149 )
6d2b7d01
JB
150 if (performanceStorageConfiguration.enabled === true) {
151 this.storage = StorageFactory.getStorage(
66a7748d 152 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6d2b7d01 153 performanceStorageConfiguration.type!,
66a7748d 154 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6d2b7d01 155 performanceStorageConfiguration.uri!,
66a7748d
JB
156 this.logPrefix()
157 )
158 await this.storage?.open()
6d2b7d01 159 }
36adaf06 160 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
66a7748d 161 .enabled === true && this.uiServer?.start()
82e9c15a 162 // Start ChargingStation object instance in worker thread
66a7748d 163 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
e1d9a0f4 164 for (const stationTemplateUrl of Configuration.getStationTemplateUrls()!) {
82e9c15a 165 try {
2f989136
JB
166 const nbStations =
167 this.chargingStationsByTemplate.get(parse(stationTemplateUrl.file).name)
168 ?.configured ?? stationTemplateUrl.numberOfStations
82e9c15a 169 for (let index = 1; index <= nbStations; index++) {
c5ecc04d 170 await this.addChargingStation(index, stationTemplateUrl.file)
82e9c15a
JB
171 }
172 } catch (error) {
173 console.error(
174 chalk.red(
66a7748d 175 `Error at starting charging station with template file ${stationTemplateUrl.file}: `
82e9c15a 176 ),
66a7748d
JB
177 error
178 )
ded13d97 179 }
ded13d97 180 }
82e9c15a
JB
181 console.info(
182 chalk.green(
183 `Charging stations simulator ${
184 this.version
c5ecc04d 185 } started with ${this.numberOfConfiguredChargingStations} configured charging station(s) from ${this.numberOfChargingStationTemplates} charging station template(s) and ${
2f989136 186 Configuration.workerDynamicPoolInUse() ? `${workerConfiguration.poolMinSize}/` : ''
82e9c15a 187 }${this.workerImplementation?.size}${
2f989136 188 Configuration.workerPoolInUse() ? `/${workerConfiguration.poolMaxSize}` : ''
5b373a23 189 } worker(s) concurrently running in '${workerConfiguration.processType}' mode${
401fa922 190 this.workerImplementation?.maxElementsPerWorker != null
5199f9fd 191 ? ` (${this.workerImplementation.maxElementsPerWorker} charging station(s) per worker)`
82e9c15a 192 : ''
66a7748d
JB
193 }`
194 )
195 )
56e2e1ab
JB
196 Configuration.workerDynamicPoolInUse() &&
197 console.warn(
198 chalk.yellow(
66a7748d
JB
199 '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'
200 )
201 )
202 console.info(chalk.green('Worker set/pool information:'), this.workerImplementation?.info)
203 this.started = true
204 this.starting = false
82e9c15a 205 } else {
66a7748d 206 console.error(chalk.red('Cannot start an already starting charging stations simulator'))
ded13d97 207 }
b322b8b4 208 } else {
66a7748d 209 console.error(chalk.red('Cannot start an already started charging stations simulator'))
ded13d97
JB
210 }
211 }
212
c5ecc04d 213 public async stop (): Promise<void> {
66a7748d
JB
214 if (this.started) {
215 if (!this.stopping) {
216 this.stopping = true
c5ecc04d
JB
217 await this.uiServer?.sendInternalRequest(
218 this.uiServer.buildProtocolRequest(
219 generateUUID(),
220 ProcedureName.STOP_CHARGING_STATION,
221 Constants.EMPTY_FROZEN_OBJECT
66a7748d 222 )
c5ecc04d
JB
223 )
224 try {
225 await this.waitChargingStationsStopped()
226 } catch (error) {
227 console.error(chalk.red('Error while waiting for charging stations to stop: '), error)
ab7a96fa 228 }
66a7748d
JB
229 await this.workerImplementation?.stop()
230 delete this.workerImplementation
231 this.removeAllListeners()
232 await this.storage?.close()
233 delete this.storage
66a7748d
JB
234 this.started = false
235 this.stopping = false
82e9c15a 236 } else {
66a7748d 237 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'))
82e9c15a 238 }
b322b8b4 239 } else {
66a7748d 240 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'))
ded13d97 241 }
ded13d97
JB
242 }
243
c5ecc04d
JB
244 private async restart (): Promise<void> {
245 await this.stop()
73edcc94 246 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
66a7748d 247 .enabled === false && this.uiServer?.stop()
2f989136 248 this.initializedCounters = false
66a7748d 249 await this.start()
ded13d97
JB
250 }
251
66a7748d
JB
252 private async waitChargingStationsStopped (): Promise<string> {
253 return await new Promise<string>((resolve, reject) => {
5b2721db 254 const waitTimeout = setTimeout(() => {
a01134ed 255 const timeoutMessage = `Timeout ${formatDurationMilliSeconds(
66a7748d
JB
256 Constants.STOP_CHARGING_STATIONS_TIMEOUT
257 )} reached at stopping charging stations`
a01134ed
JB
258 console.warn(chalk.yellow(timeoutMessage))
259 reject(new Error(timeoutMessage))
66a7748d 260 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT)
36adaf06
JB
261 waitChargingStationEvents(
262 this,
263 ChargingStationWorkerMessageEvents.stopped,
a01134ed 264 this.numberOfStartedChargingStations
5b2721db
JB
265 )
266 .then(() => {
66a7748d 267 resolve('Charging stations stopped')
5b2721db 268 })
b7ee97c1 269 .catch(reject)
5b2721db 270 .finally(() => {
66a7748d
JB
271 clearTimeout(waitTimeout)
272 })
273 })
36adaf06
JB
274 }
275
66a7748d 276 private initializeWorkerImplementation (workerConfiguration: WorkerConfiguration): void {
c5ecc04d
JB
277 if (!isMainThread) {
278 return
279 }
66a7748d 280 let elementsPerWorker: number | undefined
5199f9fd 281 switch (workerConfiguration.elementsPerWorker) {
487f0dfd
JB
282 case 'auto':
283 elementsPerWorker =
2f989136
JB
284 this.numberOfConfiguredChargingStations > availableParallelism()
285 ? Math.round(this.numberOfConfiguredChargingStations / (availableParallelism() * 1.5))
66a7748d
JB
286 : 1
287 break
c20d5d72 288 case 'all':
2f989136 289 elementsPerWorker = this.numberOfConfiguredChargingStations
66a7748d 290 break
8603c1ca 291 }
6d2b7d01
JB
292 this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
293 join(
294 dirname(fileURLToPath(import.meta.url)),
66a7748d 295 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
6d2b7d01 296 ),
66a7748d 297 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6d2b7d01
JB
298 workerConfiguration.processType!,
299 {
300 workerStartDelay: workerConfiguration.startDelay,
301 elementStartDelay: workerConfiguration.elementStartDelay,
66a7748d 302 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6d2b7d01 303 poolMaxSize: workerConfiguration.poolMaxSize!,
66a7748d 304 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
6d2b7d01
JB
305 poolMinSize: workerConfiguration.poolMinSize!,
306 elementsPerWorker: elementsPerWorker ?? (workerConfiguration.elementsPerWorker as number),
307 poolOptions: {
ba9a56a6 308 messageHandler: this.messageHandler.bind(this) as MessageHandler<Worker>,
66a7748d
JB
309 workerOptions: { resourceLimits: workerConfiguration.resourceLimits }
310 }
311 }
312 )
ded13d97 313 }
81797102 314
66a7748d
JB
315 private messageHandler (
316 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>
32de5a57
LM
317 ): void {
318 // logger.debug(
319 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
320 // msg,
4ed03b6e 321 // undefined,
66a7748d
JB
322 // 2
323 // )}`
324 // )
32de5a57 325 try {
8cc482a9 326 switch (msg.event) {
721646e9 327 case ChargingStationWorkerMessageEvents.started:
66a7748d
JB
328 this.emit(ChargingStationWorkerMessageEvents.started, msg.data as ChargingStationData)
329 break
721646e9 330 case ChargingStationWorkerMessageEvents.stopped:
66a7748d
JB
331 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data as ChargingStationData)
332 break
721646e9 333 case ChargingStationWorkerMessageEvents.updated:
66a7748d
JB
334 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data as ChargingStationData)
335 break
721646e9 336 case ChargingStationWorkerMessageEvents.performanceStatistics:
f130b8e6
JB
337 this.emit(
338 ChargingStationWorkerMessageEvents.performanceStatistics,
66a7748d
JB
339 msg.data as Statistics
340 )
341 break
2bb7a73e
JB
342 case ChargingStationWorkerMessageEvents.startWorkerElementError:
343 logger.error(
f2f33b97 344 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while starting worker element:`,
66a7748d
JB
345 msg.data
346 )
347 this.emit(ChargingStationWorkerMessageEvents.startWorkerElementError, msg.data)
348 break
2bb7a73e 349 case ChargingStationWorkerMessageEvents.startedWorkerElement:
66a7748d 350 break
32de5a57
LM
351 default:
352 throw new BaseError(
f93dda6a
JB
353 `Unknown charging station worker event: '${
354 msg.event
66a7748d
JB
355 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
356 )
32de5a57
LM
357 }
358 } catch (error) {
359 logger.error(
360 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
8cc482a9 361 msg.event
32de5a57 362 }' event:`,
66a7748d
JB
363 error
364 )
32de5a57
LM
365 }
366 }
367
66a7748d
JB
368 private readonly workerEventStarted = (data: ChargingStationData): void => {
369 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
2f989136
JB
370 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
371 ++this.chargingStationsByTemplate.get(data.stationInfo.templateName)!.started
56eb297e 372 logger.info(
e6159ce8 373 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
56eb297e 374 data.stationInfo.chargingStationId
e6159ce8 375 } (hashId: ${data.stationInfo.hashId}) started (${
56eb297e 376 this.numberOfStartedChargingStations
c5ecc04d 377 } started from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
66a7748d
JB
378 )
379 }
32de5a57 380
66a7748d
JB
381 private readonly workerEventStopped = (data: ChargingStationData): void => {
382 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
2f989136
JB
383 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
384 --this.chargingStationsByTemplate.get(data.stationInfo.templateName)!.started
56eb297e 385 logger.info(
e6159ce8 386 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
56eb297e 387 data.stationInfo.chargingStationId
e6159ce8 388 } (hashId: ${data.stationInfo.hashId}) stopped (${
56eb297e 389 this.numberOfStartedChargingStations
c5ecc04d 390 } started from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
66a7748d
JB
391 )
392 }
32de5a57 393
66a7748d
JB
394 private readonly workerEventUpdated = (data: ChargingStationData): void => {
395 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
396 }
32de5a57 397
66a7748d 398 private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
be0a4d4d
JB
399 // eslint-disable-next-line @typescript-eslint/unbound-method
400 if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
401 (
402 this.storage.storePerformanceStatistics as (
403 performanceStatistics: Statistics
404 ) => Promise<void>
405 )(data).catch(Constants.EMPTY_FUNCTION)
406 } else {
407 (this.storage?.storePerformanceStatistics as (performanceStatistics: Statistics) => void)(
408 data
409 )
410 }
66a7748d 411 }
32de5a57 412
66a7748d
JB
413 private initializeCounters (): void {
414 if (!this.initializedCounters) {
66a7748d
JB
415 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
416 const stationTemplateUrls = Configuration.getStationTemplateUrls()!
9bf0ef23 417 if (isNotEmptyArray(stationTemplateUrls)) {
7436ee0d 418 for (const stationTemplateUrl of stationTemplateUrls) {
2f989136
JB
419 const templateName = parse(stationTemplateUrl.file).name
420 this.chargingStationsByTemplate.set(templateName, {
421 configured: stationTemplateUrl.numberOfStations,
c5ecc04d
JB
422 started: 0,
423 lastIndex: 0
2f989136
JB
424 })
425 this.uiServer?.chargingStationTemplates.add(templateName)
426 }
427 if (this.chargingStationsByTemplate.size !== stationTemplateUrls.length) {
428 console.error(
429 chalk.red(
430 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
431 )
432 )
433 exit(exitCodes.duplicateChargingStationTemplateUrls)
7436ee0d 434 }
a596d200 435 } else {
2f989136
JB
436 console.error(
437 chalk.red("'stationTemplateUrls' not defined or empty, please check your configuration")
66a7748d
JB
438 )
439 exit(exitCodes.missingChargingStationsConfiguration)
a596d200 440 }
c5ecc04d
JB
441 if (
442 this.numberOfConfiguredChargingStations === 0 &&
443 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
444 .enabled === true
445 ) {
2f989136
JB
446 console.error(
447 chalk.red(
c5ecc04d 448 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
2f989136
JB
449 )
450 )
66a7748d 451 exit(exitCodes.noChargingStationTemplates)
a596d200 452 }
66a7748d 453 this.initializedCounters = true
846d2851 454 }
7c72977b
JB
455 }
456
c5ecc04d 457 public async addChargingStation (index: number, stationTemplateFile: string): Promise<void> {
6ed3c845 458 await this.workerImplementation?.addElement({
717c1e56 459 index,
d972af76
JB
460 templateFile: join(
461 dirname(fileURLToPath(import.meta.url)),
e7aeea18
JB
462 'assets',
463 'station-templates',
c5ecc04d 464 stationTemplateFile
66a7748d
JB
465 )
466 })
c5ecc04d
JB
467 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
468 this.chargingStationsByTemplate.get(parse(stationTemplateFile).name)!.lastIndex = max(
469 index,
470 this.chargingStationsByTemplate.get(parse(stationTemplateFile).name)?.lastIndex ?? -Infinity
471 )
717c1e56
JB
472 }
473
66a7748d 474 private gracefulShutdown (): void {
f130b8e6
JB
475 this.stop()
476 .then(() => {
5199f9fd 477 console.info(chalk.green('Graceful shutdown'))
66a7748d 478 this.uiServer?.stop()
36adaf06
JB
479 // stop() asks for charging stations to stop by default
480 this.waitChargingStationsStopped()
481 .then(() => {
66a7748d 482 exit(exitCodes.succeeded)
36adaf06 483 })
5b2721db 484 .catch(() => {
66a7748d
JB
485 exit(exitCodes.gracefulShutdownError)
486 })
f130b8e6 487 })
a974c8e4 488 .catch(error => {
66a7748d
JB
489 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error)
490 exit(exitCodes.gracefulShutdownError)
491 })
36adaf06 492 }
f130b8e6 493
66a7748d
JB
494 private readonly logPrefix = (): string => {
495 return logPrefix(' Bootstrap |')
496 }
ded13d97 497}