plugin-cluster-manager 1.1.16 → 1.1.17

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 (49) hide show
  1. package/dist/client/index.js +1 -1
  2. package/dist/client-v2/376.cd1d86e85a50088e.js +10 -0
  3. package/dist/client-v2/index.js +1 -1
  4. package/dist/externalVersion.js +6 -6
  5. package/dist/locale/en-US.json +16 -6
  6. package/dist/locale/vi-VN.json +16 -6
  7. package/dist/locale/zh-CN.json +16 -6
  8. package/dist/server/actions/cluster-nodes.js +43 -10
  9. package/dist/server/actions/doctor.js +69 -4
  10. package/dist/server/actions/event-queue-monitor.js +33 -3
  11. package/dist/server/actions/orchestrator.js +48 -32
  12. package/dist/server/actions/queue-mappings.js +1 -0
  13. package/dist/server/actions/tasks.js +8 -8
  14. package/dist/server/adapters/redis-event-queue-adapter.js +188 -0
  15. package/dist/server/adapters/redis-node-registry.js +42 -3
  16. package/dist/server/collections/orchestrator-stacks.js +6 -0
  17. package/dist/server/collections/worker-queue-mappings.js +1 -1
  18. package/dist/server/orchestrator/PackageManager.js +47 -12
  19. package/dist/server/plugin.js +36 -5
  20. package/dist/server/queue-scanner.js +54 -34
  21. package/dist/server/utils/node.js +3 -7
  22. package/dist/server/utils/redis.js +37 -9
  23. package/dist/shared/worker-processes.js +233 -0
  24. package/package.json +1 -1
  25. package/src/client/ClusterNodes.tsx +76 -10
  26. package/src/client/ContainerOrchestrator.tsx +146 -8
  27. package/src/client/QueueAssignment.tsx +10 -2
  28. package/src/locale/en-US.json +16 -6
  29. package/src/locale/vi-VN.json +16 -6
  30. package/src/locale/zh-CN.json +16 -6
  31. package/src/server/__tests__/worker-processes.test.ts +42 -0
  32. package/src/server/actions/cluster-nodes.ts +43 -8
  33. package/src/server/actions/doctor.ts +77 -0
  34. package/src/server/actions/event-queue-monitor.ts +34 -3
  35. package/src/server/actions/orchestrator.ts +58 -38
  36. package/src/server/actions/queue-mappings.ts +1 -0
  37. package/src/server/actions/tasks.ts +142 -142
  38. package/src/server/adapters/redis-event-queue-adapter.ts +189 -0
  39. package/src/server/adapters/redis-node-registry.ts +44 -4
  40. package/src/server/collections/orchestrator-stacks.ts +6 -0
  41. package/src/server/collections/worker-queue-mappings.ts +3 -3
  42. package/src/server/orchestrator/PackageManager.ts +48 -11
  43. package/src/server/orchestrator/types.ts +5 -4
  44. package/src/server/plugin.ts +40 -6
  45. package/src/server/queue-scanner.ts +65 -51
  46. package/src/server/utils/node.ts +3 -10
  47. package/src/server/utils/redis.ts +39 -4
  48. package/src/shared/worker-processes.ts +216 -0
  49. package/dist/client-v2/914.c0bce51908fd81d7.js +0 -10
