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