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