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