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