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