plugin-cluster-manager 1.1.15 → 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.
- package/dist/client/index.js +1 -1
- package/dist/client-v2/376.cd1d86e85a50088e.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/locale/en-US.json +16 -6
- package/dist/locale/vi-VN.json +16 -6
- package/dist/locale/zh-CN.json +16 -6
- package/dist/server/actions/cluster-nodes.js +44 -11
- package/dist/server/actions/doctor.js +73 -7
- package/dist/server/actions/event-queue-monitor.js +33 -3
- package/dist/server/actions/orchestrator.js +48 -32
- package/dist/server/actions/queue-mappings.js +1 -0
- package/dist/server/actions/tasks.js +8 -8
- package/dist/server/adapters/redis-event-queue-adapter.js +188 -0
- package/dist/server/adapters/redis-node-registry.js +44 -10
- package/dist/server/collections/orchestrator-stacks.js +6 -0
- package/dist/server/collections/worker-queue-mappings.js +1 -1
- package/dist/server/orchestrator/PackageManager.js +47 -12
- package/dist/server/plugin.js +37 -6
- package/dist/server/queue-scanner.js +54 -34
- package/dist/server/utils/node.js +3 -7
- package/dist/server/utils/redis.js +37 -9
- package/dist/shared/worker-processes.js +233 -0
- package/package.json +1 -1
- package/src/client/ClusterNodes.tsx +76 -10
- package/src/client/ContainerOrchestrator.tsx +146 -8
- package/src/client/QueueAssignment.tsx +10 -2
- package/src/locale/en-US.json +16 -6
- package/src/locale/vi-VN.json +16 -6
- package/src/locale/zh-CN.json +16 -6
- package/src/server/__tests__/worker-processes.test.ts +42 -0
- package/src/server/actions/cluster-nodes.ts +43 -8
- package/src/server/actions/doctor.ts +77 -0
- package/src/server/actions/event-queue-monitor.ts +34 -3
- package/src/server/actions/orchestrator.ts +58 -38
- package/src/server/actions/queue-mappings.ts +1 -0
- package/src/server/actions/tasks.ts +142 -142
- package/src/server/adapters/redis-event-queue-adapter.ts +189 -0
- package/src/server/adapters/redis-node-registry.ts +44 -4
- package/src/server/collections/orchestrator-stacks.ts +6 -0
- package/src/server/collections/worker-queue-mappings.ts +3 -3
- package/src/server/orchestrator/PackageManager.ts +48 -11
- package/src/server/orchestrator/types.ts +5 -4
- package/src/server/plugin.ts +40 -6
- package/src/server/queue-scanner.ts +65 -51
- package/src/server/utils/node.ts +3 -10
- package/src/server/utils/redis.ts +39 -4
- package/src/shared/worker-processes.ts +216 -0
- package/dist/client-v2/914.c0bce51908fd81d7.js +0 -10
|
@@ -9,6 +9,12 @@ import type { ContainerInfo, StackConfig } from '../orchestrator/types';
|
|
|
9
9
|
import { getLocalNodeId, getNodeRoleFrom } from '../utils/node';
|
|
10
10
|
import { getRedisClient, scanKeys } from '../utils/redis';
|
|
11
11
|
import { packagesFromConfig, type CustomPackageMap, type WorkerPackageMap } from '../../shared/packages';
|
|
12
|
+
import {
|
|
13
|
+
getWorkerProcessDefinition,
|
|
14
|
+
normalizeWorkerMode,
|
|
15
|
+
resolveWorkerProcessName,
|
|
16
|
+
WORKER_PROCESS_DEFINITIONS,
|
|
17
|
+
} from '../../shared/worker-processes';
|
|
12
18
|
|
|
13
19
|
const ACTIVE_RUN_KEY = 'cluster-manager:doctor:active';
|
|
14
20
|
const RESPONSE_KEY_PREFIX = 'cluster-manager:doctor-response:';
|
|
@@ -1112,29 +1118,91 @@ async function getQueueDiagnostics(app: Application) {
|
|
|
1112
1118
|
| { constructor?: { name?: string }; queues?: Map<string, unknown[]> }
|
|
1113
1119
|
| undefined;
|
|
1114
1120
|
const channels = [];
|
|
1121
|
+
const requiredProcesses = new Set<string>();
|
|
1115
1122
|
for (const [channel, options] of eventQueue.events || new Map()) {
|
|
1116
1123
|
let pending: number | null = null;
|
|
1117
1124
|
if (adapter?.queues && eventQueue.getFullChannel) {
|
|
1118
1125
|
const fullChannel = eventQueue.getFullChannel(channel, options.shared);
|
|
1119
1126
|
pending = adapter.queues.get(fullChannel)?.length || 0;
|
|
1120
1127
|
}
|
|
1128
|
+
const processName = resolveWorkerProcessName(channel);
|
|
1129
|
+
const definition = getWorkerProcessDefinition(processName);
|
|
1130
|
+
if (definition && !definition.sandbox) {
|
|
1131
|
+
requiredProcesses.add(definition.name);
|
|
1132
|
+
}
|
|
1121
1133
|
channels.push({
|
|
1122
1134
|
channel,
|
|
1135
|
+
workerProcessName: definition?.name,
|
|
1123
1136
|
concurrency: options.concurrency || 1,
|
|
1124
1137
|
interval: options.interval || 250,
|
|
1125
1138
|
pending,
|
|
1126
1139
|
});
|
|
1127
1140
|
}
|
|
1128
1141
|
|
|
1142
|
+
for (const definition of WORKER_PROCESS_DEFINITIONS) {
|
|
1143
|
+
if (definition.common && definition.pluginName) {
|
|
1144
|
+
try {
|
|
1145
|
+
if ((app as any).pm?.get?.(definition.pluginName)) {
|
|
1146
|
+
requiredProcesses.add(definition.name);
|
|
1147
|
+
}
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Ignore plugin-manager lookup errors.
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const coverage = await getWorkerProcessCoverage(app, Array.from(requiredProcesses));
|
|
1155
|
+
|
|
1129
1156
|
return {
|
|
1130
1157
|
available: true,
|
|
1131
1158
|
connected: eventQueue.isConnected?.() || false,
|
|
1132
1159
|
adapter: adapter?.constructor?.name || 'unknown',
|
|
1133
1160
|
channels,
|
|
1161
|
+
coverage,
|
|
1134
1162
|
totalPending: channels.reduce((sum, item) => sum + (item.pending || 0), 0),
|
|
1135
1163
|
};
|
|
1136
1164
|
}
|
|
1137
1165
|
|
|
1166
|
+
async function getWorkerProcessCoverage(app: Application, requiredProcesses: string[]) {
|
|
1167
|
+
const result = {
|
|
1168
|
+
required: requiredProcesses,
|
|
1169
|
+
covered: [] as string[],
|
|
1170
|
+
missing: [] as string[],
|
|
1171
|
+
wildcard: false,
|
|
1172
|
+
stacks: [] as Array<{ id: unknown; name: unknown; workerMode: string }>,
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
try {
|
|
1176
|
+
const repo = app.db.getRepository('orchestratorStacks');
|
|
1177
|
+
const stacks = await repo.find({ filter: { enabled: true } });
|
|
1178
|
+
|
|
1179
|
+
const covered = new Set<string>();
|
|
1180
|
+
for (const stack of stacks) {
|
|
1181
|
+
const envVars = stack.get?.('envVars') as { WORKER_MODE?: string } | undefined;
|
|
1182
|
+
const workerMode =
|
|
1183
|
+
normalizeWorkerMode((stack.get?.('workerMode') as string | undefined) || envVars?.WORKER_MODE) || '*';
|
|
1184
|
+
result.stacks.push({
|
|
1185
|
+
id: stack.get?.('id'),
|
|
1186
|
+
name: stack.get?.('name'),
|
|
1187
|
+
workerMode,
|
|
1188
|
+
});
|
|
1189
|
+
if (workerMode === '*') {
|
|
1190
|
+
result.wildcard = true;
|
|
1191
|
+
}
|
|
1192
|
+
for (const token of workerMode.split(',').filter(Boolean)) {
|
|
1193
|
+
covered.add(token);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
result.covered = Array.from(covered);
|
|
1198
|
+
result.missing = result.wildcard ? [] : requiredProcesses.filter((processName) => !covered.has(processName));
|
|
1199
|
+
} catch {
|
|
1200
|
+
result.missing = requiredProcesses;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return result;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1138
1206
|
async function getOrchestratorDiagnostics(app: Application, options: DoctorSnapshotOptions) {
|
|
1139
1207
|
const plugin = getApp(app).pm?.get('plugin-cluster-manager') as {
|
|
1140
1208
|
orchestrator?: {
|
|
@@ -1214,6 +1282,7 @@ function buildRecommendations(params: {
|
|
|
1214
1282
|
packageDrifts: number;
|
|
1215
1283
|
redisAvailable: boolean;
|
|
1216
1284
|
databaseOk: boolean;
|
|
1285
|
+
queueCoverageMissing?: string[];
|
|
1217
1286
|
}) {
|
|
1218
1287
|
const recommendations = [];
|
|
1219
1288
|
if (!params.redisAvailable) {
|
|
@@ -1258,6 +1327,13 @@ function buildRecommendations(params: {
|
|
|
1258
1327
|
message: 'One or more worker nodes are missing configured packages or have failed package initialization.',
|
|
1259
1328
|
});
|
|
1260
1329
|
}
|
|
1330
|
+
if (params.queueCoverageMissing?.length) {
|
|
1331
|
+
recommendations.push({
|
|
1332
|
+
level: 'warning',
|
|
1333
|
+
code: 'worker_process_coverage_missing',
|
|
1334
|
+
message: `No explicit worker stack covers: ${params.queueCoverageMissing.join(', ')}.`,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1261
1337
|
if (params.topErrors > 0) {
|
|
1262
1338
|
recommendations.push({
|
|
1263
1339
|
level: 'warning',
|
|
@@ -1301,6 +1377,7 @@ async function buildDoctorReport(app: Application, run: Record<string, unknown>,
|
|
|
1301
1377
|
packageDrifts: packageDiagnostics.packageDrifts.length,
|
|
1302
1378
|
redisAvailable: Boolean(redisDiagnostics.available),
|
|
1303
1379
|
databaseOk: Boolean(databaseDiagnostics.ping.ok),
|
|
1380
|
+
queueCoverageMissing: queueDiagnostics.coverage?.missing || [],
|
|
1304
1381
|
});
|
|
1305
1382
|
const criticalFindings = recommendations.filter((item) => item.level === 'critical').length;
|
|
1306
1383
|
const warningFindings = recommendations.filter((item) => item.level === 'warning').length;
|
|
@@ -2,7 +2,13 @@ import { Context } from '@nocobase/actions';
|
|
|
2
2
|
import { scanKeys } from '../utils/redis';
|
|
3
3
|
|
|
4
4
|
const REDIS_QUEUE_CONNECTION = 'cluster-manager:queue-monitor';
|
|
5
|
-
const REDIS_QUEUE_PATTERNS = [
|
|
5
|
+
const REDIS_QUEUE_PATTERNS = [
|
|
6
|
+
'*:plugin-git-manager:review:queue',
|
|
7
|
+
'*:plugin-build-guide-block:build:queue',
|
|
8
|
+
'*:plugin-build-visualization-block:build:queue',
|
|
9
|
+
'file-preview-auth.ocr.queue',
|
|
10
|
+
'nocobase:event-queue:*',
|
|
11
|
+
];
|
|
6
12
|
|
|
7
13
|
function getQueueRedisUrl() {
|
|
8
14
|
return process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
|
|
@@ -19,10 +25,16 @@ async function getQueueRedis(ctx: Context) {
|
|
|
19
25
|
|
|
20
26
|
function knownRedisQueueKeys(ctx: Context) {
|
|
21
27
|
const appName = (ctx.app as any).name || process.env.APP_NAME || 'main';
|
|
22
|
-
return [
|
|
28
|
+
return [
|
|
29
|
+
`${appName}:plugin-git-manager:review:queue`,
|
|
30
|
+
`${appName}:plugin-build-guide-block:build:queue`,
|
|
31
|
+
`${appName}:plugin-build-visualization-block:build:queue`,
|
|
32
|
+
'file-preview-auth.ocr.queue',
|
|
33
|
+
];
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
function isKnownRedisQueueKey(key: string) {
|
|
37
|
+
if (key.startsWith('nocobase:event-queue:')) return true;
|
|
26
38
|
return REDIS_QUEUE_PATTERNS.some((pattern) => {
|
|
27
39
|
const suffix = pattern.replace('*:', '');
|
|
28
40
|
return key === suffix || key.endsWith(`:${suffix}`);
|
|
@@ -30,6 +42,25 @@ function isKnownRedisQueueKey(key: string) {
|
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
function describeRedisQueueKey(key: string) {
|
|
45
|
+
if (key === 'file-preview-auth.ocr.queue') {
|
|
46
|
+
return {
|
|
47
|
+
appName: 'main',
|
|
48
|
+
plugin: 'plugin-file-preview-auth',
|
|
49
|
+
queue: 'ocr',
|
|
50
|
+
channel: key,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (key.startsWith('nocobase:event-queue:')) {
|
|
55
|
+
const channel = key.slice('nocobase:event-queue:'.length);
|
|
56
|
+
return {
|
|
57
|
+
appName: channel.split('.')[0] || 'main',
|
|
58
|
+
plugin: 'event-queue',
|
|
59
|
+
queue: channel,
|
|
60
|
+
channel,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
const parts = String(key).split(':');
|
|
34
65
|
const queue = parts[parts.length - 2] || key;
|
|
35
66
|
const plugin = parts[parts.length - 3] || 'unknown';
|
|
@@ -96,7 +127,7 @@ function parseRedisQueueMessage(raw: string, key: string, index: number) {
|
|
|
96
127
|
} catch {
|
|
97
128
|
// Keep the raw string for non-JSON messages.
|
|
98
129
|
}
|
|
99
|
-
const queuedAt = content?.queuedAt ? Date.parse(content.queuedAt) : null;
|
|
130
|
+
const queuedAt = content?.queuedAt ? Date.parse(content.queuedAt) : content?.options?.timestamp || null;
|
|
100
131
|
return {
|
|
101
132
|
id: `${key}:${index}`,
|
|
102
133
|
index,
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
import { Context } from '@nocobase/actions';
|
|
9
9
|
import { getRedisClient } from '../utils/redis';
|
|
10
10
|
import type { IOrchestratorAdapter, StackConfig } from '../orchestrator/types';
|
|
11
|
+
import { normalizeWorkerMode } from '../../shared/worker-processes';
|
|
12
|
+
|
|
13
|
+
type QueueMappingRecord = {
|
|
14
|
+
get: (key: string) => unknown;
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
/** Helper: get orchestrator adapter from plugin instance */
|
|
13
18
|
function getAdapter(ctx: Context): IOrchestratorAdapter {
|
|
@@ -54,6 +59,33 @@ async function assertManagedContainer(
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
function applyWorkerMode(stack: StackConfig, workerMode: string) {
|
|
63
|
+
stack.workerMode = workerMode;
|
|
64
|
+
stack.envVars = {
|
|
65
|
+
...(stack.envVars || {}),
|
|
66
|
+
APP_ROLE: stack.envVars?.APP_ROLE || 'worker',
|
|
67
|
+
WORKER_MODE: workerMode,
|
|
68
|
+
SKILL_HUB_SANDBOX: stack.envVars?.SKILL_HUB_SANDBOX || 'false',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveMappedWorkerMode(ctx: Context, stack: StackConfig): Promise<string | undefined> {
|
|
73
|
+
const mappingsRepo = ctx.db.getRepository('workerQueueMappings');
|
|
74
|
+
const assigned = (await mappingsRepo.find({
|
|
75
|
+
filter: {
|
|
76
|
+
stackId: stack.id,
|
|
77
|
+
enabled: true,
|
|
78
|
+
},
|
|
79
|
+
})) as QueueMappingRecord[];
|
|
80
|
+
|
|
81
|
+
return normalizeWorkerMode(
|
|
82
|
+
assigned
|
|
83
|
+
.map((mapping) => String(mapping.get('queueName') || ''))
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join(','),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export const orchestratorActions = {
|
|
58
90
|
/**
|
|
59
91
|
* GET /workerOrchestrator:ping
|
|
@@ -122,9 +154,9 @@ export const orchestratorActions = {
|
|
|
122
154
|
* Body: { stackId: 1, replicas: 3 }
|
|
123
155
|
* Leader-only
|
|
124
156
|
*
|
|
125
|
-
* Before scaling, resolves
|
|
126
|
-
* WORKER_MODE into
|
|
127
|
-
*
|
|
157
|
+
* Before scaling, resolves the stack-level worker mode and injects
|
|
158
|
+
* WORKER_MODE into envVars so new containers process only selected queues.
|
|
159
|
+
* Queue mappings remain as a fallback for legacy stacks.
|
|
128
160
|
*/
|
|
129
161
|
async scale(ctx: Context, next: () => Promise<void>) {
|
|
130
162
|
assertLeader(ctx);
|
|
@@ -136,44 +168,32 @@ export const orchestratorActions = {
|
|
|
136
168
|
|
|
137
169
|
const stack = await getStack(ctx, stackId);
|
|
138
170
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
ctx.app.logger.info(
|
|
155
|
-
`[Orchestrator] Injecting WORKER_MODE=${workerMode} for stack "${stack.name}" (${queueNames.length} queue(s) assigned)`,
|
|
156
|
-
);
|
|
157
|
-
// Merge into envVars; adapter code merges envVars over inherited env
|
|
158
|
-
stack.envVars = {
|
|
159
|
-
...(stack.envVars || {}),
|
|
160
|
-
WORKER_MODE: workerMode,
|
|
161
|
-
};
|
|
171
|
+
const stackWorkerMode = normalizeWorkerMode(stack.workerMode);
|
|
172
|
+
const envWorkerMode = normalizeWorkerMode(stack.envVars?.WORKER_MODE);
|
|
173
|
+
|
|
174
|
+
if (stackWorkerMode) {
|
|
175
|
+
applyWorkerMode(stack, stackWorkerMode);
|
|
176
|
+
ctx.app.logger.info(`[Orchestrator] Using stack WORKER_MODE=${stackWorkerMode} for "${stack.name}"`);
|
|
177
|
+
} else if (envWorkerMode && envWorkerMode !== '*') {
|
|
178
|
+
applyWorkerMode(stack, envWorkerMode);
|
|
179
|
+
ctx.app.logger.info(`[Orchestrator] Using env WORKER_MODE=${envWorkerMode} for "${stack.name}"`);
|
|
180
|
+
} else {
|
|
181
|
+
try {
|
|
182
|
+
const mappedWorkerMode = await resolveMappedWorkerMode(ctx, stack);
|
|
183
|
+
|
|
184
|
+
if (mappedWorkerMode) {
|
|
185
|
+
applyWorkerMode(stack, mappedWorkerMode);
|
|
186
|
+
ctx.app.logger.info(`[Orchestrator] Using mapped WORKER_MODE=${mappedWorkerMode} for "${stack.name}"`);
|
|
162
187
|
} else {
|
|
163
|
-
|
|
164
|
-
stack
|
|
165
|
-
|
|
166
|
-
WORKER_MODE: '*',
|
|
167
|
-
};
|
|
188
|
+
const fallbackWorkerMode = envWorkerMode || '*';
|
|
189
|
+
applyWorkerMode(stack, fallbackWorkerMode);
|
|
190
|
+
ctx.app.logger.info(`[Orchestrator] Using fallback WORKER_MODE=${fallbackWorkerMode} for "${stack.name}"`);
|
|
168
191
|
}
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
const fallbackWorkerMode = envWorkerMode || '*';
|
|
194
|
+
ctx.app.logger.debug(`[Orchestrator] Queue mappings not available: ${err.message}`);
|
|
195
|
+
applyWorkerMode(stack, fallbackWorkerMode);
|
|
169
196
|
}
|
|
170
|
-
} catch (err: any) {
|
|
171
|
-
// If workerQueueMappings table doesn't exist yet, fall back gracefully
|
|
172
|
-
ctx.app.logger.debug(`[Orchestrator] Queue mappings not available: ${err.message}`);
|
|
173
|
-
stack.envVars = {
|
|
174
|
-
...(stack.envVars || {}),
|
|
175
|
-
WORKER_MODE: '*',
|
|
176
|
-
};
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
const result = await adapter.scale(stack, Number(replicas));
|
|
@@ -1,142 +1,142 @@
|
|
|
1
|
-
import { Context } from '@nocobase/actions';
|
|
2
|
-
|
|
3
|
-
export const tasksActions = {
|
|
4
|
-
async list(ctx: Context, next: () => Promise<void>) {
|
|
5
|
-
const repo = ctx.db.getRepository('asyncTasks');
|
|
6
|
-
const { page = 1, pageSize = 20, statusFilter } = ctx.action.params;
|
|
7
|
-
|
|
8
|
-
const filter: any = {};
|
|
9
|
-
if (statusFilter !== undefined && statusFilter !== '') {
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const [rows, count] = await repo.findAndCount({
|
|
14
|
-
filter,
|
|
15
|
-
appends: ['createdBy'],
|
|
16
|
-
offset: (page - 1) * pageSize,
|
|
17
|
-
limit: pageSize,
|
|
18
|
-
sort: ['-createdAt'],
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
ctx.body = {
|
|
22
|
-
data: rows,
|
|
23
|
-
meta: { count, page: Number(page), pageSize: Number(pageSize) },
|
|
24
|
-
};
|
|
25
|
-
await next();
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
async cancel(ctx: Context, next: () => Promise<void>) {
|
|
29
|
-
const { filterByTk } = ctx.action.params;
|
|
30
|
-
if (!filterByTk) {
|
|
31
|
-
ctx.throw(400, 'Task ID is required');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const repo = ctx.db.getRepository('asyncTasks');
|
|
35
|
-
const task = await repo.findOne({ filterByTk });
|
|
36
|
-
if (!task) {
|
|
37
|
-
ctx.throw(404, 'Task not found');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Status: RUNNING = 0, PENDING = null
|
|
41
|
-
const status = task.get('status');
|
|
42
|
-
if (status !== 0 && status !== null) {
|
|
43
|
-
ctx.throw(400, 'Task is not running or pending');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
47
|
-
ctx.app.logger.info(`[cluster-manager] Canceling task ${filterByTk} by user ${user}`);
|
|
48
|
-
|
|
49
|
-
// Try to cancel via pub/sub for cross-instance support
|
|
50
|
-
const pluginName = '@nocobase/plugin-async-task-manager';
|
|
51
|
-
try {
|
|
52
|
-
await ctx.app.pubSubManager.publish(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
]
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
if (days && Number(days) > 0) {
|
|
131
|
-
const date = new Date();
|
|
132
|
-
date.setDate(date.getDate() - Number(days));
|
|
133
|
-
filter.createdAt = {
|
|
134
|
-
$lt: date.toISOString(),
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const count = await repo.destroy({ filter });
|
|
139
|
-
ctx.body = { success: true, deletedCount: count };
|
|
140
|
-
await next();
|
|
141
|
-
},
|
|
142
|
-
};
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
|
|
3
|
+
export const tasksActions = {
|
|
4
|
+
async list(ctx: Context, next: () => Promise<void>) {
|
|
5
|
+
const repo = ctx.db.getRepository('asyncTasks');
|
|
6
|
+
const { page = 1, pageSize = 20, statusFilter } = ctx.action.params;
|
|
7
|
+
|
|
8
|
+
const filter: any = {};
|
|
9
|
+
if (statusFilter !== undefined && statusFilter !== '') {
|
|
10
|
+
filter.status = statusFilter === 'null' ? null : Number(statusFilter);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const [rows, count] = await repo.findAndCount({
|
|
14
|
+
filter,
|
|
15
|
+
appends: ['createdBy'],
|
|
16
|
+
offset: (page - 1) * pageSize,
|
|
17
|
+
limit: pageSize,
|
|
18
|
+
sort: ['-createdAt'],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
ctx.body = {
|
|
22
|
+
data: rows,
|
|
23
|
+
meta: { count, page: Number(page), pageSize: Number(pageSize) },
|
|
24
|
+
};
|
|
25
|
+
await next();
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async cancel(ctx: Context, next: () => Promise<void>) {
|
|
29
|
+
const { filterByTk } = ctx.action.params;
|
|
30
|
+
if (!filterByTk) {
|
|
31
|
+
ctx.throw(400, 'Task ID is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const repo = ctx.db.getRepository('asyncTasks');
|
|
35
|
+
const task = await repo.findOne({ filterByTk });
|
|
36
|
+
if (!task) {
|
|
37
|
+
ctx.throw(404, 'Task not found');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Status: RUNNING = 0, PENDING = null
|
|
41
|
+
const status = task.get('status');
|
|
42
|
+
if (status !== 0 && status !== null) {
|
|
43
|
+
ctx.throw(400, 'Task is not running or pending');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
47
|
+
ctx.app.logger.info(`[cluster-manager] Canceling task ${filterByTk} by user ${user}`);
|
|
48
|
+
|
|
49
|
+
// Try to cancel via pub/sub for cross-instance support
|
|
50
|
+
const pluginName = '@nocobase/plugin-async-task-manager';
|
|
51
|
+
try {
|
|
52
|
+
await ctx.app.pubSubManager.publish(
|
|
53
|
+
`${pluginName}.task.cancel`,
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
taskId: filterByTk,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
// Fallback: direct DB update
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await repo.update({
|
|
63
|
+
filterByTk,
|
|
64
|
+
values: {
|
|
65
|
+
status: -2, // CANCELED
|
|
66
|
+
doneAt: new Date(),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
ctx.body = { success: true };
|
|
71
|
+
await next();
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async retry(ctx: Context, next: () => Promise<void>) {
|
|
75
|
+
const { filterByTk } = ctx.action.params;
|
|
76
|
+
if (!filterByTk) {
|
|
77
|
+
ctx.throw(400, 'Task ID is required');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const repo = ctx.db.getRepository('asyncTasks');
|
|
81
|
+
const task = await repo.findOne({ filterByTk });
|
|
82
|
+
if (!task) {
|
|
83
|
+
ctx.throw(404, 'Task not found');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const status = task.get('status');
|
|
87
|
+
// Only retry failed (-1) or canceled (-2) tasks
|
|
88
|
+
if (status !== -1 && status !== -2) {
|
|
89
|
+
ctx.throw(400, 'Only failed or canceled tasks can be retried');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
93
|
+
ctx.app.logger.info(`[cluster-manager] Retrying task ${filterByTk} by user ${user}`);
|
|
94
|
+
|
|
95
|
+
await repo.update({
|
|
96
|
+
filterByTk,
|
|
97
|
+
values: {
|
|
98
|
+
status: null, // PENDING
|
|
99
|
+
result: null,
|
|
100
|
+
progressCurrent: 0,
|
|
101
|
+
startedAt: null,
|
|
102
|
+
doneAt: null,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Re-queue the task
|
|
107
|
+
const pluginName = '@nocobase/plugin-async-task-manager';
|
|
108
|
+
try {
|
|
109
|
+
await ctx.app.eventQueue.publish(`${pluginName}.task`, {
|
|
110
|
+
id: filterByTk,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// Queue may not be available
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ctx.body = { success: true };
|
|
117
|
+
await next();
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async purge(ctx: Context, next: () => Promise<void>) {
|
|
121
|
+
const { days = 0 } = ctx.action.params.values || ctx.action.params;
|
|
122
|
+
const user = ctx.state?.currentUser?.nickname || ctx.state?.currentUser?.id || 'unknown';
|
|
123
|
+
ctx.app.logger.info(`[cluster-manager] Purging tasks (days=${days}) by user ${user}`);
|
|
124
|
+
const repo = ctx.db.getRepository('asyncTasks');
|
|
125
|
+
|
|
126
|
+
const filter: any = {
|
|
127
|
+
$and: [{ status: { $ne: 0 } }, { status: { $ne: null } }],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (days && Number(days) > 0) {
|
|
131
|
+
const date = new Date();
|
|
132
|
+
date.setDate(date.getDate() - Number(days));
|
|
133
|
+
filter.createdAt = {
|
|
134
|
+
$lt: date.toISOString(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const count = await repo.destroy({ filter });
|
|
139
|
+
ctx.body = { success: true, deletedCount: count };
|
|
140
|
+
await next();
|
|
141
|
+
},
|
|
142
|
+
};
|