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