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