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