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