@@ -0,0 +1,189 @@
1
+ import type { Application, IEventQueueAdapter, QueueEventOptions, QueueMessageOptions } from '@nocobase/server';
2
+ import { createClient } from 'redis';
3
+ import { randomUUID } from 'crypto';
4
+ import { workerModeServesProcess } from '../../shared/worker-processes';
5
+
6
+ const DEFAULT_INTERVAL_MS = 250;
7
+ const DEFAULT_CONCURRENCY = 1;
8
+ const DEFAULT_ACK_TIMEOUT_MS = 15_000;
9
+ const REDIS_QUEUE_PREFIX = 'nocobase:event-queue';
10
+
11
+ type RedisClient = ReturnType<typeof createClient>;
12
+
13
+ type StoredMessage = {
14
+ id: string;
15
+ content: unknown;
16
+ options?: QueueMessageOptions;
17
+ };
18
+
19
+ function sleep(ms: number) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ function createTimeoutSignal(timeout: number): AbortSignal {
24
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
25
+ return AbortSignal.timeout(timeout);
26
+ }
27
+ const controller = new AbortController();
28
+ setTimeout(() => controller.abort(), timeout);
29
+ return controller.signal;
30
+ }
31
+
32
+ export class RedisEventQueueAdapter implements IEventQueueAdapter {
33
+ private client: RedisClient;
34
+ private connected = false;
35
+ private events = new Map<string, QueueEventOptions>();
36
+ private reading = new Map<string, Set<Promise<void>>>();
37
+ private consuming = new Set<string>();
38
+
39
+ constructor(
40
+ private readonly options: {
41
+ app: Application;
42
+ url: string;
43
+ },
44
+ ) {
45
+ this.client = createClient({ url: options.url });
46
+ this.client.on('error', (error) => {
47
+ this.options.app.logger.error(`[RedisEventQueueAdapter] Redis error: ${error.message}`);
48
+ });
49
+ }
50
+
51
+ isConnected(): boolean {
52
+ return this.connected;
53
+ }
54
+
55
+ async connect(): Promise<void> {
56
+ if (this.connected) return;
57
+ if (!this.client.isOpen) {
58
+ await this.client.connect();
59
+ }
60
+ this.connected = true;
61
+ for (const channel of this.events.keys()) {
62
+ this.startConsumer(channel);
63
+ }
64
+ this.options.app.logger.info('[RedisEventQueueAdapter] Connected');
65
+ }
66
+
67
+ async close(): Promise<void> {
68
+ this.connected = false;
69
+ const batches = Array.from(this.reading.values()).flatMap((items) => Array.from(items));
70
+ if (batches.length) {
71
+ await Promise.allSettled(batches);
72
+ }
73
+ if (this.client.isOpen) {
74
+ await this.client.quit().catch(() => this.client.disconnect());
75
+ }
76
+ this.options.app.logger.info('[RedisEventQueueAdapter] Closed');
77
+ }
78
+
79
+ subscribe(channel: string, event: QueueEventOptions): void {
80
+ this.events.set(channel, event);
81
+ if (this.connected) {
82
+ this.startConsumer(channel);
83
+ }
84
+ }
85
+
86
+ unsubscribe(channel: string): void {
87
+ this.events.delete(channel);
88
+ }
89
+
90
+ async publish(channel: string, content: unknown, options: QueueMessageOptions = {}): Promise<void> {
91
+ if (!this.connected) {
92
+ throw new Error('redis event queue is not connected');
93
+ }
94
+ const message: StoredMessage = {
95
+ id: randomUUID(),
96
+ content,
97
+ options: {
98
+ ...options,
99
+ timestamp: Date.now(),
100
+ },
101
+ };
102
+ await this.client.rPush(this.getQueueKey(channel), JSON.stringify(message));
103
+ }
104
+
105
+ private getQueueKey(channel: string) {
106
+ return `${REDIS_QUEUE_PREFIX}:${channel}`;
107
+ }
108
+
109
+ private startConsumer(channel: string) {
110
+ if (this.consuming.has(channel)) return;
111
+ this.consuming.add(channel);
112
+ return this.consume(channel)
113
+ .catch((error) => {
114
+ this.options.app.logger.error(`[RedisEventQueueAdapter] Consumer failed for ${channel}: ${error.message}`);
115
+ })
116
+ .finally(() => {
117
+ this.consuming.delete(channel);
118
+ if (this.connected && this.events.has(channel)) {
119
+ this.startConsumer(channel);
120
+ }
121
+ });
122
+ }
123
+
124
+ private async consume(channel: string) {
125
+ while (this.connected && this.events.has(channel)) {
126
+ const event = this.events.get(channel);
127
+ if (event && this.canProcess(channel, event)) {
128
+ this.read(channel, event);
129
+ }
130
+ await sleep(event?.interval || DEFAULT_INTERVAL_MS);
131
+ }
132
+ }
133
+
134
+ private canProcess(channel: string, event: QueueEventOptions) {
135
+ if (!workerModeServesProcess(process.env.WORKER_MODE, channel)) {
136
+ return false;
137
+ }
138
+ return event.idle();
139
+ }
140
+
141
+ private read(channel: string, event: QueueEventOptions) {
142
+ const active = this.reading.get(channel) || new Set<Promise<void>>();
143
+ this.reading.set(channel, active);
144
+
145
+ const available = (event.concurrency || DEFAULT_CONCURRENCY) - active.size;
146
+ for (let index = 0; index < available; index += 1) {
147
+ const promise = this.readOne(channel, event).finally(() => active.delete(promise));
148
+ active.add(promise);
149
+ }
150
+ }
151
+
152
+ private async readOne(channel: string, event: QueueEventOptions) {
153
+ const raw = await this.client.lPop(this.getQueueKey(channel));
154
+ if (!raw) return;
155
+
156
+ let message: StoredMessage;
157
+ try {
158
+ message = JSON.parse(raw);
159
+ } catch (error) {
160
+ this.options.app.logger.warn(`[RedisEventQueueAdapter] Dropped invalid message from ${channel}`, error);
161
+ return;
162
+ }
163
+
164
+ await this.process(channel, event, message);
165
+ }
166
+
167
+ private async process(channel: string, event: QueueEventOptions, message: StoredMessage) {
168
+ const { timeout = DEFAULT_ACK_TIMEOUT_MS, maxRetries = 0, retried = 0 } = message.options || {};
169
+ try {
170
+ await event.process(message.content, {
171
+ id: message.id,
172
+ retried,
173
+ signal: createTimeoutSignal(timeout),
174
+ queueOptions: message.options,
175
+ });
176
+ } catch (error) {
177
+ if (maxRetries > 0 && retried < maxRetries) {
178
+ await this.publish(channel, message.content, {
179
+ ...message.options,
180
+ timeout,
181
+ maxRetries,
182
+ retried: retried + 1,
183
+ });
184
+ return;
185
+ }
186
+ this.options.app.logger.error(`[RedisEventQueueAdapter] Message failed on ${channel}`, error);
187
+ }
188
+ }
189
+ }
@@ -1,5 +1,5 @@
1
1
  import os from 'os';
