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