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