@fsai-flow/core 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +22 -2
  2. package/dist/README.md +22 -2
  3. package/dist/package.json +14 -4
  4. package/dist/src/index.d.ts +1 -0
  5. package/dist/src/index.js +1 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/lib/ActiveWebhooks.js +4 -4
  8. package/dist/src/lib/ActiveWebhooks.js.map +1 -1
  9. package/dist/src/lib/ActiveWorkflows.d.ts +31 -2
  10. package/dist/src/lib/ActiveWorkflows.js +302 -90
  11. package/dist/src/lib/ActiveWorkflows.js.map +1 -1
  12. package/dist/src/lib/LoadNodeParameterOptions.js +3 -3
  13. package/dist/src/lib/LoadNodeParameterOptions.js.map +1 -1
  14. package/dist/src/lib/NodeExecuteFunctions.d.ts +1 -1
  15. package/dist/src/lib/NodeExecuteFunctions.js +2 -6
  16. package/dist/src/lib/NodeExecuteFunctions.js.map +1 -1
  17. package/dist/src/lib/RedisLeaderElectionManager.d.ts +53 -0
  18. package/dist/src/lib/RedisLeaderElectionManager.js +294 -0
  19. package/dist/src/lib/RedisLeaderElectionManager.js.map +1 -0
  20. package/dist/src/lib/UserSettings.d.ts +1 -1
  21. package/dist/src/lib/UserSettings.js +10 -10
  22. package/dist/src/lib/UserSettings.js.map +1 -1
  23. package/dist/src/lib/WorkflowExecute.js +21 -2
  24. package/dist/src/lib/WorkflowExecute.js.map +1 -1
  25. package/package.json +15 -5
  26. package/src/index.ts +1 -0
  27. package/src/lib/ActiveWebhooks.ts +1 -1
  28. package/src/lib/ActiveWorkflows.ts +327 -56
  29. package/src/lib/LoadNodeParameterOptions.ts +1 -1
  30. package/src/lib/NodeExecuteFunctions.ts +1 -5
  31. package/src/lib/RedisLeaderElectionManager.ts +334 -0
  32. package/src/lib/UserSettings.ts +1 -1
  33. package/src/lib/WorkflowExecute.ts +23 -3
  34. package/tsconfig.json +0 -1
