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