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