Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
@@ -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 <shangjianlou@gmail.com>

1.6.1 - 01/09/2022
==================

Expand Down
133 changes: 107 additions & 26 deletions lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
Expand All @@ -75,32 +78,40 @@ 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);
node.error(e);
}
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();
}
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down