2
- import { scanKeys, getRedisClient } from '../utils/redis';
2
+ import { scanKeys, getRedisClient, isClusterRedisConfigured } from '../utils/redis';
3
3
  import { getLocalNodeId } from '../utils/node';
4
4
 
5
5
  export class RedisNodeRegistry {
@@ -7,6 +7,10 @@ export class RedisNodeRegistry {
7
7
  private readonly ttlSecs = 30; // 30 seconds TTL
8
8
  private readonly intervalMs = 10000; // Heartbeat every 10 seconds
9
9
  private readonly keyPrefix = 'cluster-manager:nodes:';
10
+ private warnedMissingRedis = false;
11
+ private lastHeartbeatAt: number | null = null;
12
+ private lastHeartbeatError: string | null = null;
13
+ private lastReadError: string | null = null;
10
14
 
11
15
  constructor(private app: any) {}
12
16
 
@@ -33,7 +37,16 @@ export class RedisNodeRegistry {
33
37
 
34
38
  private async heartbeat() {
35
39
  const redis = getRedisClient(this.app);
36
- if (!redis) return;
40
+ if (!redis) {
41
+ this.lastHeartbeatError = 'Redis is not configured for cluster node discovery';
42
+ if (!this.warnedMissingRedis) {
43
+ this.warnedMissingRedis = true;
44
+ this.app.logger.warn(
45
+ '[RedisNodeRegistry] Redis is not configured; Cluster Nodes can only show the local fallback node.',
46
+ );
47
+ }
48
+ return;
49
+ }
37
50
 
38
51
  // Unique identifier combining hostname, port, pid, mode, and appName to handle multiple workers on the same host
39
52
  const port = process.env.APP_PORT || 'unknown';
@@ -69,6 +82,8 @@ export class RedisNodeRegistry {
69
82
  arch: process.arch,
70
83
  uptime: process.uptime(),
71
84
  workerMode: mode,
85
+ appRole: process.env.APP_ROLE || '',
86
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
72
87
  appPort: port,
73
88
  clusterMode: process.env.CLUSTER_MODE || '',
74
89
  },
@@ -90,18 +105,27 @@ export class RedisNodeRegistry {
90
105
 
91
106
  try {
92
107
  await redis.sendCommand(['SET', key, JSON.stringify(metadata), 'EX', this.ttlSecs.toString()]);
108
+ this.lastHeartbeatAt = Date.now();
109
+ this.lastHeartbeatError = null;
93
110
  } catch (err: any) {
111
+ this.lastHeartbeatError = err.message;
94
112
  this.app.logger.error(`[RedisNodeRegistry] Heartbeat failed: ${err.message}`);
95
113
  }
96
114
  }
97
115
 
98
116
  public async getNodes(): Promise<any[]> {
99
117
  const redis = getRedisClient(this.app);
100
- if (!redis) return [];
118
+ if (!redis) {
119
+ this.lastReadError = 'Redis is not configured for cluster node discovery';
120
+ return [];
121
+ }
101
122
 
102
123
  try {
103
124
  const rawKeys = await scanKeys(redis, `${this.keyPrefix}*`);
104
- if (rawKeys.length === 0) return [];
125
+ if (rawKeys.length === 0) {
126
+ this.lastReadError = null;
127
+ return [];
128
+ }
105
129
 
106
130
  const values = await redis.sendCommand(['MGET', ...rawKeys]);
107
131
 
@@ -117,10 +141,26 @@ export class RedisNodeRegistry {
117
141
  }
118
142
  }
119
143
  }
144
+ this.lastReadError = null;
120
145
  return nodes;
121
146
  } catch (err: any) {
147
+ this.lastReadError = err.message;
122
148
  this.app.logger.error(`[RedisNodeRegistry] Error fetching nodes: ${err.message}`);
123
149
  return [];
124
150
  }
125
151
  }
152
+
153
+ public getStatus() {
154
+ const redis = getRedisClient(this.app);
155
+ return {
156
+ configured: isClusterRedisConfigured(this.app),
157
+ connected: Boolean(redis),
158
+ keyPrefix: this.keyPrefix,
159
+ ttlSecs: this.ttlSecs,
160
+ intervalMs: this.intervalMs,
161
+ lastHeartbeatAt: this.lastHeartbeatAt,
162
+ lastHeartbeatError: this.lastHeartbeatError,
163
+ lastReadError: this.lastReadError,
164
+ };
165
+ }
126
166
  }
@@ -44,6 +44,12 @@ export default {
44
44
  interface: 'json',
45
45
  uiSchema: { title: 'Environment Variables' },
46
46
  },
47
+ {
48
+ name: 'workerMode',
49
+ type: 'string',
50
+ interface: 'input',
51
+ uiSchema: { title: 'Worker Mode', 'x-component': 'Input' },
52
+ },
47
53
  {
48
54
  name: 'volumes',
49
55
  type: 'json',
@@ -5,8 +5,8 @@
5
5
  * When a stack has assigned queues, the orchestrator adapter sets
6
6
  * WORKER_MODE=<comma-separated-queue-names> on new containers.
7
7
  *
8
- * If no mappings exist for a stack, WORKER_MODE=* is preserved
9
- * (backwards compatibility).
8
+ * Explicit orchestratorStacks.workerMode takes precedence. If no explicit
9
+ * mode exists, mappings can still provide WORKER_MODE for legacy stacks.
10
10
  */
11
11
  export default {
12
12
  name: 'workerQueueMappings',
@@ -67,7 +67,7 @@ export default {
67
67
  'x-component': 'Select',
68
68
  'x-component-props': {
69
69
  allowClear: true,
70
- placeholder: 'Unassigned (worker runs all queues)',
70
+ placeholder: 'Unassigned (fallback only)',
71
71
  },
72
72
  },
73
73
  },
@@ -8,6 +8,17 @@ import Application from '@nocobase/server';
8
8
  /** Allow only safe package name characters: letters, digits, dash, underscore, dot, @, /, [, ] */
9
9
  const SAFE_PKG_RE = /^(?:[a-zA-Z0-9_.@/-]|\[|\])+$/;
10
10
  const INSTALL_CHANNEL = 'cluster-manager.install-packages';
11
+ const APT_ENV: NodeJS.ProcessEnv = {
12
+ DEBIAN_FRONTEND: 'noninteractive',
13
+ DEBCONF_NONINTERACTIVE_SEEN: 'true',
14
+ APT_LISTCHANGES_FRONTEND: 'none',
15
+ TERM: 'dumb',
16
+ NEEDRESTART_MODE: 'a',
17
+ UCF_FORCE_CONFOLD: '1',
18
+ UCF_FORCE_CONFFOLD: '1',
19
+ };
20
+ const APT_DPKG_OPTIONS = ['-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold'];
21
+ const DPKG_CONFIGURE_OPTIONS = ['--force-confdef', '--force-confold', '--configure', '-a'];
11
22
 
12
23
  type TargetRole = 'app' | 'worker' | 'sandbox' | 'all';
13
24
  type PackageKind = 'apt' | 'npm' | 'python';
@@ -164,22 +175,27 @@ export class PackageManager {
164
175
  };
165
176
 
166
177
  // Step 1: APT packages
167
- if (missingPackages.apt.length > 0) {
178
+ if (safePackages.apt.length > 0) {
168
179
  if (registryConfig.aptMirrorUrl) {
169
180
  const aptMirrorUrl = sanitizeHttpUrl(registryConfig.aptMirrorUrl, 'APT mirror URL');
170
181
  logs.push(`Applying APT mirror: ${redactUrl(aptMirrorUrl)}`);
171
182
  await this.configureAptMirror(aptMirrorUrl, logs);
172
183
  }
173
184
 
174
- await this.updateInstallStatus('running', 20, 'Installing APT packages...', logs);
175
- await this.runCommand('apt-get', ['update', '-qq'], 'Updating APT package index...', logs, 1200000);
176
- await this.runCommand(
177
- 'apt-get',
178
- ['install', '-y', '--no-install-recommends', ...missingPackages.apt],
179
- 'Installing APT packages...',
180
- logs,
181
- 1200000,
182
- );
185
+ await this.updateInstallStatus('running', 20, 'Preparing APT/dpkg...', logs);
186
+ await this.repairAptState(logs);
187
+ if (missingPackages.apt.length > 0) {
188
+ await this.updateInstallStatus('running', 25, 'Installing APT packages...', logs);
189
+ await this.runCommand('apt-get', ['update', '-qq'], 'Updating APT package index...', logs, 1200000, APT_ENV);
190
+ await this.runCommand(
191
+ 'apt-get',
192
+ [...APT_DPKG_OPTIONS, 'install', '-y', '--no-install-recommends', ...missingPackages.apt],
193
+ 'Installing APT packages...',
194
+ logs,
195
+ 1200000,
196
+ APT_ENV,
197
+ );
198
+ }
183
199
  }
184
200
 
185
201
  // Step 2: NPM packages (Global)
@@ -277,12 +293,13 @@ export class PackageManager {
277
293
  label: string,
278
294
  logs: string[],
279
295
  timeoutMs = 1200000,
296
+ env?: NodeJS.ProcessEnv,
280
297
  ): Promise<void> {
281
298
  logs.push(`RUNNING: ${formatCommand(command, args)}`);
282
299
  logs.push(`${label}`);
283
300
 
284
301
  await new Promise<void>((resolve, reject) => {
285
- const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
302
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env } });
286
303
  let stdout = '';
287
304
  let stderr = '';
288
305
  let settled = false;
@@ -324,6 +341,26 @@ export class PackageManager {
324
341
  });
325
342
  }
326
343
 
344
+ private async repairAptState(logs: string[]): Promise<void> {
345
+ logs.push('Preparing APT/dpkg in non-interactive mode...');
346
+ await this.runCommand(
347
+ 'dpkg',
348
+ DPKG_CONFIGURE_OPTIONS,
349
+ 'Repairing interrupted dpkg configuration...',
350
+ logs,
351
+ 1200000,
352
+ APT_ENV,
353
+ );
354
+ await this.runCommand(
355
+ 'apt-get',
356
+ [...APT_DPKG_OPTIONS, '-f', 'install', '-y', '--no-install-recommends'],
357
+ 'Repairing incomplete APT dependencies...',
358
+ logs,
359
+ 1200000,
360
+ APT_ENV,
361
+ );
362
+ }
363
+
327
364
  private async getMissingPackages(kind: PackageKind, packages: string[], logs: string[]): Promise<string[]> {
328
365
  const missing: string[] = [];
329
366
  for (const pkg of packages) {
@@ -24,8 +24,8 @@ export interface ScaleResult {
24
24
  }
25
25
 
26
26
  export interface ContainerStats {
27
- cpu: number; // percentage 0-100
28
- memory: number; // bytes
27
+ cpu: number; // percentage 0-100
28
+ memory: number; // bytes
29
29
  memoryLimit: number;
30
30
  networkRx: number; // bytes
31
31
  networkTx: number; // bytes
@@ -38,11 +38,12 @@ export interface StackConfig {
38
38
  image: string;
39
39
  command?: string;
40
40
  envVars?: Record<string, string>;
41
+ workerMode?: string;
41
42
  volumes?: string[];
42
43
  networks?: string[];
43
44
  resourceLimits?: {
44
- memory?: string; // "1536Mi" | "2Gi"
45
- cpu?: string; // "1" | "500m"
45
+ memory?: string; // "1536Mi" | "2Gi"
46
+ cpu?: string; // "1" | "500m"
46
47
  };
47
48
  replicas: number;
48
49
  desiredReplicas: number;
@@ -7,11 +7,12 @@ import { redisActions } from './actions/redis-monitor';
7
7
  import { aclCacheActions, createAclCacheMiddleware } from './actions/acl-cache';
8
8
  import { clusterActions, readLocalLogs } from './actions/cluster-nodes';
9
9
  import { getRedisClient } from './utils/redis';
10
- import { getLocalNodeId, isWorkerMode } from './utils/node';
10
+ import { getLocalNodeId, getLocalRole, isWorkerMode } from './utils/node';
11
11
  import { eventQueueActions } from './actions/event-queue-monitor';
12
12
  import { lockActions } from './actions/lock-monitor';
13
13
  import { cacheMonitorActions } from './actions/cache-monitor';
14
14
  import { RedisPubSubAdapter } from './adapters/redis-pubsub-adapter';
15
+ import { RedisEventQueueAdapter } from './adapters/redis-event-queue-adapter';
15
16
  import { RedisNodeRegistry } from './adapters/redis-node-registry';
16
17
  import { RedisLockAdapter } from './adapters/redis-lock-adapter';
17
18
  import { orchestratorActions } from './actions/orchestrator';
@@ -137,6 +138,7 @@ export class PluginClusterManagerServer extends Plugin {
137
138
 
138
139
  // Register Redis PubSub adapter if URL is configured and no adapter already set
139
140
  this.registerPubSubAdapter();
141
+ await this.registerEventQueueAdapter();
140
142
 
141
143
  // Register missing Redis Lock adapter if running bare open-source core
142
144
  const lockMgr = this.app.lockManager as any;
@@ -410,6 +412,38 @@ export class PluginClusterManagerServer extends Plugin {
410
412
  this.app.logger.info('[cluster-manager] Redis PubSub adapter registered');
411
413
  }
412
414
 
415
+ private async registerEventQueueAdapter() {
416
+ const enabled = process.env.QUEUE_ADAPTER === 'redis' || Boolean(process.env.QUEUE_ADAPTER_REDIS_URL);
417
+ if (!enabled) {
418
+ return;
419
+ }
420
+
421
+ const url = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
422
+ if (!url) {
423
+ this.app.logger.warn('[cluster-manager] QUEUE_ADAPTER=redis but QUEUE_ADAPTER_REDIS_URL/REDIS_URL is not set');
424
+ return;
425
+ }
426
+
427
+ const eventQueue = this.app.eventQueue as any;
428
+ const existingAdapter = eventQueue?.adapter;
429
+ const existingName = existingAdapter?.constructor?.name;
430
+ if (existingAdapter && existingName !== 'MemoryEventQueueAdapter') {
431
+ this.app.logger.info(`[cluster-manager] EventQueue adapter already registered (${existingName}), skipping`);
432
+ return;
433
+ }
434
+
435
+ const adapter = new RedisEventQueueAdapter({ app: this.app, url });
436
+ const wasConnected = Boolean(eventQueue?.isConnected?.());
437
+ if (wasConnected) {
438
+ await eventQueue.close();
439
+ }
440
+ eventQueue.setAdapter(adapter);
441
+ if (wasConnected) {
442
+ await eventQueue.connect();
443
+ }
444
+ this.app.logger.info('[cluster-manager] Redis EventQueue adapter registered');
445
+ }
446
+
413
447
  /**
414
448
  * Initialize the Container Orchestrator subsystem.
415
449
  * Config is loaded from DB (orchestratorSettings collection) first,
@@ -484,10 +518,10 @@ export class PluginClusterManagerServer extends Plugin {
484
518
  // Leader election runs on app nodes only. Worker-only pods still load the
485
519
  // plugin for monitoring/package installation, but they must not become the
486
520
  // Kubernetes orchestrator leader.
487
- const workerOnlyNode = this.isWorkerOnlyNode();
521
+ const nonAppNode = this.isNonAppNode();
488
522
  this.leaderElection = new LeaderElection(this.app, {
489
- enabled: !workerOnlyNode,
490
- disabledReason: workerOnlyNode ? 'Worker-only nodes do not run orchestrator write operations.' : '',
523
+ enabled: !nonAppNode,
524
+ disabledReason: nonAppNode ? 'Non-app nodes do not run orchestrator write operations.' : '',
491
525
  });
492
526
  await this.leaderElection.init();
493
527
 
@@ -561,8 +595,8 @@ export class PluginClusterManagerServer extends Plugin {
561
595
  }
562
596
  }
563
597
 
564
- private isWorkerOnlyNode(): boolean {
565
- return isWorkerMode(process.env.WORKER_MODE);
598
+ private isNonAppNode(): boolean {
599
+ return getLocalRole() !== 'app';
566
600
  }
567
601
  }
568
602