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