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