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