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