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