@@ -0,0 +1,334 @@
1
+ import Redis, { RedisOptions } from 'ioredis';
2
+ import { LoggerProxy as Logger } from '@fsai-flow/workflow';
3
+
4
+ export interface RedisLeaderElectionCallbacks {
5
+ onStartedLeading: () => void;
6
+ onStoppedLeading: () => void;
7
+ onNewLeader?: (identity: string) => void;
8
+ }
9
+
10
+ export class RedisLeaderElectionManager {
11
+ private redis: Redis;
12
+ private isLeader = false;
13
+ private lockKey: string;
14
+ private nodeId: string;
15
+ private lockTTL: number = 30000; // 30 seconds in milliseconds
16
+ private renewalInterval: number = 10000; // 10 seconds in milliseconds
17
+ private renewalTimer?: NodeJS.Timeout;
18
+ private callbacks: RedisLeaderElectionCallbacks;
19
+
20
+ constructor(
21
+ lockKey: string,
22
+ redisConfig: string | RedisOptions,
23
+ callbacks: RedisLeaderElectionCallbacks
24
+ ) {
25
+ this.lockKey = `leader:${lockKey}`;
26
+ this.nodeId = this.generateNodeId();
27
+ this.callbacks = callbacks;
28
+
29
+ // Initialize Redis connection
30
+ if (typeof redisConfig === 'string') {
31
+ this.redis = new Redis(redisConfig);
32
+ } else {
33
+ this.redis = new Redis(redisConfig);
34
+ }
35
+
36
+ // Handle Redis connection events
37
+ this.redis.on('connect', () => {
38
+ Logger.info(`Redis connected for leader election: ${this.lockKey}`);
39
+ });
40
+
41
+ this.redis.on('ready', () => {
42
+ Logger.info(`Redis ready for leader election: ${this.lockKey}`);
43
+ });
44
+
45
+ this.redis.on('error', (error: any) => {
46
+ Logger.error(`Redis connection error for leader election: ${error.message}`);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Wait for Redis connection to be ready
52
+ */
53
+ private async waitForRedisConnection(): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ if (this.redis.status === 'ready') {
56
+ Logger.debug(`Redis already ready for ${this.lockKey}`);
57
+ resolve();
58
+ return;
59
+ }
60
+
61
+ let isSettled = false;
62
+ let timeoutId: NodeJS.Timeout | null = null;
63
+
64
+ // Cleanup function to prevent memory leaks
65
+ const cleanup = () => {
66
+ if (isSettled) return; // Already cleaned up
67
+ isSettled = true;
68
+
69
+ // Clear timeout safely
70
+ if (timeoutId !== null) {
71
+ clearTimeout(timeoutId);
72
+ timeoutId = null;
73
+ }
74
+
75
+ // Remove event listeners safely
76
+ try {
77
+ this.redis.off('ready', onReady);
78
+ this.redis.off('error', onError);
79
+ } catch (error) {
80
+ // Ignore cleanup errors - Redis connection might be destroyed
81
+ Logger.debug(`Cleanup warning for ${this.lockKey}: ${error}`);
82
+ }
83
+ };
84
+
85
+ const onReady = () => {
86
+ cleanup();
87
+ Logger.debug(`Redis connection established for ${this.lockKey}`);
88
+ resolve();
89
+ };
90
+
91
+ const onError = (error: Error) => {
92
+ cleanup();
93
+ Logger.error(`Redis connection failed for ${this.lockKey}: ${error.message}`);
94
+ reject(error);
95
+ };
96
+
97
+ const onTimeout = () => {
98
+ cleanup();
99
+ reject(new Error(`Redis connection timeout after 10 seconds for ${this.lockKey}`));
100
+ };
101
+
102
+ // Set up timeout
103
+ timeoutId = setTimeout(onTimeout, 10000); // 10 second timeout
104
+
105
+ // Set up event listeners
106
+ this.redis.once('ready', onReady);
107
+ this.redis.once('error', onError);
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Start the leader election process
113
+ */
114
+ async start(): Promise<void> {
115
+ Logger.info(`🚀 Starting Redis leader election for ${this.nodeId} on key ${this.lockKey}`);
116
+ Logger.info(`🔧 Leader election config: TTL=${this.lockTTL}ms, RenewalInterval=${this.renewalInterval}ms`);
117
+
118
+ // Wait for Redis connection to be ready
119
+ Logger.info(`⏳ ${this.nodeId} waiting for Redis connection...`);
120
+ await this.waitForRedisConnection();
121
+ Logger.info(`✅ Redis connection ready for ${this.nodeId}`);
122
+
123
+ // Try to acquire leadership immediately
124
+ Logger.info(`🎯 ${this.nodeId} making initial leadership attempt...`);
125
+ await this.tryAcquireLeadership();
126
+
127
+ // Start the renewal/retry loop
128
+ this.startRenewalLoop();
129
+
130
+ Logger.info(`✅ Leader election process started for ${this.nodeId}`);
131
+ }
132
+
133
+ /**
134
+ * Stop the leader election process
135
+ */
136
+ async stop(): Promise<void> {
137
+ Logger.info(`🛑 Stopping Redis leader election for ${this.nodeId}`);
138
+
139
+ // Stop renewal timer
140
+ if (this.renewalTimer) {
141
+ Logger.debug(`⏹️ Stopping leadership monitoring loop for ${this.nodeId}`);
142
+ clearInterval(this.renewalTimer as NodeJS.Timeout);
143
+ this.renewalTimer = undefined;
144
+ }
145
+
146
+ // Release leadership if we have it
147
+ if (this.isLeader) {
148
+ Logger.info(`👋 ${this.nodeId} releasing leadership voluntarily for ${this.lockKey}`);
149
+ await this.releaseLock();
150
+ }
151
+
152
+ // Close Redis connection
153
+ Logger.debug(`🔌 Disconnecting Redis for ${this.nodeId}`);
154
+ this.redis.disconnect();
155
+
156
+ Logger.info(`✅ Leader election stopped for ${this.nodeId}`);
157
+ }
158
+
159
+ /**
160
+ * Check if this node is currently the leader
161
+ */
162
+ getIsLeader(): boolean {
163
+ return this.isLeader;
164
+ }
165
+
166
+ /**
167
+ * Try to acquire leadership
168
+ */
169
+ private async tryAcquireLeadership(): Promise<boolean> {
170
+ try {
171
+ // Check if Redis connection is ready before attempting operations
172
+ if (this.redis.status !== 'ready') {
173
+ Logger.warn(`Redis not ready for ${this.nodeId}, current status: ${this.redis.status}`);
174
+ return false;
175
+ }
176
+
177
+ Logger.debug(`Node ${this.nodeId} attempting to acquire leadership for ${this.lockKey}`);
178
+
179
+ // Use SET with NX (not exists) and PX (expire in milliseconds)
180
+ const result = await this.redis.set(
181
+ this.lockKey,
182
+ this.nodeId,
183
+ 'PX', this.lockTTL,
184
+ 'NX'
185
+ );
186
+
187
+ if (result === 'OK') {
188
+ // Successfully acquired leadership
189
+ if (!this.isLeader) {
190
+ this.isLeader = true;
191
+ Logger.info(`🏆 Node ${this.nodeId} ACQUIRED LEADERSHIP for ${this.lockKey} (TTL: ${this.lockTTL}ms)`);
192
+ this.callbacks.onStartedLeading();
193
+ } else {
194
+ Logger.debug(`Node ${this.nodeId} renewed leadership lock for ${this.lockKey}`);
195
+ }
196
+ return true;
197
+ } else {
198
+ // Failed to acquire leadership - check who is the current leader
199
+ const currentLeader = await this.redis.get(this.lockKey);
200
+ const lockTTL = await this.redis.pttl(this.lockKey);
201
+
202
+ if (currentLeader) {
203
+ if (currentLeader !== this.nodeId) {
204
+ Logger.debug(`👑 Node ${this.nodeId} sees ${currentLeader} is the current leader for ${this.lockKey} (TTL: ${lockTTL}ms)`);
205
+ if (this.callbacks.onNewLeader) {
206
+ this.callbacks.onNewLeader(currentLeader);
207
+ }
208
+ }
209
+ } else {
210
+ Logger.debug(`Node ${this.nodeId} found no current leader for ${this.lockKey}, but failed to acquire lock`);
211
+ }
212
+
213
+ // Lost leadership if we previously had it
214
+ if (this.isLeader) {
215
+ this.isLeader = false;
216
+ Logger.info(`📉 Node ${this.nodeId} LOST LEADERSHIP for ${this.lockKey}`);
217
+ this.callbacks.onStoppedLeading();
218
+ }
219
+ return false;
220
+ }
221
+ } catch (error: any) {
222
+ Logger.error(`❌ Error during leader election for ${this.nodeId}: ${error.message}`);
223
+
224
+ // If we had leadership and there's an error, assume we lost it
225
+ if (this.isLeader) {
226
+ this.isLeader = false;
227
+ Logger.info(`💥 Node ${this.nodeId} LOST LEADERSHIP due to error: ${this.lockKey}`);
228
+ this.callbacks.onStoppedLeading();
229
+ }
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Renew the leadership lock
236
+ */
237
+ private async renewLock(): Promise<boolean> {
238
+ try {
239
+ // Check if Redis connection is ready before attempting operations
240
+ if (this.redis.status !== 'ready') {
241
+ Logger.warn(`Redis not ready for ${this.nodeId} renewal, current status: ${this.redis.status}`);
242
+ return false;
243
+ }
244
+
245
+ Logger.debug(`🔄 Node ${this.nodeId} attempting to renew leadership for ${this.lockKey}`);
246
+
247
+ // Use Lua script to atomically check ownership and renew
248
+ const script = `
249
+ if redis.call("get", KEYS[1]) == ARGV[1] then
250
+ return redis.call("pexpire", KEYS[1], ARGV[2])
251
+ else
252
+ return 0
253
+ end
254
+ `;
255
+
256
+ const result = await this.redis.eval(
257
+ script,
258
+ 1,
259
+ this.lockKey,
260
+ this.nodeId,
261
+ this.lockTTL.toString()
262
+ );
263
+
264
+ if (result === 1) {
265
+ Logger.debug(`✅ Node ${this.nodeId} successfully renewed leadership for ${this.lockKey} (TTL: ${this.lockTTL}ms)`);
266
+ return true;
267
+ } else {
268
+ Logger.warn(`⚠️ Node ${this.nodeId} failed to renew leadership for ${this.lockKey} - lock no longer owned by this node`);
269
+ return false;
270
+ }
271
+ } catch (error: any) {
272
+ Logger.error(`❌ Error renewing leadership lock for ${this.nodeId}: ${error.message}`);
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Release the leadership lock
279
+ */
280
+ private async releaseLock(): Promise<void> {
281
+ try {
282
+ // Use Lua script to atomically check ownership and delete
283
+ const script = `
284
+ if redis.call("get", KEYS[1]) == ARGV[1] then
285
+ return redis.call("del", KEYS[1])
286
+ else
287
+ return 0
288
+ end
289
+ `;
290
+
291
+ await this.redis.eval(script, 1, this.lockKey, this.nodeId);
292
+ Logger.info(`Node ${this.nodeId} released leadership lock for ${this.lockKey}`);
293
+ } catch (error: any) {
294
+ Logger.error(`Error releasing lock for ${this.nodeId}: ${error.message}`);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Start the renewal/retry loop
300
+ */
301
+ private startRenewalLoop(): void {
302
+ Logger.info(`Starting leadership monitoring loop for ${this.nodeId} (check every ${this.renewalInterval}ms)`);
303
+
304
+ this.renewalTimer = setInterval(async () => {
305
+ if (this.isLeader) {
306
+ // Leader: Try to renew the lock
307
+ Logger.debug(`⏰ [LEADER] Node ${this.nodeId} periodic leadership renewal check`);
308
+ const renewed = await this.renewLock();
309
+ if (!renewed) {
310
+ // Failed to renew - we lost leadership
311
+ this.isLeader = false;
312
+ Logger.info(`💔 [LEADER] Node ${this.nodeId} LOST LEADERSHIP (failed to renew) for ${this.lockKey}`);
313
+ this.callbacks.onStoppedLeading();
314
+ } else {
315
+ // Successfully renewed - keeping leadership
316
+ //Logger.info(`🔒 [LEADER] Node ${this.nodeId} RENEWED LEADERSHIP - staying active for ${this.lockKey}`);
317
+ }
318
+ } else {
319
+ // Follower: Try to acquire leadership
320
+ Logger.debug(`⏰ [FOLLOWER] Node ${this.nodeId} checking for available leadership`);
321
+ await this.tryAcquireLeadership();
322
+ }
323
+ }, this.renewalInterval);
324
+ }
325
+
326
+ /**
327
+ * Generate a unique node identifier
328
+ */
329
+ private generateNodeId(): string {
330
+ return process.env['POD_NAME'] ||
331
+ process.env['HOSTNAME'] ||
332
+ `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
333
+ }
334
+ }
@@ -13,7 +13,7 @@ import {
13
13
  USER_FOLDER_ENV_OVERWRITE,
14
14
  USER_SETTINGS_FILE_NAME,
15
15
  USER_SETTINGS_SUBFOLDER,
16
- } from '../../src';
16
+ } from '..';
17
17
 
18
18
  // eslint-disable-next-line @typescript-eslint/no-var-requires
19
19
  const { promisify } = require('util');
@@ -1,4 +1,3 @@
1
-
2
1
  import PCancelable = require('p-cancelable');
3
2
 
4
3
  import {
@@ -24,7 +23,7 @@ import {
24
23
  WorkflowOperationError,
25
24
  } from '@fsai-flow/workflow';
26
25
  import { get } from 'lodash';
27
- import { NodeExecuteFunctions } from '../../src';
26
+ import { NodeExecuteFunctions } from '..';
28
27
 
29
28
  export class WorkflowExecute {
30
29
  runExecutionData: IRunExecutionData;
@@ -837,7 +836,7 @@ export class WorkflowExecute {
837
836
  }
838
837
 
839
838
  break;
840
- } catch (error) {
839
+ } catch (error: any) {
841
840
  this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name;
842
841
 
843
842
  executionError = {
@@ -846,6 +845,27 @@ export class WorkflowExecute {
846
845
  stack: (error as NodeOperationError | NodeApiError).stack,
847
846
  };
848
847
 
848
+ // Check if it's a critical database connection error that should fail immediately
849
+ const isCriticalConnectionError = error.message?.includes('too many clients') ||
850
+ error.message?.includes('Connection terminated') ||
851
+ error.message?.includes('connection is closed') ||
852
+ error.message?.includes('ECONNRESET') ||
853
+ error.message?.includes('ENOTFOUND') ||
854
+ error.code === 'ECONNRESET' ||
855
+ error.code === 'ENOTFOUND';
856
+
857
+ if (isCriticalConnectionError) {
858
+ Logger.error(`Critical database connection error in node "${executionNode.name}": ${error.message}`, {
859
+ node: executionNode.name,
860
+ workflowId: workflow.id,
861
+ error: error.message
862
+ });
863
+
864
+ // For critical connection errors, break out of retry loop immediately
865
+ // This will cause the workflow to fail on this node
866
+ break;
867
+ }
868
+
849
869
  Logger.debug(`Running node "${executionNode.name}" finished with error`, {
850
870
  node: executionNode.name,
851
871
  workflowId: workflow.id,
package/tsconfig.json CHANGED
@@ -1,5 +1,4 @@
1
1
  {
2
- "extends": "../../tsconfig.base.json",
3
2
  "compilerOptions": {
4
3
  "baseUrl": ".",
5
4
  "paths": {