diff --git a/History.md b/History.md index 9e76a51..9d6e9be 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,14 @@ +1.6.2 - 11/13/2024 +================== + + * Fixed state persistence issue: State values now correctly load from filesystem on Node-RED restart (@shangjianlou) + * Fixed global.json persistence: Removed duplicate state storage in global.json to avoid data inconsistency (@shangjianlou) + * Improved initialization order: Filesystem is now the primary source of truth, global context is runtime cache only (@shangjianlou) + * Added deep copy mechanism for proper object serialization (@shangjianlou) + * Fixed async initialization issue by using synchronous file read during constructor (@shangjianlou) + + Contributors: shangjianlou + 1.6.1 - 01/09/2022 ================== diff --git a/lib/state.js b/lib/state.js index 99cedef..29c63c2 100644 --- a/lib/state.js +++ b/lib/state.js @@ -46,6 +46,9 @@ const fs = require('fs') const { promisify } = require('util') const readFile = promisify(fs.readFile) const writeFile = promisify(fs.writeFile) +// Synchronous file operations for initialization +const readFileSync = fs.readFileSync +const existsSync = fs.existsSync const mkdirp = require('mkdirp'); const isValidVarName = require('is-valid-var-name'); const convertUnits = require('convert-units'); @@ -64,7 +67,7 @@ class stateNode { constructor(config) { let node = this; RED.nodes.createNode(node, config); - let globalContext = node.context().global; + node.globalContext = node.context().global; node.id = config.id; node.name = config.name; node.config = config; @@ -75,7 +78,7 @@ class stateNode { node.timestamp = 0; node.history = []; - let stateDir = globalContext.get('sharedStateDir') || './shared-state'; + let stateDir = node.globalContext.get('sharedStateDir') || './shared-state'; try { mkdirp(stateDir); } catch (e) { node.error('Unable to create storage directory: ' + stateDir); @@ -83,24 +86,32 @@ class stateNode { } node.stateFile = stateDir + '/' + node.name; - // Initialize from global context if available - if (globalContext.keys().indexOf('state') < 0) { - globalContext.set('state', {}); - } - node.globalState = globalContext.get('state'); - let thisState = node.globalState[node.name]; - if (thisState) { - node.initFromObj(thisState); - } - - // Initialize from the filesystem on Node-RED reboot - if (!node.initialized) { - node.initFromFS(); + // Initialize global context container + if (node.globalContext.keys().indexOf('state') < 0) { + node.globalContext.set('state', {}); } + node.globalState = node.globalContext.get('state'); - // Set pre-initialized global state - if (!node.initialized) { - node.globalState[node.name] = node.exposedState(); + // Priority 1: Initialize from the filesystem (reliable persistent storage) + // This should be the primary source of truth after Node-RED restart + // Use synchronous read for initialization to ensure it completes before constructor returns + node.initFromFSSync(); + + // If loaded from filesystem, update global context with the loaded state + // This ensures global context is in sync with filesystem (the source of truth) + if (node.initialized) { + node.updateGlobalContext(); + } else { + // Priority 2: If filesystem load failed, try global context (runtime cache) + // This is only a fallback for runtime state sharing during the same session + let thisState = node.globalState[node.name]; + if (thisState) { + node.initFromObj(thisState); + } else { + // No state found anywhere, initialize with defaults + // Set pre-initialized global state (for runtime sharing) + node.updateGlobalContext(); + } } } @@ -116,6 +127,33 @@ class stateNode { }; } + // Produce a deep copy of state for global context storage + // This ensures proper serialization and avoids reference issues + exposedStateDeepCopy() { + let node = this; + return JSON.parse(JSON.stringify({ + value: node.value, + prev: node.prev, + timestamp: node.timestamp, + history: node.history, + config: node.config, + })); + } + + // Update global context for runtime sharing only (not persisted to global.json) + // This keeps state in memory for fast access during the same Node-RED session + // File system is the only persistent storage to avoid duplicate data + updateGlobalContext() { + let node = this; + // Update the state object in memory only + // We don't call set() here to avoid persisting to global.json + // This keeps global.json clean and avoids duplicate storage + // File system is the single source of truth for persistence + node.globalState[node.name] = node.exposedStateDeepCopy(); + // Note: Direct modification of objects from get() doesn't trigger persistence + // This is intentional - we only want file system persistence + } + // Update the state async update(newState, fromMsg) { let node = this; @@ -144,12 +182,38 @@ class stateNode { node.history[0] !== undefined && node.history[0].ts !== undefined) // @colincoder Initially this is undefined so deal with it prev_ts = node.history[0].ts; - if (node.config.historyCount > 0 && node.timestamp-prev_ts >= parseInt(node.config.saveInterval, 10)) { + + // Update history if historyCount > 0 + if (node.config.historyCount > 0) { node.history.splice(0,0,{val:node.value, ts:node.timestamp}); node.trimHistory(); + } + + // Save to filesystem based on saveInterval + // If saveInterval is 0, always save immediately + // If saveInterval > 0, only save if enough time has passed since last save + // This ensures state persistence while allowing performance optimization + let saveInterval = parseInt(node.config.saveInterval, 10) || 0; + let shouldSave = false; + + if (saveInterval === 0) { + // Always save if saveInterval is 0 + shouldSave = true; + } else if (prev_ts === 0) { + // Always save on first update (no previous timestamp) + shouldSave = true; + } else { + // Save if saveInterval has passed + shouldSave = (node.timestamp - prev_ts >= saveInterval); + } + + if (shouldSave) { await writeFile(node.stateFile, JSON.stringify(node.exposedState())); } - node.globalState[node.name] = node.exposedState(); + + // Update global context for runtime sharing only (not persisted to global.json) + // File system is the single source of truth for persistence + node.updateGlobalContext(); node.emit('change', node.exposedState()); } catch (e) { @@ -282,16 +346,33 @@ class stateNode { // Initialize from an external state object initFromObj(external) { let node = this; - node.value = external.value, - node.prev = external.prev, - node.timestamp = external.timestamp, - node.history = external.history, - node.globalState[node.name] = node.exposedState(); + // Use deep copy to avoid reference issues when loading from global context + let externalCopy = JSON.parse(JSON.stringify(external)); + node.value = externalCopy.value; + node.prev = externalCopy.prev; + node.timestamp = externalCopy.timestamp; + node.history = externalCopy.history || []; + node.updateGlobalContext(); node.emit('init', node.exposedState()); node.initialized = true; } - // Initialize state from the filesystem + // Initialize state from the filesystem (synchronous version for constructor) + initFromFSSync() { + let node = this; + try { + if (existsSync(node.stateFile)) { + let file = readFileSync(node.stateFile, 'utf8'); + node.initFromObj(JSON.parse(file)); + } + } catch (e) { + // State file doesn't exist or can't be read + // This is normal for first-time initialization + return; + } + } + + // Initialize state from the filesystem (async version for runtime use) async initFromFS() { let node = this; try { diff --git a/package.json b/package.json index 641e59c..83103b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,13 @@ { "name": "node-red-contrib-state", "author": "Loren West - github.com/lorenwest", - "version": "1.6.1", + "contributors": [ + { + "name": "shangjianlou", + "email": "shangjianlou@gmail.com" + } + ], + "version": "1.6.2", "description": "Shared state with persistence, notification, and history", "license": "MIT", "homepage": "https://github.com/lorenwest/node-red-contrib-state",