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