fix: untangle worker set message from application message
[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 try {
380 switch (msg.event) {
381 case ChargingStationWorkerMessageEvents.added:
382 this.emit(ChargingStationWorkerMessageEvents.added, msg.data)
383 break
384 case ChargingStationWorkerMessageEvents.deleted:
385 this.emit(ChargingStationWorkerMessageEvents.deleted, msg.data)
386 break
387 case ChargingStationWorkerMessageEvents.started:
388 this.emit(ChargingStationWorkerMessageEvents.started, msg.data)
389 break
390 case ChargingStationWorkerMessageEvents.stopped:
391 this.emit(ChargingStationWorkerMessageEvents.stopped, msg.data)
392 break
393 case ChargingStationWorkerMessageEvents.updated:
394 this.emit(ChargingStationWorkerMessageEvents.updated, msg.data)
395 break
396 case ChargingStationWorkerMessageEvents.performanceStatistics:
397 this.emit(ChargingStationWorkerMessageEvents.performanceStatistics, msg.data)
398 break
399 default:
400 throw new BaseError(
401 `Unknown charging station worker event: '${
402 msg.event
403 }' received with data: ${JSON.stringify(msg.data, undefined, 2)}`
404 )
405 }
406 } catch (error) {
407 logger.error(
408 `${this.logPrefix()} ${moduleName}.messageHandler: Error occurred while handling '${
409 msg.event
410 }' event:`,
411 error
412 )
413 }
414 }
415
416 private readonly workerEventAdded = (data: ChargingStationData): void => {
417 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
418 logger.info(
419 `${this.logPrefix()} ${moduleName}.workerEventAdded: Charging station ${
420 data.stationInfo.chargingStationId
421 } (hashId: ${data.stationInfo.hashId}) added (${
422 this.numberOfAddedChargingStations
423 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
424 )
425 }
426
427 private readonly workerEventDeleted = (data: ChargingStationData): void => {
428 this.uiServer.chargingStations.delete(data.stationInfo.hashId)
429 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
430 const templateStatistics = this.templateStatistics.get(data.stationInfo.templateName)!
431 --templateStatistics.added
432 templateStatistics.indexes.delete(data.stationInfo.templateIndex)
433 logger.info(
434 `${this.logPrefix()} ${moduleName}.workerEventDeleted: Charging station ${
435 data.stationInfo.chargingStationId
436 } (hashId: ${data.stationInfo.hashId}) deleted (${
437 this.numberOfAddedChargingStations
438 } added from ${this.numberOfConfiguredChargingStations} configured charging station(s))`
439 )
440 }
441
442 private readonly workerEventStarted = (data: ChargingStationData): void => {
443 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
444 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
445 ++this.templateStatistics.get(data.stationInfo.templateName)!.started
446 logger.info(
447 `${this.logPrefix()} ${moduleName}.workerEventStarted: Charging station ${
448 data.stationInfo.chargingStationId
449 } (hashId: ${data.stationInfo.hashId}) started (${
450 this.numberOfStartedChargingStations
451 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
452 )
453 }
454
455 private readonly workerEventStopped = (data: ChargingStationData): void => {
456 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
457 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
458 --this.templateStatistics.get(data.stationInfo.templateName)!.started
459 logger.info(
460 `${this.logPrefix()} ${moduleName}.workerEventStopped: Charging station ${
461 data.stationInfo.chargingStationId
462 } (hashId: ${data.stationInfo.hashId}) stopped (${
463 this.numberOfStartedChargingStations
464 } started from ${this.numberOfAddedChargingStations} added charging station(s))`
465 )
466 }
467
468 private readonly workerEventUpdated = (data: ChargingStationData): void => {
469 this.uiServer.chargingStations.set(data.stationInfo.hashId, data)
470 }
471
472 private readonly workerEventPerformanceStatistics = (data: Statistics): void => {
473 // eslint-disable-next-line @typescript-eslint/unbound-method
474 if (isAsyncFunction(this.storage?.storePerformanceStatistics)) {
475 (
476 this.storage.storePerformanceStatistics as (
477 performanceStatistics: Statistics
478 ) => Promise<void>
479 )(data).catch(Constants.EMPTY_FUNCTION)
480 } else {
481 (this.storage?.storePerformanceStatistics as (performanceStatistics: Statistics) => void)(
482 data
483 )
484 }
485 }
486
487 private initializeCounters (): void {
488 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
489 const stationTemplateUrls = Configuration.getStationTemplateUrls()!
490 if (isNotEmptyArray(stationTemplateUrls)) {
491 for (const stationTemplateUrl of stationTemplateUrls) {
492 const templateName = buildTemplateName(stationTemplateUrl.file)
493 this.templateStatistics.set(templateName, {
494 configured: stationTemplateUrl.numberOfStations,
495 added: 0,
496 started: 0,
497 indexes: new Set<number>()
498 })
499 this.uiServer.chargingStationTemplates.add(templateName)
500 }
501 if (this.templateStatistics.size !== stationTemplateUrls.length) {
502 console.error(
503 chalk.red(
504 "'stationTemplateUrls' contains duplicate entries, please check your configuration"
505 )
506 )
507 exit(exitCodes.duplicateChargingStationTemplateUrls)
508 }
509 } else {
510 console.error(
511 chalk.red("'stationTemplateUrls' not defined or empty, please check your configuration")
512 )
513 exit(exitCodes.missingChargingStationsConfiguration)
514 }
515 if (
516 this.numberOfConfiguredChargingStations === 0 &&
517 Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
518 .enabled !== true
519 ) {
520 console.error(
521 chalk.red(
522 "'stationTemplateUrls' has no charging station enabled and UI server is disabled, please check your configuration"
523 )
524 )
525 exit(exitCodes.noChargingStationTemplates)
526 }
527 }
528
529 public async addChargingStation (
530 index: number,
531 templateFile: string,
532 options?: ChargingStationOptions
533 ): Promise<ChargingStationInfo | undefined> {
534 if (!this.started && !this.starting) {
535 throw new BaseError(
536 'Cannot add charging station while the charging stations simulator is not started'
537 )
538 }
539 const stationInfo = await this.workerImplementation?.addElement({
540 index,
541 templateFile: join(
542 dirname(fileURLToPath(import.meta.url)),
543 'assets',
544 'station-templates',
545 templateFile
546 ),
547 options
548 })
549 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
550 const templateStatistics = this.templateStatistics.get(buildTemplateName(templateFile))!
551 ++templateStatistics.added
552 templateStatistics.indexes.add(index)
553 return stationInfo
554 }
555
556 private gracefulShutdown (): void {
557 this.stop()
558 .then(() => {
559 console.info(chalk.green('Graceful shutdown'))
560 this.uiServer.stop()
561 this.uiServerStarted = false
562 this.waitChargingStationsStopped()
563 .then(() => {
564 exit(exitCodes.succeeded)
565 })
566 .catch(() => {
567 exit(exitCodes.gracefulShutdownError)
568 })
569 })
570 .catch((error: unknown) => {
571 console.error(chalk.red('Error while shutdowning charging stations simulator: '), error)
572 exit(exitCodes.gracefulShutdownError)
573 })
574 }
575
576 private readonly logPrefix = (): string => {
577 return logPrefix(' Bootstrap |')
578 }
579 }