fix: clear UI server cache at simulator stop
[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 this.uiServer?.chargingStations.clear()
265 await this.storage?.close()
266 delete this.storage
267 this.started = false
268 this.stopping = false
269 } else {
270 console.error(chalk.red('Cannot stop an already stopping charging stations simulator'))
271 }
272 } else {
273 console.error(chalk.red('Cannot stop an already stopped charging stations simulator'))
274 }
275 }
276
277 private async restart (): Promise<void> {
278 await this.stop()
279 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
280 .enabled !== true && this.uiServer?.stop()
281 this.initializedCounters = false
282 await this.start()
283 }
284
285 private async waitChargingStationsStopped (): Promise<string> {
286 return await new Promise<string>((resolve, reject) => {
287 const waitTimeout = setTimeout(() => {
288 const timeoutMessage = `Timeout ${formatDurationMilliSeconds(
289 Constants.STOP_CHARGING_STATIONS_TIMEOUT
290 )} reached at stopping charging stations`
291 console.warn(chalk.yellow(timeoutMessage))
292 reject(new Error(timeoutMessage))
293 }, Constants.STOP_CHARGING_STATIONS_TIMEOUT)
294 waitChargingStationEvents(
295 this,
296 ChargingStationWorkerMessageEvents.stopped,
297 this.numberOfStartedChargingStations
298 )
299 .then(() => {
300 resolve('Charging stations stopped')
301 })
302 .catch(reject)
303 .finally(() => {
304 clearTimeout(waitTimeout)
305 })
306 })
307 }
308
309 private initializeWorkerImplementation (workerConfiguration: WorkerConfiguration): void {
310 if (!isMainThread) {
311 return
312 }
313 let elementsPerWorker: number
314 switch (workerConfiguration.elementsPerWorker) {
315 case 'all':
316 elementsPerWorker = this.numberOfConfiguredChargingStations
317 break
318 case 'auto':
319 default:
320 elementsPerWorker =
321 this.numberOfConfiguredChargingStations > availableParallelism()
322 ? Math.round(this.numberOfConfiguredChargingStations / (availableParallelism() * 1.5))
323 : 1
324 break
325 }
326 this.workerImplementation = WorkerFactory.getWorkerImplementation<ChargingStationWorkerData>(
327 join(
328 dirname(fileURLToPath(import.meta.url)),
329 `ChargingStationWorker${extname(fileURLToPath(import.meta.url))}`
330 ),
331 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
332 workerConfiguration.processType!,
333 {
334 workerStartDelay: workerConfiguration.startDelay,
335 elementStartDelay: workerConfiguration.elementStartDelay,
336 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
337 poolMaxSize: workerConfiguration.poolMaxSize!,
338 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
339 poolMinSize: workerConfiguration.poolMinSize!,
340 elementsPerWorker,
341 poolOptions: {
342 messageHandler: this.messageHandler.bind(this) as MessageHandler<Worker>,
343 workerOptions: { resourceLimits: workerConfiguration.resourceLimits }
344 }
345 }
346 )
347 }
348
349 private messageHandler (
350 msg: ChargingStationWorkerMessage<ChargingStationWorkerMessageData>
351 ): void {
352 // logger.debug(
353 // `${this.logPrefix()} ${moduleName}.messageHandler: Worker channel message received: ${JSON.stringify(
354 // msg,
355 // undefined,
356 // 2
357 // )}`
358 // )
359 try {
360 switch (msg.event) {
361 case ChargingStationWorkerMessageEvents.added:
362 this.emit(ChargingStationWorkerMessageEvents.added, msg.data)
363 break
364 case ChargingStationWorkerMessageEvents.deleted:
365 this.emit(ChargingStationWorkerMessageEvents.deleted, msg.data)
366 break
367 case ChargingStationWorkerMessageEvents.started:
368 this.emit(ChargingStationWorkerMessageEvents.started, msg.data)
369 break
370 case ChargingStationWorkerMessageEvents.stopped:
371 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data)
372 break
373 case ChargingStationWorkerMessageEvents.updated:
374 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data)
375 break
376 case ChargingStationWorkerMessageEvents.performanceStatistics:
377 this.emit(ChargingStationWorkerMessageEvents.performanceStatistics, msg.data)
378 break
379 case ChargingStationWorkerMessageEvents.addedWorkerElement:
380 this.emit(ChargingStationWorkerMessageEvents.addWorkerElement, msg.data)
381 break
382 case ChargingStationWorkerMessageEvents.workerElementError:
383 this.emit(ChargingStationWorkerMessageEvents.workerElementError, msg.data)
384 break
385 default:
386 throw new BaseError(
387 `Unknown charging station worker event: '${
388 msg.event
389 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
390 )
391 }
392 } catch (error) {
393 logger.error(
394 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
395 msg.event
396 }' event:`,
397 error
398 )
399 }
400 }
401
402 private readonly workerEventAdded = (data: ChargingStationData): void => {
403 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
404 logger.info(
405 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
406 data.stationInfo.chargingStationId
407 } (hashId: ${data.stationInfo.hashId}) added (${
408 this.numberOfAddedChargingStations
409 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
410 )
411 }
412
413 private readonly workerEventDeleted = (data: ChargingStationData): void => {
414 this.uiServer?.chargingStations.delete(data.stationInfo.hashId)
415 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
416 const templateChargingStations = this.templatesChargingStations.get(
417 data.stationInfo.templateName
418 )!
419 --templateChargingStations.added
420 templateChargingStations.indexes.delete(data.stationInfo.templateIndex)
421 logger.info(
422 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
423 data.stationInfo.chargingStationId
424 } (hashId: ${data.stationInfo.hashId}) deleted (${
425 this.numberOfAddedChargingStations
426 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
427 )
428 }
429
430 private readonly workerEventStarted = (data: ChargingStationData): void => {
431 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
432 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
433 ++this.templatesChargingStations.get(data.stationInfo.templateName)!.started
434 logger.info(
435 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
436 data.stationInfo.chargingStationId
437 } (hashId: ${data.stationInfo.hashId}) started (${
438 this.numberOfStartedChargingStations
439 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
440 )
441 }
442
443 private readonly workerEventStopped = (data: ChargingStationData): void => {
444 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
445 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
446 --this.templatesChargingStations.get(data.stationInfo.templateName)!.started
447 logger.info(
448 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
449 data.stationInfo.chargingStationId
450 } (hashId: ${data.stationInfo.hashId}) stopped (${
451 this.numberOfStartedChargingStations
452 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
453 )
454 }
455
456 private readonly workerEventUpdated = (data: ChargingStationData): void => {
457 this.uiServer?.chargingStations.set(data.stationInfo.hashId, data)
458 }
459
460 private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
461 // eslint-disable-next-line @typescript-eslint/unbound-method
462 if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
463 (
464 this.storage.storePerformanceStatistics as (
465 performanceStatistics: Statistics
466 ) => Promise<void>
467 )(data).catch(Constants.EMPTY_FUNCTION)
468 } else {
469 (this.storage?.storePerformanceStatistics as (performanceStatistics: Statistics) => void)(
470 data
471 )
472 }
473 }
474
475 private initializeCounters (): void {
476 if (!this.initializedCounters) {
477 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
478 const stationTemplateUrls = Configuration.getStationTemplateUrls()!
479 if (isNotEmptyArray(stationTemplateUrls)) {
480 for (const stationTemplateUrl of stationTemplateUrls) {
481 const templateName = parse(stationTemplateUrl.file).name
482 this.templatesChargingStations.set(templateName, {
483 configured: stationTemplateUrl.numberOfStations,
484 added: 0,
485 started: 0,
486 indexes: new Set<number>()
487 })
488 this.uiServer?.chargingStationTemplates.add(templateName)
489 }
490 if (this.templatesChargingStations.size !== stationTemplateUrls.length) {
491 console.error(
492 chalk.red(
493 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
494 )
495 )
496 exit(exitCodes.duplicateChargingStationTemplateUrls)
497 }
498 } else {
499 console.error(
500 chalk.red("'stationTemplateUrls' not defined or empty, please check your configuration")
501 )
502 exit(exitCodes.missingChargingStationsConfiguration)
503 }
504 if (
505 this.numberOfConfiguredChargingStations === 0 &&
506 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
507 .enabled !== true
508 ) {
509 console.error(
510 chalk.red(
511 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
512 )
513 )
514 exit(exitCodes.noChargingStationTemplates)
515 }
516 this.initializedCounters = true
517 }
518 }
519
520 public async addChargingStation (
521 index: number,
522 stationTemplateFile: string,
523 options?: ChargingStationOptions
524 ): Promise<void> {
525 await this.workerImplementation?.addElement({
526 index,
527 templateFile: join(
528 dirname(fileURLToPath(import.meta.url)),
529 'assets',
530 'station-templates',
531 stationTemplateFile
532 ),
533 options
534 })
535 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
536 const templateChargingStations = this.templatesChargingStations.get(
537 parse(stationTemplateFile).name
538 )!
539 ++templateChargingStations.added
540 templateChargingStations.indexes.add(index)
541 }
542
543 private gracefulShutdown (): void {
544 this.stop()
545 .then(() => {
546 console.info(chalk.green('Graceful shutdown'))
547 this.uiServer?.stop()
548 this.waitChargingStationsStopped()
549 .then(() => {
550 exit(exitCodes.succeeded)
551 })
552 .catch(() => {
553 exit(exitCodes.gracefulShutdownError)
554 })
555 })
556 .catch(error => {
557 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error)
558 exit(exitCodes.gracefulShutdownError)
559 })
560 }
561
562 private readonly logPrefix = (): string => {
563 return logPrefix(' Bootstrap |')
564 }
565 }