From 79a4089c3cbd329e9356096f8a6608d43b539228 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 21:14:56 +0200 Subject: [PATCH] =?utf8?q?feat:=20resolve=20#1020=20=E2=80=94=20Persist=20?= =?utf8?q?minimal=20simulator=20state=20and=20reconstruct=20template=20ind?= =?utf8?q?exes=20on=20startup=20(#1858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat: persist minimal simulator state and reconstruct template indexes on startup - Add persistState boolean to ConfigurationData (default: true) - Add SimulatorState to FileType enum - Add simulatorState to AsyncLockType enum - Implement BootstrapStateUtils with: - readStateFile: reads and validates state.json with schema version check - writeStateFile: atomic write via tmp+rename with AsyncLock - reconstructTemplateIndexes: scans per-station config files to rebuild indexes - deleteStateFile: safe file deletion - Integrate into Bootstrap: - reconstructTemplateIndexes called in start() before worker spawn - State file written on start() (started:true) and stop() (started:false) - shouldAutoStart() reads state file to control auto-start behavior - persistStateEnabled getter checks persistState config and SIMULATOR_COLD_START env - Update start.ts to conditionally start based on shouldAutoStart() - Handle edge cases: corrupt files, missing fields, incompatible schema version - Add comprehensive tests for all state persistence and reconstruction scenarios Closes #1020 * refactor(bootstrap): harmonize persisted state feature with codebase conventions Address review findings on PR #1858: - HIGH #1: Phase split start() lifecycle. Add public Bootstrap.startUIServer() that always brings up the UI server (and reconstructs template indexes before accepting requests). start.ts unconditionally calls startUIServer() and only gates Bootstrap.start() on shouldAutoStart(), so a persisted stopped state no longer turns the simulator into a zombie process. - HIGH #2: Add canonical default. New defaultPersistState constant and Configuration.getPersistState() accessor matching the existing defaultUIServerConfiguration / defaultWorkerConfiguration pattern. Bootstrap.persistStateEnabled now delegates instead of inlining ?? true. - MEDIUM #3: Document persistState tunable and SIMULATOR_COLD_START environment override in README.md. - MEDIUM #4: writeStateFile and deleteStateFile now swallow filesystem errors via handleFileException with throwError:false, mirroring watchJsonFile and the storage layer. Persistence failures no longer surface as misleading 'Startup error' / 'Shutdown error'. - MEDIUM #5: reconstructTemplateIndexes runs inside startUIServer() before uiServer.start(), closing the race where UI requests could arrive before index reconstruction completes. - LOW #7: Add formatLogPrefix() utility and use it in BootstrapStateUtils, removing the leading-space artifact when logPrefixFn is undefined. - LOW #8: On state file schema mismatch, quarantine to .v.bak instead of silently deleting, allowing forensics during partial schema rollouts. JSON parse errors still delete (true corruption, unrecoverable). - LOW #9: Move state.json from dist/assets/configurations/ to dist/assets/. Drops the fragile basename-based filter from reconstructTemplateIndexes and separates control file from per-station configuration files. Drop shouldAutoStart() from IBootstrap interface (boot-time concern, no UI service consumer needs it). The method stays public on the concrete Bootstrap class for start.ts. Tests: align fixtures with new state.json location, add quarantine assertion, add non-fatal writeStateFile assertion, drop obsolete state.json filter test. * test(bootstrap): align BootstrapStateUtils tests with project style guide - Rename BootstrapState.test.ts to BootstrapStateUtils.test.ts to match the source module name (TEST_STYLE_GUIDE.md §1: 'Files: ModuleName.test.ts'). - Add mandatory standardCleanup() in afterEach (TEST_STYLE_GUIDE.md §3), matching the convention used by Configuration.test.ts, FileUtils.test.ts, ErrorUtils.test.ts and ConfigurationKeyUtils.test.ts. * fix(bootstrap): address review findings on persisted simulator state Distinguish UI-initiated stop from signal/restart via a private StopReason enum, so SIGINT/SIGTERM/SIGQUIT and config-reload restarts no longer flip the persisted state to stopped (HIGH-1). Short-circuit persistStateEnabled when the UI server is disabled, since persistence has no recovery channel without a UI; warn once on the inconsistent configuration (HIGH-2). Reconstruct template indexes via a shared prepareTemplateStatistics helper called from constructor and restart, not only from startUIServer, to prevent index collisions on config-reload of UI-added stations (MED-3). Move state file from dist/assets/state.json (wiped by pnpm build) to dist/assets/configurations/.simulator-state.json; filter dot-prefixed metadata files in reconstructTemplateIndexes so the state file co-located with charging station configurations is silently skipped (MIN-10). Clean up the .tmp file on atomic-rename failure in writeStateFile and guard readStateFile against JSON null/primitive/array content with an explicit message (MIN-7, MIN-8). Unify path computations into readonly assetsDir/configurationsDir/ stateFilePath fields, deduplicate setChargingStationTemplates via a syncUIServerTemplates helper, and route the SIMULATOR_COLD_START env var name through Constants.ENV_SIMULATOR_COLD_START (MIN-5, MIN-6). Export DEFAULT_PERSIST_STATE and add the persistState entry to config-template.json so the tunable is discoverable (MED-4). Update README to document the new state file path, the UI server requirement, and the signal-shutdown semantics. Test coverage added for tmp cleanup, JSON null/primitive/array guards, and dot-prefixed metadata filtering; the misleading 'read-only directory' test is renamed to reflect the actual OS-rejected-path scenario (MIN-9). * docs(bootstrap): clarify persisted state semantics from review feedback - README: drop developer-internals sentence on template index reconstruction from the persistState row - TemplateStatistics: document the `added` (process-scoped) vs `indexes` (process+disk-scoped) distinction exposed via SimulatorState - IBootstrap: document the contract scope (UI-server-facing, excluding process-lifecycle helpers and the internal StopReason) - formatLogPrefix: document the trailing-space contract - reconstructTemplateIndexes: refine the warn message wording for non-station configuration files (level unchanged) * refactor(utils): default formatLogPrefix prefix to logPrefix Avoids silent loss of the timestamp when callers do not pass a module-specific prefix function. Aligns with the existing convention where every module-level logPrefix delegates to Utils.logPrefix. --------- Co-authored-by: Agent Co-authored-by: Jérôme Benoit Co-authored-by: Jérôme Benoit --- README.md | 1 + src/assets/config-template.json | 1 + src/charging-station/Bootstrap.ts | 108 +++- src/charging-station/BootstrapStateUtils.ts | 176 +++++++ src/charging-station/IBootstrap.ts | 5 + src/start.ts | 6 +- src/types/ConfigurationData.ts | 1 + src/types/FileType.ts | 1 + src/types/Statistics.ts | 10 + src/utils/AsyncLock.ts | 1 + src/utils/Configuration.ts | 8 + src/utils/Constants.ts | 2 + src/utils/Utils.ts | 12 + src/utils/index.ts | 1 + .../BootstrapStateUtils.test.ts | 465 ++++++++++++++++++ 15 files changed, 780 insertions(+), 18 deletions(-) create mode 100644 src/charging-station/BootstrapStateUtils.ts create mode 100644 tests/charging-station/BootstrapStateUtils.test.ts diff --git a/README.md b/README.md index aefd24ef..08085ce2 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ But the modifications to test have to be done to the files in the build target d | uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | | performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | | stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | +| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned | #### Worker process model diff --git a/src/assets/config-template.json b/src/assets/config-template.json index 943e9c5b..da09c999 100644 --- a/src/assets/config-template.json +++ b/src/assets/config-template.json @@ -24,6 +24,7 @@ "password": "admin" } }, + "persistState": true, "stationTemplateUrls": [ { "file": "siemens.station-template.json", diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index 14eb79ab..76422a72 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -4,7 +4,7 @@ import type { Worker } from 'node:worker_threads' import { EventEmitter } from 'node:events' import { dirname, extname, join } from 'node:path' -import process, { exit } from 'node:process' +import process, { env, exit } from 'node:process' import { fileURLToPath } from 'node:url' import { isMainThread } from 'node:worker_threads' import { availableParallelism, type MessageHandler } from 'poolifier' @@ -43,6 +43,7 @@ import { isNotEmptyArray, logger, logPrefix, + once, } from '../utils/index.js' import { DEFAULT_ELEMENTS_PER_WORKER, @@ -51,6 +52,7 @@ import { type WorkerAbstract, WorkerFactory, } from '../worker/index.js' +import { readStateFile, reconstructTemplateIndexes, writeStateFile } from './BootstrapStateUtils.js' import { buildTemplateName, waitChargingStationEvents } from './Helpers.js' import { UIServerFactory } from './ui-server/UIServerFactory.js' @@ -64,6 +66,12 @@ enum exitCodes { gracefulShutdownError = 4, } +enum StopReason { + reload = 'reload', + shutdown = 'shutdown', + user = 'user', +} + export class Bootstrap extends EventEmitter implements IBootstrap { private static instance: Bootstrap | null = null public get numberOfChargingStationTemplates (): number { @@ -84,14 +92,18 @@ export class Bootstrap extends EventEmitter implements IBootstrap { ) } + private readonly assetsDir: string + private readonly configurationsDir: string private started: boolean private starting: boolean + private readonly stateFilePath: string private stopping: boolean private storage?: Storage private readonly templateStatistics: Map private readonly uiServer: AbstractUIServer private uiServerStarted: boolean private readonly version: string = packageJson.version + private readonly warnPersistStateWithoutUIServerOnce: () => void private workerImplementation?: WorkerAbstract private get numberOfAddedChargingStations (): number { @@ -108,6 +120,23 @@ export class Bootstrap extends EventEmitter implements IBootstrap { ) } + private get persistStateEnabled (): boolean { + if (env[Constants.ENV_SIMULATOR_COLD_START] === 'true') { + return false + } + if (!Configuration.getPersistState()) { + return false + } + if ( + Configuration.getConfigurationSection(ConfigurationSection.uiServer) + .enabled !== true + ) { + this.warnPersistStateWithoutUIServerOnce() + return false + } + return true + } + private constructor () { super() for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) { @@ -121,11 +150,19 @@ export class Bootstrap extends EventEmitter implements IBootstrap { this.stopping = false this.uiServerStarted = false this.templateStatistics = new Map() + this.assetsDir = join(dirname(fileURLToPath(import.meta.url)), 'assets') + this.configurationsDir = join(this.assetsDir, 'configurations') + this.stateFilePath = join(this.configurationsDir, '.simulator-state.json') + this.warnPersistStateWithoutUIServerOnce = once(() => { + logger.warn( + `${this.logPrefix()} ${moduleName}: persistState is enabled but UI server is disabled. Persistence has no recovery channel and is ignored` + ) + }) this.uiServer = UIServerFactory.getUIServerImplementation( Configuration.getConfigurationSection(ConfigurationSection.uiServer), this ) - this.initializeCounters() + this.prepareTemplateStatistics() this.initializeWorkerImplementation( Configuration.getConfigurationSection(ConfigurationSection.worker) ) @@ -154,12 +191,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap { const stationInfo = await this.workerImplementation?.addElement({ index, options, - templateFile: join( - dirname(fileURLToPath(import.meta.url)), - 'assets', - 'station-templates', - templateFile - ), + templateFile: join(this.assetsDir, 'station-templates', templateFile), }) const templateStatistics = this.templateStatistics.get(buildTemplateName(templateFile)) if (stationInfo != null && templateStatistics != null) { @@ -196,6 +228,17 @@ export class Bootstrap extends EventEmitter implements IBootstrap { } } + public shouldAutoStart (): boolean { + if (!this.persistStateEnabled) { + return true + } + const stateFile = readStateFile(this.stateFilePath, this.logPrefix) + if (stateFile == null) { + return true + } + return stateFile.started + } + public async start (): Promise { if (!this.started) { if (!this.starting) { @@ -228,17 +271,13 @@ export class Bootstrap extends EventEmitter implements IBootstrap { await this.storage.open() } } - this.uiServer.setChargingStationTemplates( - Configuration.getStationTemplateUrls()?.map(stationTemplateUrl => - buildTemplateName(stationTemplateUrl.file) - ) - ) if ( !this.uiServerStarted && Configuration.getConfigurationSection( ConfigurationSection.uiServer ).enabled === true ) { + this.syncUIServerTemplates() this.uiServer.start() this.uiServerStarted = true } @@ -307,6 +346,9 @@ export class Bootstrap extends EventEmitter implements IBootstrap { this.workerImplementation?.info ) this.started = true + if (this.persistStateEnabled) { + await writeStateFile(this.stateFilePath, true, this.logPrefix) + } } finally { this.starting = false } @@ -322,7 +364,22 @@ export class Bootstrap extends EventEmitter implements IBootstrap { } } - public async stop (): Promise { + public startUIServer (): void { + if (this.uiServerStarted) { + return + } + if ( + Configuration.getConfigurationSection(ConfigurationSection.uiServer) + .enabled !== true + ) { + return + } + this.syncUIServerTemplates() + this.uiServer.start() + this.uiServerStarted = true + } + + public async stop (reason: StopReason = StopReason.user): Promise { if (this.started) { if (!this.stopping) { this.stopping = true @@ -341,6 +398,9 @@ export class Bootstrap extends EventEmitter implements IBootstrap { await this.storage?.close() delete this.storage this.started = false + if (this.persistStateEnabled && reason === StopReason.user) { + await writeStateFile(this.stateFilePath, false, this.logPrefix) + } } finally { this.stopping = false } @@ -357,7 +417,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap { } private gracefulShutdown (): void { - this.stop() + this.stop(StopReason.shutdown) .then(() => { logger.info(`${this.logPrefix()} ${moduleName}.gracefulShutdown: Graceful shutdown`) if (this.uiServerStarted) { @@ -564,8 +624,13 @@ export class Bootstrap extends EventEmitter implements IBootstrap { } } + private prepareTemplateStatistics (): void { + this.initializeCounters() + reconstructTemplateIndexes(this.configurationsDir, this.templateStatistics, this.logPrefix) + } + private async restart (): Promise { - await this.stop() + await this.stop(StopReason.reload) if ( this.uiServerStarted && Configuration.getConfigurationSection(ConfigurationSection.uiServer) @@ -574,7 +639,8 @@ export class Bootstrap extends EventEmitter implements IBootstrap { this.uiServer.stop() this.uiServerStarted = false } - this.initializeCounters() + this.prepareTemplateStatistics() + this.syncUIServerTemplates() // TODO: compare worker configuration hash to skip unnecessary re-initialization this.initializeWorkerImplementation( Configuration.getConfigurationSection(ConfigurationSection.worker) @@ -582,6 +648,14 @@ export class Bootstrap extends EventEmitter implements IBootstrap { await this.start() } + private syncUIServerTemplates (): void { + this.uiServer.setChargingStationTemplates( + Configuration.getStationTemplateUrls()?.map(stationTemplateUrl => + buildTemplateName(stationTemplateUrl.file) + ) + ) + } + private async waitChargingStationsStopped (): Promise { return await new Promise((resolve, reject: (reason?: unknown) => void) => { const waitTimeout = setTimeout(() => { diff --git a/src/charging-station/BootstrapStateUtils.ts b/src/charging-station/BootstrapStateUtils.ts new file mode 100644 index 00000000..9b2ff8d5 --- /dev/null +++ b/src/charging-station/BootstrapStateUtils.ts @@ -0,0 +1,176 @@ +// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +import type { TemplateStatistics } from '../types/index.js' + +import { FileType } from '../types/index.js' +import { + AsyncLock, + AsyncLockType, + ensureError, + formatLogPrefix, + handleFileException, + logger, +} from '../utils/index.js' + +const moduleName = 'BootstrapStateUtils' + +export const STATE_FILE_VERSION = 1 + +export interface SimulatorStateFile { + started: boolean + version: number +} + +export const deleteStateFile = (stateFilePath: string, logPrefixFn?: () => string): void => { + try { + rmSync(stateFilePath, { force: true }) + } catch (error) { + handleFileException( + stateFilePath, + FileType.SimulatorState, + ensureError(error), + logPrefixFn?.() ?? '', + { throwError: false } + ) + } +} + +export const readStateFile = ( + stateFilePath: string, + logPrefixFn?: () => string +): SimulatorStateFile | undefined => { + if (!existsSync(stateFilePath)) { + return undefined + } + try { + const parsed = JSON.parse(readFileSync(stateFilePath, 'utf8')) as unknown + if (parsed == null || typeof parsed !== 'object') { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: State file content is not a JSON object, deleting` + ) + deleteStateFile(stateFilePath, logPrefixFn) + return undefined + } + const content = parsed as Partial + if (typeof content.version !== 'number' || typeof content.started !== 'boolean') { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Invalid state file content, deleting` + ) + deleteStateFile(stateFilePath, logPrefixFn) + return undefined + } + if (content.version !== STATE_FILE_VERSION) { + const backupPath = `${stateFilePath}.v${content.version.toString()}.bak` + try { + renameSync(stateFilePath, backupPath) + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Incompatible state file schema version ${content.version.toString()} (expected ${STATE_FILE_VERSION.toString()}), quarantined to ${basename(backupPath)}` + ) + } catch (renameError) { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to quarantine incompatible state file, deleting:`, + renameError + ) + deleteStateFile(stateFilePath, logPrefixFn) + } + return undefined + } + return content as SimulatorStateFile + } catch (error) { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to read state file, deleting:`, + error + ) + deleteStateFile(stateFilePath, logPrefixFn) + return undefined + } +} + +export const reconstructTemplateIndexes = ( + configurationsDir: string, + templateStatistics: Map, + logPrefixFn?: () => string +): void => { + if (!existsSync(configurationsDir)) { + return + } + let files: string[] + try { + files = readdirSync(configurationsDir).filter( + file => file.endsWith('.json') && !file.startsWith('.') + ) + } catch (error) { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Failed to read configurations directory:`, + error + ) + return + } + for (const file of files) { + const filePath = join(configurationsDir, file) + try { + const { stationInfo } = JSON.parse(readFileSync(filePath, 'utf8')) as { + stationInfo?: { templateIndex?: number; templateName?: string } + } + if (stationInfo?.templateName == null || stationInfo.templateIndex == null) { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping ${file}: not a charging station configuration (missing stationInfo.templateName/templateIndex)` + ) + continue + } + const templateStats = templateStatistics.get(stationInfo.templateName) + if (templateStats != null) { + templateStats.indexes.add(stationInfo.templateIndex) + } + } catch (error) { + logger.warn( + `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping corrupt file ${file}:`, + error + ) + } + } +} + +export const writeStateFile = async ( + stateFilePath: string, + started: boolean, + logPrefixFn?: () => string +): Promise => { + await AsyncLock.runExclusive(AsyncLockType.simulatorState, () => { + const tmpFile = `${stateFilePath}.tmp` + try { + mkdirSync(dirname(stateFilePath), { recursive: true }) + const stateData: SimulatorStateFile = { + started, + version: STATE_FILE_VERSION, + } + writeFileSync(tmpFile, JSON.stringify(stateData, undefined, 2), 'utf8') + renameSync(tmpFile, stateFilePath) + } catch (error) { + // Best-effort tmp cleanup; ignore secondary failure to surface the original error. + try { + rmSync(tmpFile, { force: true }) + } catch { + // Ignore + } + handleFileException( + stateFilePath, + FileType.SimulatorState, + ensureError(error), + logPrefixFn?.() ?? '', + { throwError: false } + ) + } + }) +} diff --git a/src/charging-station/IBootstrap.ts b/src/charging-station/IBootstrap.ts index b12cbcbe..1614c6cf 100644 --- a/src/charging-station/IBootstrap.ts +++ b/src/charging-station/IBootstrap.ts @@ -5,6 +5,11 @@ import type { Statistics, } from '../types/index.js' +/** + * Contract exposed by the simulator core to UI servers and UI services. Process-lifecycle helpers used only by the + * entry point (`shouldAutoStart`, `startUIServer`) and the internal `StopReason` parameter of `stop` are intentionally + * excluded from this contract. + */ export interface IBootstrap { addChargingStation( index: number, diff --git a/src/start.ts b/src/start.ts index a28d1e91..c573db98 100644 --- a/src/start.ts +++ b/src/start.ts @@ -5,7 +5,11 @@ import chalk from 'chalk' import { Bootstrap } from './charging-station/index.js' try { - await Bootstrap.getInstance().start() + const bootstrap = Bootstrap.getInstance() + bootstrap.startUIServer() + if (bootstrap.shouldAutoStart()) { + await bootstrap.start() + } } catch (error) { console.error(chalk.red('Startup error: '), error) } diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index d6605024..2634567a 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -53,6 +53,7 @@ export interface ConfigurationData { /** @deprecated Moved to log configuration section. */ logStatisticsInterval?: number performanceStorage?: StorageConfiguration + persistState?: boolean stationTemplateUrls: StationTemplateUrl[] supervisionUrlDistribution?: SupervisionUrlDistribution supervisionUrls?: string | string[] diff --git a/src/types/FileType.ts b/src/types/FileType.ts index 9071002b..b38814f8 100644 --- a/src/types/FileType.ts +++ b/src/types/FileType.ts @@ -5,4 +5,5 @@ export enum FileType { Configuration = 'configuration', JsonSchema = 'json schema', PerformanceRecords = 'performance records', + SimulatorState = 'simulator state', } diff --git a/src/types/Statistics.ts b/src/types/Statistics.ts index 02927e07..fc979871 100644 --- a/src/types/Statistics.ts +++ b/src/types/Statistics.ts @@ -29,10 +29,20 @@ export type StatisticsData = Partial<{ }> export interface TemplateStatistics { + /** Number of charging stations added via `addChargingStation` in the current process. Decremented when a station is deleted. */ added: number + /** `numberOfStations` from the matching `stationTemplateUrls` entry. */ configured: number + /** + * Template indexes known to the simulator: those allocated in the current process plus those reconstructed at startup + * from existing charging station configuration files in `dist/assets/configurations/`. Used by `getLastContiguousIndex` + * to allocate collision-free indexes when adding stations via UI. May exceed `added` because reconstructed indexes + * reserve disk-persisted slots without re-spawning their stations. + */ indexes: Set + /** `provisionedNumberOfStations` from the matching `stationTemplateUrls` entry. */ provisioned: number + /** Number of currently started charging stations across this template. */ started: number } diff --git a/src/utils/AsyncLock.ts b/src/utils/AsyncLock.ts index 4813d958..bd121647 100644 --- a/src/utils/AsyncLock.ts +++ b/src/utils/AsyncLock.ts @@ -7,6 +7,7 @@ import { isAsyncFunction } from './Utils.js' export enum AsyncLockType { configuration = 'configuration', performance = 'performance', + simulatorState = 'simulatorState', } type ResolveType = (value: PromiseLike | void) => void diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index deb6d3fd..1b364b16 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -84,6 +84,10 @@ const defaultWorkerConfiguration: WorkerConfiguration = { startDelay: DEFAULT_WORKER_START_DELAY_MS, } +const defaultPersistState = true + +export const DEFAULT_PERSIST_STATE = defaultPersistState + // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class Configuration { public static configurationChangeCallback?: () => Promise @@ -167,6 +171,10 @@ export class Configuration { return Configuration.configurationSectionCache.get(sectionName) as T } + public static getPersistState (): boolean { + return Configuration.getConfigurationData()?.persistState ?? defaultPersistState + } + public static getStationTemplateUrls (): StationTemplateUrl[] | undefined { const checkDeprecatedConfigurationKeysOnce = once(() => { checkDeprecatedConfigurationKeys(Configuration.getConfigurationData()) diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 837b74eb..65fcfad6 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -109,6 +109,8 @@ export class Constants { /* This is intentional */ }) + static readonly ENV_SIMULATOR_COLD_START = 'SIMULATOR_COLD_START' + static readonly MAX_RANDOM_INTEGER = 281474976710655 // 2^48 - 1 (randomInit() limit) // Node.js setInterval/setTimeout maximum safe delay value (2^31-1 ms ≈ 24.8 days) diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 1ade0f7a..98f706ab 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -32,6 +32,18 @@ export const logPrefix = (prefixString = ''): string => { return `${new Date().toLocaleString()}${prefixString}` } +/** + * Formats a log prefix for direct concatenation with a module/method tag. + * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` (timestamp only) so callers without a + * module-specific prefix still emit a timestamped log line. + * @returns The prefix followed by a single trailing space (e.g. `" "`). The trailing space is part of the + * contract: call sites concatenate the result directly with the message body, e.g. + * `` `${formatLogPrefix(fn)}${moduleName}.method: ...` ``. + */ +export const formatLogPrefix = (logPrefixFn: () => string = logPrefix): string => { + return `${logPrefixFn()} ` +} + export const once = (fn: (...args: A) => R): ((...args: A) => R) => { let hasBeenCalled = false let result!: R diff --git a/src/utils/index.ts b/src/utils/index.ts index 799d80b5..ace35ae4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -43,6 +43,7 @@ export { extractTimeSeriesValues, formatDurationMilliSeconds, formatDurationSeconds, + formatLogPrefix, generateUUID, getMessageTypeString, getRandomFloatFluctuatedRounded, diff --git a/tests/charging-station/BootstrapStateUtils.test.ts b/tests/charging-station/BootstrapStateUtils.test.ts new file mode 100644 index 00000000..18decf1e --- /dev/null +++ b/tests/charging-station/BootstrapStateUtils.test.ts @@ -0,0 +1,465 @@ +/** + * @file Tests for BootstrapStateUtils + * @description Unit tests for simulator state file read/write and template index reconstruction + */ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { TemplateStatistics } from '../../src/types/index.js' + +import { + deleteStateFile, + readStateFile, + reconstructTemplateIndexes, + STATE_FILE_VERSION, + writeStateFile, +} from '../../src/charging-station/BootstrapStateUtils.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +await describe('BootstrapStateUtils', async () => { + let testDir: string + let stateFilePath: string + let configurationsDir: string + + beforeEach(() => { + testDir = join( + tmpdir(), + `bootstrap-state-test-${Date.now().toString()}-${Math.random().toString(36).slice(2)}` + ) + configurationsDir = join(testDir, 'configurations') + stateFilePath = join(testDir, 'state.json') + mkdirSync(configurationsDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDir, { force: true, recursive: true }) + standardCleanup() + }) + + await describe('writeStateFile', async () => { + await it('should write state file with started true', async () => { + // Act + await writeStateFile(stateFilePath, true) + + // Assert + assert.strictEqual(existsSync(stateFilePath), true) + const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as { + started: boolean + version: number + } + assert.strictEqual(content.version, STATE_FILE_VERSION) + assert.strictEqual(content.started, true) + }) + + await it('should write state file with started false', async () => { + // Act + await writeStateFile(stateFilePath, false) + + // Assert + const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as { + started: boolean + version: number + } + assert.strictEqual(content.started, false) + }) + + await it('should atomically overwrite existing state file', async () => { + // Arrange + await writeStateFile(stateFilePath, true) + + // Act + await writeStateFile(stateFilePath, false) + + // Assert + const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as { + started: boolean + version: number + } + assert.strictEqual(content.started, false) + }) + + await it('should create parent directory if it does not exist', async () => { + // Arrange + const deepPath = join(testDir, 'nested', 'dir', 'state.json') + + // Act + await writeStateFile(deepPath, true) + + // Assert + assert.strictEqual(existsSync(deepPath), true) + }) + + await it('should not leave tmp file after successful write', async () => { + // Act + await writeStateFile(stateFilePath, true) + + // Assert + assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false) + }) + + await it('should not throw when target path is rejected by the OS', async () => { + // Arrange + const invalidPath = join(testDir, 'does-not-exist', '\0invalid', 'state.json') + + // Act & Assert: writeStateFile must swallow filesystem errors so callers + // (Bootstrap.start/stop) do not surface persistence failures as fatal errors. + await assert.doesNotReject(async () => { + await writeStateFile(invalidPath, true) + }) + }) + + await it('should clean up tmp file when atomic rename fails', async () => { + // Arrange + mkdirSync(stateFilePath, { recursive: true }) + writeFileSync(join(stateFilePath, 'placeholder'), 'x', 'utf8') + + // Act + await writeStateFile(stateFilePath, true) + + // Assert + assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false) + }) + }) + + await describe('readStateFile', async () => { + await it('should return undefined when file does not exist', () => { + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + }) + + await it('should return parsed state when file is valid', async () => { + // Arrange + await writeStateFile(stateFilePath, true) + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.notStrictEqual(result, undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual + assert.strictEqual(result!.version, STATE_FILE_VERSION) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual + assert.strictEqual(result!.started, true) + }) + + await it('should return undefined and delete file when content is corrupt JSON', () => { + // Arrange + writeFileSync(stateFilePath, '{not valid json', 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should return undefined and quarantine file when schema version is incompatible', () => { + // Arrange + writeFileSync(stateFilePath, JSON.stringify({ started: true, version: 999 }), 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + assert.strictEqual(existsSync(`${stateFilePath}.v999.bak`), true) + }) + + await it('should return undefined and delete file when started field is missing', () => { + // Arrange + writeFileSync(stateFilePath, JSON.stringify({ version: STATE_FILE_VERSION }), 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should return undefined and delete file when version field is missing', () => { + // Arrange + writeFileSync(stateFilePath, JSON.stringify({ started: true }), 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should return undefined and delete file when content is JSON null', () => { + // Arrange + writeFileSync(stateFilePath, 'null', 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should return undefined and delete file when content is a JSON primitive', () => { + // Arrange + writeFileSync(stateFilePath, '42', 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should return undefined and delete file when content is a JSON array', () => { + // Arrange + writeFileSync(stateFilePath, '[]', 'utf8') + + // Act + const result = readStateFile(stateFilePath) + + // Assert + assert.strictEqual(result, undefined) + assert.strictEqual(existsSync(stateFilePath), false) + }) + }) + + await describe('deleteStateFile', async () => { + await it('should delete existing state file', async () => { + // Arrange + await writeStateFile(stateFilePath, true) + + // Act + deleteStateFile(stateFilePath) + + // Assert + assert.strictEqual(existsSync(stateFilePath), false) + }) + + await it('should not throw when file does not exist', () => { + // Act & Assert + assert.doesNotThrow(() => { + deleteStateFile(stateFilePath) + }) + }) + }) + + await describe('reconstructTemplateIndexes', async () => { + const createTemplateStatistics = ( + entries: [string, number][] + ): Map => { + return new Map( + entries.map(([name, configured]) => [ + name, + { added: 0, configured, indexes: new Set(), provisioned: 0, started: 0 }, + ]) + ) + } + + await it('should reconstruct indexes from valid station config files', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 2]]) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }), + 'utf8' + ) + writeFileSync( + join(configurationsDir, 'station2.json'), + JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + const indexes = templateStatistics.get('template-a')?.indexes + assert.notStrictEqual(indexes, undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual + assert.strictEqual(indexes!.size, 2) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual + assert.strictEqual(indexes!.has(1), true) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual + assert.strictEqual(indexes!.has(2), true) + }) + + await it('should skip files for templates not in templateStatistics', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ + stationInfo: { templateIndex: 1, templateName: 'removed-template' }, + }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should skip files missing templateName', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ stationInfo: { templateIndex: 1 } }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should skip files missing templateIndex', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ stationInfo: { templateName: 'template-a' } }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should skip corrupt JSON files', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync(join(configurationsDir, 'corrupt.json'), '{not valid}', 'utf8') + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should skip non-JSON files', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync(join(configurationsDir, 'readme.txt'), 'not a config', 'utf8') + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should handle empty configurations directory', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should handle non-existent configurations directory', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + const nonExistentDir = join(testDir, 'nonexistent') + + // Act + reconstructTemplateIndexes(nonExistentDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should reconstruct indexes for multiple templates', () => { + // Arrange + const templateStatistics = createTemplateStatistics([ + ['template-a', 2], + ['template-b', 1], + ]) + writeFileSync( + join(configurationsDir, 'stationA1.json'), + JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }), + 'utf8' + ) + writeFileSync( + join(configurationsDir, 'stationB1.json'), + JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-b' } }), + 'utf8' + ) + writeFileSync( + join(configurationsDir, 'stationA2.json'), + JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 2) + assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true) + assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(2), true) + assert.strictEqual(templateStatistics.get('template-b')?.indexes.size, 1) + assert.strictEqual(templateStatistics.get('template-b')?.indexes.has(1), true) + }) + + await it('should handle files missing stationInfo entirely', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ connectorsStatus: [] }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0) + }) + + await it('should skip dot-prefixed metadata files', () => { + // Arrange + const templateStatistics = createTemplateStatistics([['template-a', 1]]) + writeFileSync( + join(configurationsDir, '.simulator-state.json'), + JSON.stringify({ started: true, version: 1 }), + 'utf8' + ) + writeFileSync( + join(configurationsDir, 'station1.json'), + JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }), + 'utf8' + ) + + // Act + reconstructTemplateIndexes(configurationsDir, templateStatistics) + + // Assert + assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 1) + assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true) + }) + }) +}) -- 2.43.0