comfyui-node 1.6.6 → 1.6.7
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/LICENSE +20 -20
- package/README.md +342 -341
- package/dist/.tsbuildinfo +1 -1
- package/dist/multipool/client-registry.d.ts +3 -3
- package/dist/multipool/client-registry.d.ts.map +1 -1
- package/dist/multipool/client-registry.js +9 -9
- package/dist/multipool/client-registry.js.map +1 -1
- package/dist/multipool/helpers.d.ts +4 -4
- package/dist/multipool/index.d.ts +2 -2
- package/dist/multipool/interfaces.d.ts +0 -2
- package/dist/multipool/interfaces.d.ts.map +1 -1
- package/dist/multipool/job-queue-processor.d.ts +3 -3
- package/dist/multipool/job-queue-processor.d.ts.map +1 -1
- package/dist/multipool/job-queue-processor.js +28 -27
- package/dist/multipool/job-queue-processor.js.map +1 -1
- package/dist/multipool/logger.d.ts +29 -29
- package/dist/multipool/multi-workflow-pool.d.ts +0 -1
- package/dist/multipool/multi-workflow-pool.d.ts.map +1 -1
- package/dist/multipool/multi-workflow-pool.js +36 -37
- package/dist/multipool/multi-workflow-pool.js.map +1 -1
- package/dist/multipool/tests/client-registry-api-demo.js +1 -3
- package/dist/multipool/tests/client-registry-api-demo.js.map +1 -1
- package/dist/multipool/tests/client-registry.spec.js +6 -7
- package/dist/multipool/tests/client-registry.spec.js.map +1 -1
- package/dist/multipool/tests/error-classification-tests.d.ts +1 -1
- package/dist/multipool/tests/event-forwarding-demo.js +1 -3
- package/dist/multipool/tests/event-forwarding-demo.js.map +1 -1
- package/dist/multipool/tests/job-queue-processor.spec.js +7 -7
- package/dist/multipool/tests/job-queue-processor.spec.js.map +1 -1
- package/dist/multipool/tests/job-state-registry.d.ts +16 -16
- package/dist/multipool/tests/job-state-registry.js +23 -23
- package/dist/multipool/tests/job-state-registry.spec.js +5 -4
- package/dist/multipool/tests/job-state-registry.spec.js.map +1 -1
- package/dist/multipool/tests/multipool-basic.d.ts +11 -11
- package/dist/multipool/tests/profiling-demo.d.ts +6 -6
- package/dist/multipool/tests/profiling-demo.js +1 -2
- package/dist/multipool/tests/profiling-demo.js.map +1 -1
- package/dist/multipool/tests/prompt-generator.d.ts +9 -9
- package/dist/multipool/tests/test-helpers.d.ts +3 -3
- package/dist/multipool/tests/two-stage-edit-simulation.d.ts +31 -31
- package/dist/multipool/tests/two-stage-edit-simulation.d.ts.map +1 -1
- package/dist/multipool/tests/two-stage-edit-simulation.js +1 -2
- package/dist/multipool/tests/two-stage-edit-simulation.js.map +1 -1
- package/dist/pool/SmartPool.d.ts +143 -143
- package/dist/pool/SmartPool.js +676 -676
- package/dist/pool/SmartPoolV2.d.ts +119 -119
- package/dist/pool/SmartPoolV2.js +586 -586
- package/dist/pool/WorkflowPool.d.ts +202 -202
- package/dist/pool/client/ClientManager.d.ts +86 -86
- package/dist/pool/index.d.ts +9 -9
- package/package.json +2 -2
package/dist/pool/SmartPoolV2.js
CHANGED
|
@@ -1,587 +1,587 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { TypedEventTarget } from "../typed-event-target.js";
|
|
3
|
-
import { ComfyApi } from "../client.js";
|
|
4
|
-
import { PromptBuilder } from "../prompt-builder.js";
|
|
5
|
-
import { MemoryQueueAdapter } from "./queue/adapters/memory.js";
|
|
6
|
-
import { hashWorkflow } from "./utils/hash.js";
|
|
7
|
-
// ============================================================================
|
|
8
|
-
// MAIN CLASS
|
|
9
|
-
// ============================================================================
|
|
10
|
-
export class SmartPoolV2 extends TypedEventTarget {
|
|
11
|
-
// Client management
|
|
12
|
-
clientMap = new Map();
|
|
13
|
-
// Affinity groups and queues
|
|
14
|
-
affinityGroups = new Map();
|
|
15
|
-
defaultQueue;
|
|
16
|
-
// Job tracking
|
|
17
|
-
jobStore = new Map();
|
|
18
|
-
executionContexts = new Map();
|
|
19
|
-
// Server state
|
|
20
|
-
idleServers = new Set();
|
|
21
|
-
serverPerformance = new Map();
|
|
22
|
-
// Pool configuration
|
|
23
|
-
options;
|
|
24
|
-
// Pool state
|
|
25
|
-
isReady;
|
|
26
|
-
readyResolve;
|
|
27
|
-
// =========================================================================
|
|
28
|
-
// CONSTRUCTOR
|
|
29
|
-
// =========================================================================
|
|
30
|
-
constructor(clients, options) {
|
|
31
|
-
super();
|
|
32
|
-
this.options = {
|
|
33
|
-
connectionTimeoutMs: options?.connectionTimeoutMs ?? 10000,
|
|
34
|
-
jobExecutionTimeoutMs: options?.jobExecutionTimeoutMs ?? 5 * 60 * 1000, // 5 min
|
|
35
|
-
groupIdleTimeoutMs: options?.groupIdleTimeoutMs ?? 60 * 1000, // 60 sec
|
|
36
|
-
maxQueueDepth: options?.maxQueueDepth ?? 1000
|
|
37
|
-
};
|
|
38
|
-
// Initialize clients
|
|
39
|
-
for (const client of clients) {
|
|
40
|
-
if (typeof client === "string") {
|
|
41
|
-
const apiClient = new ComfyApi(client);
|
|
42
|
-
this.clientMap.set(apiClient.apiHost, apiClient);
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
this.clientMap.set(client.apiHost, client);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// Create default queue for unaffinitized jobs
|
|
49
|
-
this.defaultQueue = this.createAffinityGroup("default", []);
|
|
50
|
-
// Setup ready promise
|
|
51
|
-
this.isReady = new Promise((resolve) => {
|
|
52
|
-
this.readyResolve = resolve;
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
// =========================================================================
|
|
56
|
-
// PUBLIC API
|
|
57
|
-
// =========================================================================
|
|
58
|
-
/**
|
|
59
|
-
* Initialize pool and connect all clients
|
|
60
|
-
*/
|
|
61
|
-
async connect() {
|
|
62
|
-
const connectionPromises = [];
|
|
63
|
-
for (const [url, client] of this.clientMap.entries()) {
|
|
64
|
-
connectionPromises.push(new Promise((resolve, reject) => {
|
|
65
|
-
const timeout = setTimeout(() => {
|
|
66
|
-
client.abortReconnect();
|
|
67
|
-
reject(new Error(`Connection to client ${url} timed out`));
|
|
68
|
-
}, this.options.connectionTimeoutMs);
|
|
69
|
-
client
|
|
70
|
-
.init(1)
|
|
71
|
-
.then(() => {
|
|
72
|
-
clearTimeout(timeout);
|
|
73
|
-
console.log(`[SmartPoolV2] Connected to ${url}`);
|
|
74
|
-
this.idleServers.add(client.apiHost);
|
|
75
|
-
resolve();
|
|
76
|
-
})
|
|
77
|
-
.catch((err) => {
|
|
78
|
-
clearTimeout(timeout);
|
|
79
|
-
reject(err);
|
|
80
|
-
});
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
|
-
await Promise.all(connectionPromises);
|
|
84
|
-
this.readyResolve?.();
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Wait for pool to be ready
|
|
88
|
-
*/
|
|
89
|
-
ready() {
|
|
90
|
-
return this.isReady;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Enqueue a workflow - automatically routed by workflow hash
|
|
94
|
-
* Optional preferredClientIds overrides default routing for this specific job
|
|
95
|
-
*/
|
|
96
|
-
async enqueue(workflow, options) {
|
|
97
|
-
const jobId = randomUUID();
|
|
98
|
-
const workflowHash = workflow.structureHash || hashWorkflow(workflow.json || workflow);
|
|
99
|
-
const workflowJson = workflow.json || workflow;
|
|
100
|
-
const outputNodeIds = workflow.outputNodeIds || [];
|
|
101
|
-
const outputAliases = workflow.outputAliases || {};
|
|
102
|
-
// Find group by workflow hash, fall back to default
|
|
103
|
-
let groupId = workflowHash;
|
|
104
|
-
if (!this.affinityGroups.has(groupId)) {
|
|
105
|
-
groupId = "default";
|
|
106
|
-
}
|
|
107
|
-
const group = this.affinityGroups.get(groupId);
|
|
108
|
-
if (!group) {
|
|
109
|
-
throw new Error(`No affinity group for workflow hash "${workflowHash}"`);
|
|
110
|
-
}
|
|
111
|
-
// Create job record
|
|
112
|
-
const jobRecord = {
|
|
113
|
-
jobId,
|
|
114
|
-
workflow: workflowJson,
|
|
115
|
-
workflowHash,
|
|
116
|
-
options: {
|
|
117
|
-
maxAttempts: 3,
|
|
118
|
-
retryDelayMs: 1000,
|
|
119
|
-
priority: options?.priority ?? 0,
|
|
120
|
-
// Use per-job preferences if provided, otherwise use group defaults
|
|
121
|
-
preferredClientIds: options?.preferredClientIds?.length ? options.preferredClientIds : group.preferredServerIds,
|
|
122
|
-
excludeClientIds: [],
|
|
123
|
-
metadata: options?.metadata || {}
|
|
124
|
-
},
|
|
125
|
-
attempts: 0,
|
|
126
|
-
enqueuedAt: Date.now(),
|
|
127
|
-
workflowMeta: {
|
|
128
|
-
outputNodeIds,
|
|
129
|
-
outputAliases
|
|
130
|
-
},
|
|
131
|
-
status: "queued"
|
|
132
|
-
};
|
|
133
|
-
// Store job
|
|
134
|
-
this.jobStore.set(jobId, jobRecord);
|
|
135
|
-
// Enqueue to group
|
|
136
|
-
const payload = jobRecord;
|
|
137
|
-
await group.queueAdapter.enqueue(payload, { priority: options?.priority ?? 0 });
|
|
138
|
-
// Emit event
|
|
139
|
-
this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: jobRecord } }));
|
|
140
|
-
// Trigger processing immediately (event-driven)
|
|
141
|
-
setImmediate(() => this.processAffinityGroup(groupId));
|
|
142
|
-
return jobId;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Set workflow affinity - auto-creates group by workflow hash
|
|
146
|
-
* Maps workflow hash to preferred servers
|
|
147
|
-
*/
|
|
148
|
-
setAffinity(workflow, affinity) {
|
|
149
|
-
const workflowHash = hashWorkflow(workflow);
|
|
150
|
-
// Create group with hash as ID if doesn't exist
|
|
151
|
-
if (!this.affinityGroups.has(workflowHash)) {
|
|
152
|
-
this.createAffinityGroup(workflowHash, affinity.preferredClientIds || []);
|
|
153
|
-
}
|
|
154
|
-
const group = this.affinityGroups.get(workflowHash);
|
|
155
|
-
if (group) {
|
|
156
|
-
group.workflowHashes.add(workflowHash);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Get job by ID
|
|
161
|
-
*/
|
|
162
|
-
getJob(jobId) {
|
|
163
|
-
return this.jobStore.get(jobId);
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Shutdown pool
|
|
167
|
-
*/
|
|
168
|
-
shutdown() {
|
|
169
|
-
// Cancel all timeouts
|
|
170
|
-
for (const group of this.affinityGroups.values()) {
|
|
171
|
-
if (group.idleTimeoutHandle) {
|
|
172
|
-
clearTimeout(group.idleTimeoutHandle);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (this.defaultQueue?.idleTimeoutHandle) {
|
|
176
|
-
clearTimeout(this.defaultQueue.idleTimeoutHandle);
|
|
177
|
-
}
|
|
178
|
-
// Cancel all job timeouts
|
|
179
|
-
for (const ctx of this.executionContexts.values()) {
|
|
180
|
-
if (ctx.timeoutHandle) {
|
|
181
|
-
clearTimeout(ctx.timeoutHandle);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
// Destroy clients
|
|
185
|
-
for (const client of this.clientMap.values()) {
|
|
186
|
-
try {
|
|
187
|
-
client.destroy();
|
|
188
|
-
}
|
|
189
|
-
catch (err) {
|
|
190
|
-
console.error(`[SmartPoolV2] Error destroying client: ${err}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Get server performance metrics
|
|
196
|
-
*/
|
|
197
|
-
getServerPerformance(clientId) {
|
|
198
|
-
return this.serverPerformance.get(clientId);
|
|
199
|
-
}
|
|
200
|
-
// =========================================================================
|
|
201
|
-
// PRIVATE: AFFINITY GROUP MANAGEMENT
|
|
202
|
-
// =========================================================================
|
|
203
|
-
createAffinityGroup(groupId, preferredServerIds) {
|
|
204
|
-
const group = {
|
|
205
|
-
id: groupId,
|
|
206
|
-
preferredServerIds,
|
|
207
|
-
workflowHashes: new Set(),
|
|
208
|
-
queueAdapter: new MemoryQueueAdapter(),
|
|
209
|
-
isProcessing: false,
|
|
210
|
-
lastJobCompletedMs: Date.now(),
|
|
211
|
-
jobsEnqueued: 0,
|
|
212
|
-
jobsCompleted: 0,
|
|
213
|
-
jobsFailed: 0
|
|
214
|
-
};
|
|
215
|
-
this.affinityGroups.set(groupId, group);
|
|
216
|
-
return group;
|
|
217
|
-
}
|
|
218
|
-
// =========================================================================
|
|
219
|
-
// PRIVATE: QUEUE PROCESSING (EVENT-DRIVEN)
|
|
220
|
-
// =========================================================================
|
|
221
|
-
/**
|
|
222
|
-
* Process affinity group queue - triggered by events only (no polling)
|
|
223
|
-
*/
|
|
224
|
-
async processAffinityGroup(groupId) {
|
|
225
|
-
const group = this.affinityGroups.get(groupId);
|
|
226
|
-
if (!group)
|
|
227
|
-
return;
|
|
228
|
-
// Reentrancy guard: if already processing, defer
|
|
229
|
-
if (group.isProcessing) {
|
|
230
|
-
if (!group.processingDeferred) {
|
|
231
|
-
group.processingDeferred = new Promise((resolve) => {
|
|
232
|
-
setImmediate(() => {
|
|
233
|
-
group.isProcessing = false;
|
|
234
|
-
this.processAffinityGroup(groupId).then(resolve);
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
return group.processingDeferred;
|
|
239
|
-
}
|
|
240
|
-
group.isProcessing = true;
|
|
241
|
-
try {
|
|
242
|
-
while (true) {
|
|
243
|
-
// Peek at waiting jobs first
|
|
244
|
-
const waitingJobs = await group.queueAdapter.peek(100);
|
|
245
|
-
if (waitingJobs.length === 0) {
|
|
246
|
-
break; // No waiting jobs
|
|
247
|
-
}
|
|
248
|
-
// Get the first waiting job
|
|
249
|
-
const jobPayload = waitingJobs[0];
|
|
250
|
-
const job = this.jobStore.get(jobPayload.jobId);
|
|
251
|
-
if (!job) {
|
|
252
|
-
// Job not found, discard from queue
|
|
253
|
-
await group.queueAdapter.discard(jobPayload.jobId, new Error("Job not found"));
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
// Find idle servers compatible with this specific job
|
|
257
|
-
// First check job's preferred clients, then group's preferred servers
|
|
258
|
-
const preferredServerIds = job.options.preferredClientIds?.length
|
|
259
|
-
? job.options.preferredClientIds
|
|
260
|
-
: group.preferredServerIds;
|
|
261
|
-
const compatibleIdleServers = Array.from(this.idleServers).filter((serverId) => {
|
|
262
|
-
// If preferred servers specified (job or group), must match
|
|
263
|
-
if (preferredServerIds.length > 0) {
|
|
264
|
-
return preferredServerIds.includes(serverId);
|
|
265
|
-
}
|
|
266
|
-
return true;
|
|
267
|
-
});
|
|
268
|
-
if (compatibleIdleServers.length === 0) {
|
|
269
|
-
break; // No idle compatible servers for this job
|
|
270
|
-
}
|
|
271
|
-
// Sort compatible servers by performance (fastest first)
|
|
272
|
-
const sortedServers = this.sortServersByPerformance(compatibleIdleServers);
|
|
273
|
-
const selectedServerId = sortedServers[0];
|
|
274
|
-
const selectedClient = this.clientMap.get(selectedServerId);
|
|
275
|
-
if (!selectedClient) {
|
|
276
|
-
break;
|
|
277
|
-
}
|
|
278
|
-
// Reserve job
|
|
279
|
-
const reservation = await group.queueAdapter.reserveById(jobPayload.jobId);
|
|
280
|
-
if (!reservation) {
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
// Mark server as no longer idle (synchronous)
|
|
284
|
-
this.idleServers.delete(selectedServerId);
|
|
285
|
-
// Enqueue job on server (synchronous, fires in background)
|
|
286
|
-
await this.enqueueJobOnServer(job, selectedClient, groupId, reservation);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
finally {
|
|
290
|
-
group.isProcessing = false;
|
|
291
|
-
// Check for deferred processing
|
|
292
|
-
if (group.processingDeferred) {
|
|
293
|
-
group.processingDeferred = undefined;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// =========================================================================
|
|
298
|
-
// PRIVATE: JOB EXECUTION (NO CALLWRAPPER)
|
|
299
|
-
// =========================================================================
|
|
300
|
-
/**
|
|
301
|
-
* Enqueue job on server and manage execution
|
|
302
|
-
*/
|
|
303
|
-
async enqueueJobOnServer(job, client, groupId, reservation) {
|
|
304
|
-
const group = this.affinityGroups.get(groupId);
|
|
305
|
-
if (!group)
|
|
306
|
-
return;
|
|
307
|
-
job.attempts += 1;
|
|
308
|
-
job.status = "running";
|
|
309
|
-
job.clientId = client.apiHost;
|
|
310
|
-
job.startedAt = Date.now();
|
|
311
|
-
try {
|
|
312
|
-
// Clone workflow to avoid mutations
|
|
313
|
-
const workflowJson = JSON.parse(JSON.stringify(job.workflow));
|
|
314
|
-
const outputNodeIds = job.workflowMeta?.outputNodeIds || [];
|
|
315
|
-
// Auto-randomize seeds
|
|
316
|
-
try {
|
|
317
|
-
for (const node of Object.values(workflowJson)) {
|
|
318
|
-
const n = node;
|
|
319
|
-
if (n?.inputs?.seed === -1) {
|
|
320
|
-
n.inputs.seed = Math.floor(Math.random() * 2_147_483_647);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
/* non-fatal */
|
|
326
|
-
}
|
|
327
|
-
// Build prompt
|
|
328
|
-
const pb = new PromptBuilder(workflowJson, [], outputNodeIds);
|
|
329
|
-
for (const nodeId of outputNodeIds) {
|
|
330
|
-
pb.setOutputNode(nodeId, nodeId);
|
|
331
|
-
}
|
|
332
|
-
const promptJson = pb.prompt;
|
|
333
|
-
// Append to server queue
|
|
334
|
-
let queueResponse;
|
|
335
|
-
try {
|
|
336
|
-
queueResponse = await client.ext.queue.appendPrompt(promptJson);
|
|
337
|
-
}
|
|
338
|
-
catch (err) {
|
|
339
|
-
throw new Error(`Failed to enqueue job: ${err}`);
|
|
340
|
-
}
|
|
341
|
-
const promptId = queueResponse.prompt_id;
|
|
342
|
-
job.promptId = promptId;
|
|
343
|
-
// Create execution context
|
|
344
|
-
const ctx = {
|
|
345
|
-
job,
|
|
346
|
-
groupId,
|
|
347
|
-
promptId
|
|
348
|
-
};
|
|
349
|
-
this.executionContexts.set(job.jobId, ctx);
|
|
350
|
-
// Emit accepted event
|
|
351
|
-
this.dispatchEvent(new CustomEvent("job:accepted", {
|
|
352
|
-
detail: { job, clientId: client.apiHost }
|
|
353
|
-
}));
|
|
354
|
-
this.dispatchEvent(new CustomEvent("job:started", {
|
|
355
|
-
detail: { job, clientId: client.apiHost, promptId }
|
|
356
|
-
}));
|
|
357
|
-
// Set up execution timeout (5 min)
|
|
358
|
-
ctx.timeoutHandle = setTimeout(() => {
|
|
359
|
-
console.warn(`[SmartPoolV2] Job ${job.jobId} execution timeout`);
|
|
360
|
-
this.handleJobTimeout(job.jobId);
|
|
361
|
-
}, this.options.jobExecutionTimeoutMs);
|
|
362
|
-
// Set up event listeners (strict prompt_id matching)
|
|
363
|
-
const outputMap = {};
|
|
364
|
-
let outputsCollected = 0;
|
|
365
|
-
const expectedOutputCount = outputNodeIds.length;
|
|
366
|
-
ctx.executedHandler = (ev) => {
|
|
367
|
-
// Strict prompt_id check
|
|
368
|
-
if (ev.detail.prompt_id !== promptId) {
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const nodeId = ev.detail.node;
|
|
372
|
-
const output = ev.detail.output;
|
|
373
|
-
outputMap[nodeId] = output;
|
|
374
|
-
outputsCollected++;
|
|
375
|
-
// All outputs collected?
|
|
376
|
-
if (outputsCollected === expectedOutputCount) {
|
|
377
|
-
this.handleJobCompletion(job.jobId, groupId, outputMap, client.apiHost);
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
ctx.executionSuccessHandler = async (ev) => {
|
|
381
|
-
if (ev.detail.prompt_id !== promptId) {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
// Try to get missing outputs from history if needed
|
|
385
|
-
if (outputsCollected < expectedOutputCount) {
|
|
386
|
-
try {
|
|
387
|
-
const history = await client.ext.history.getHistory(promptId);
|
|
388
|
-
if (history?.outputs) {
|
|
389
|
-
for (const [nodeIdStr, nodeOutput] of Object.entries(history.outputs)) {
|
|
390
|
-
const nodeId = nodeIdStr;
|
|
391
|
-
if (!outputMap[nodeId] && nodeOutput) {
|
|
392
|
-
outputMap[nodeId] = nodeOutput;
|
|
393
|
-
outputsCollected++;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
catch {
|
|
399
|
-
/* non-fatal */
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// Complete job regardless
|
|
403
|
-
this.handleJobCompletion(job.jobId, groupId, outputMap, client.apiHost);
|
|
404
|
-
};
|
|
405
|
-
ctx.executionErrorHandler = (ev) => {
|
|
406
|
-
if (ev.detail.prompt_id !== promptId) {
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
const error = new Error(`Execution error: ${ev.detail.exception_type}`);
|
|
410
|
-
this.handleJobFailure(job.jobId, groupId, error);
|
|
411
|
-
};
|
|
412
|
-
// Attach listeners
|
|
413
|
-
client.on("executed", ctx.executedHandler);
|
|
414
|
-
client.on("execution_success", ctx.executionSuccessHandler);
|
|
415
|
-
client.on("execution_error", ctx.executionErrorHandler);
|
|
416
|
-
// Commit to queue
|
|
417
|
-
await group.queueAdapter.commit(reservation.reservationId);
|
|
418
|
-
}
|
|
419
|
-
catch (err) {
|
|
420
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
421
|
-
// Retry or fail
|
|
422
|
-
if (job.attempts < job.options.maxAttempts) {
|
|
423
|
-
await group.queueAdapter.retry(reservation.reservationId, {
|
|
424
|
-
delayMs: job.options.retryDelayMs
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
else {
|
|
428
|
-
await group.queueAdapter.discard(reservation.reservationId, error);
|
|
429
|
-
this.handleJobFailure(job.jobId, groupId, error);
|
|
430
|
-
}
|
|
431
|
-
// Mark server idle again
|
|
432
|
-
this.idleServers.add(client.apiHost);
|
|
433
|
-
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
434
|
-
detail: { clientId: client.apiHost, groupId }
|
|
435
|
-
}));
|
|
436
|
-
// Trigger processing
|
|
437
|
-
setImmediate(() => this.processAffinityGroup(groupId));
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Handle job completion
|
|
442
|
-
*/
|
|
443
|
-
handleJobCompletion(jobId, groupId, outputMap, clientId) {
|
|
444
|
-
const job = this.jobStore.get(jobId);
|
|
445
|
-
if (!job)
|
|
446
|
-
return;
|
|
447
|
-
const ctx = this.executionContexts.get(jobId);
|
|
448
|
-
if (ctx?.timeoutHandle) {
|
|
449
|
-
clearTimeout(ctx.timeoutHandle);
|
|
450
|
-
}
|
|
451
|
-
// Clean up listeners
|
|
452
|
-
if (ctx) {
|
|
453
|
-
const client = this.clientMap.get(clientId);
|
|
454
|
-
if (client) {
|
|
455
|
-
if (ctx.executedHandler)
|
|
456
|
-
client.off("executed", ctx.executedHandler);
|
|
457
|
-
if (ctx.executionSuccessHandler)
|
|
458
|
-
client.off("execution_success", ctx.executionSuccessHandler);
|
|
459
|
-
if (ctx.executionErrorHandler)
|
|
460
|
-
client.off("execution_error", ctx.executionErrorHandler);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
// Update job
|
|
464
|
-
job.status = "completed";
|
|
465
|
-
job.result = outputMap;
|
|
466
|
-
job.completedAt = Date.now();
|
|
467
|
-
const executionTimeMs = job.completedAt - (job.startedAt || job.completedAt);
|
|
468
|
-
this.updateServerPerformance(clientId, executionTimeMs);
|
|
469
|
-
// Update group stats
|
|
470
|
-
const group = this.affinityGroups.get(groupId);
|
|
471
|
-
if (group) {
|
|
472
|
-
group.jobsCompleted++;
|
|
473
|
-
group.lastJobCompletedMs = Date.now();
|
|
474
|
-
// Reset idle timeout for this group
|
|
475
|
-
if (group.idleTimeoutHandle) {
|
|
476
|
-
clearTimeout(group.idleTimeoutHandle);
|
|
477
|
-
}
|
|
478
|
-
group.idleTimeoutHandle = setTimeout(() => {
|
|
479
|
-
this.dispatchEvent(new CustomEvent("group:idle-timeout", {
|
|
480
|
-
detail: { groupId, reason: "No jobs completed in idle threshold" }
|
|
481
|
-
}));
|
|
482
|
-
}, this.options.groupIdleTimeoutMs);
|
|
483
|
-
}
|
|
484
|
-
// Mark server idle
|
|
485
|
-
this.idleServers.add(clientId);
|
|
486
|
-
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
487
|
-
detail: { clientId, groupId }
|
|
488
|
-
}));
|
|
489
|
-
// Emit completed event
|
|
490
|
-
this.dispatchEvent(new CustomEvent("job:completed", { detail: { job } }));
|
|
491
|
-
// Clean up context
|
|
492
|
-
this.executionContexts.delete(jobId);
|
|
493
|
-
// Trigger processing
|
|
494
|
-
setImmediate(() => this.processAffinityGroup(groupId));
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Handle job failure
|
|
498
|
-
*/
|
|
499
|
-
handleJobFailure(jobId, groupId, error) {
|
|
500
|
-
const job = this.jobStore.get(jobId);
|
|
501
|
-
if (!job)
|
|
502
|
-
return;
|
|
503
|
-
const ctx = this.executionContexts.get(jobId);
|
|
504
|
-
if (ctx?.timeoutHandle) {
|
|
505
|
-
clearTimeout(ctx.timeoutHandle);
|
|
506
|
-
}
|
|
507
|
-
// Clean up listeners
|
|
508
|
-
if (ctx && job.clientId) {
|
|
509
|
-
const client = this.clientMap.get(job.clientId);
|
|
510
|
-
if (client) {
|
|
511
|
-
if (ctx.executedHandler)
|
|
512
|
-
client.off("executed", ctx.executedHandler);
|
|
513
|
-
if (ctx.executionSuccessHandler)
|
|
514
|
-
client.off("execution_success", ctx.executionSuccessHandler);
|
|
515
|
-
if (ctx.executionErrorHandler)
|
|
516
|
-
client.off("execution_error", ctx.executionErrorHandler);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
job.status = "failed";
|
|
520
|
-
job.lastError = error;
|
|
521
|
-
job.completedAt = Date.now();
|
|
522
|
-
// Update group stats
|
|
523
|
-
const group = this.affinityGroups.get(groupId);
|
|
524
|
-
if (group) {
|
|
525
|
-
group.jobsFailed++;
|
|
526
|
-
}
|
|
527
|
-
// Mark server idle
|
|
528
|
-
if (job.clientId) {
|
|
529
|
-
this.idleServers.add(job.clientId);
|
|
530
|
-
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
531
|
-
detail: { clientId: job.clientId, groupId }
|
|
532
|
-
}));
|
|
533
|
-
}
|
|
534
|
-
// Emit failed event
|
|
535
|
-
this.dispatchEvent(new CustomEvent("job:failed", {
|
|
536
|
-
detail: { job, error, willRetry: false }
|
|
537
|
-
}));
|
|
538
|
-
// Clean up context
|
|
539
|
-
this.executionContexts.delete(jobId);
|
|
540
|
-
// Trigger processing
|
|
541
|
-
setImmediate(() => this.processAffinityGroup(groupId));
|
|
542
|
-
}
|
|
543
|
-
/**
|
|
544
|
-
* Handle job timeout
|
|
545
|
-
*/
|
|
546
|
-
handleJobTimeout(jobId) {
|
|
547
|
-
const job = this.jobStore.get(jobId);
|
|
548
|
-
if (!job)
|
|
549
|
-
return;
|
|
550
|
-
const ctx = this.executionContexts.get(jobId);
|
|
551
|
-
const groupId = ctx?.groupId || "default";
|
|
552
|
-
const error = new Error(`Job execution timeout after ${this.options.jobExecutionTimeoutMs}ms`);
|
|
553
|
-
this.handleJobFailure(jobId, groupId, error);
|
|
554
|
-
}
|
|
555
|
-
// =========================================================================
|
|
556
|
-
// PRIVATE: PERFORMANCE TRACKING
|
|
557
|
-
// =========================================================================
|
|
558
|
-
updateServerPerformance(clientId, executionTimeMs) {
|
|
559
|
-
let metrics = this.serverPerformance.get(clientId);
|
|
560
|
-
if (!metrics) {
|
|
561
|
-
metrics = {
|
|
562
|
-
clientId,
|
|
563
|
-
totalJobsCompleted: 0,
|
|
564
|
-
totalExecutionTimeMs: 0,
|
|
565
|
-
averageExecutionTimeMs: 0
|
|
566
|
-
};
|
|
567
|
-
this.serverPerformance.set(clientId, metrics);
|
|
568
|
-
}
|
|
569
|
-
metrics.totalJobsCompleted++;
|
|
570
|
-
metrics.totalExecutionTimeMs += executionTimeMs;
|
|
571
|
-
metrics.lastJobDurationMs = executionTimeMs;
|
|
572
|
-
metrics.averageExecutionTimeMs = metrics.totalExecutionTimeMs / metrics.totalJobsCompleted;
|
|
573
|
-
}
|
|
574
|
-
sortServersByPerformance(serverIds) {
|
|
575
|
-
return [...serverIds].sort((a, b) => {
|
|
576
|
-
const metricsA = this.serverPerformance.get(a);
|
|
577
|
-
const metricsB = this.serverPerformance.get(b);
|
|
578
|
-
// Untracked servers go to end
|
|
579
|
-
if (!metricsA)
|
|
580
|
-
return 1;
|
|
581
|
-
if (!metricsB)
|
|
582
|
-
return -1;
|
|
583
|
-
return metricsA.averageExecutionTimeMs - metricsB.averageExecutionTimeMs;
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { TypedEventTarget } from "../typed-event-target.js";
|
|
3
|
+
import { ComfyApi } from "../client.js";
|
|
4
|
+
import { PromptBuilder } from "../prompt-builder.js";
|
|
5
|
+
import { MemoryQueueAdapter } from "./queue/adapters/memory.js";
|
|
6
|
+
import { hashWorkflow } from "./utils/hash.js";
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// MAIN CLASS
|
|
9
|
+
// ============================================================================
|
|
10
|
+
export class SmartPoolV2 extends TypedEventTarget {
|
|
11
|
+
// Client management
|
|
12
|
+
clientMap = new Map();
|
|
13
|
+
// Affinity groups and queues
|
|
14
|
+
affinityGroups = new Map();
|
|
15
|
+
defaultQueue;
|
|
16
|
+
// Job tracking
|
|
17
|
+
jobStore = new Map();
|
|
18
|
+
executionContexts = new Map();
|
|
19
|
+
// Server state
|
|
20
|
+
idleServers = new Set();
|
|
21
|
+
serverPerformance = new Map();
|
|
22
|
+
// Pool configuration
|
|
23
|
+
options;
|
|
24
|
+
// Pool state
|
|
25
|
+
isReady;
|
|
26
|
+
readyResolve;
|
|
27
|
+
// =========================================================================
|
|
28
|
+
// CONSTRUCTOR
|
|
29
|
+
// =========================================================================
|
|
30
|
+
constructor(clients, options) {
|
|
31
|
+
super();
|
|
32
|
+
this.options = {
|
|
33
|
+
connectionTimeoutMs: options?.connectionTimeoutMs ?? 10000,
|
|
34
|
+
jobExecutionTimeoutMs: options?.jobExecutionTimeoutMs ?? 5 * 60 * 1000, // 5 min
|
|
35
|
+
groupIdleTimeoutMs: options?.groupIdleTimeoutMs ?? 60 * 1000, // 60 sec
|
|
36
|
+
maxQueueDepth: options?.maxQueueDepth ?? 1000
|
|
37
|
+
};
|
|
38
|
+
// Initialize clients
|
|
39
|
+
for (const client of clients) {
|
|
40
|
+
if (typeof client === "string") {
|
|
41
|
+
const apiClient = new ComfyApi(client);
|
|
42
|
+
this.clientMap.set(apiClient.apiHost, apiClient);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
this.clientMap.set(client.apiHost, client);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Create default queue for unaffinitized jobs
|
|
49
|
+
this.defaultQueue = this.createAffinityGroup("default", []);
|
|
50
|
+
// Setup ready promise
|
|
51
|
+
this.isReady = new Promise((resolve) => {
|
|
52
|
+
this.readyResolve = resolve;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// =========================================================================
|
|
56
|
+
// PUBLIC API
|
|
57
|
+
// =========================================================================
|
|
58
|
+
/**
|
|
59
|
+
* Initialize pool and connect all clients
|
|
60
|
+
*/
|
|
61
|
+
async connect() {
|
|
62
|
+
const connectionPromises = [];
|
|
63
|
+
for (const [url, client] of this.clientMap.entries()) {
|
|
64
|
+
connectionPromises.push(new Promise((resolve, reject) => {
|
|
65
|
+
const timeout = setTimeout(() => {
|
|
66
|
+
client.abortReconnect();
|
|
67
|
+
reject(new Error(`Connection to client ${url} timed out`));
|
|
68
|
+
}, this.options.connectionTimeoutMs);
|
|
69
|
+
client
|
|
70
|
+
.init(1)
|
|
71
|
+
.then(() => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
console.log(`[SmartPoolV2] Connected to ${url}`);
|
|
74
|
+
this.idleServers.add(client.apiHost);
|
|
75
|
+
resolve();
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
reject(err);
|
|
80
|
+
});
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
await Promise.all(connectionPromises);
|
|
84
|
+
this.readyResolve?.();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Wait for pool to be ready
|
|
88
|
+
*/
|
|
89
|
+
ready() {
|
|
90
|
+
return this.isReady;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Enqueue a workflow - automatically routed by workflow hash
|
|
94
|
+
* Optional preferredClientIds overrides default routing for this specific job
|
|
95
|
+
*/
|
|
96
|
+
async enqueue(workflow, options) {
|
|
97
|
+
const jobId = randomUUID();
|
|
98
|
+
const workflowHash = workflow.structureHash || hashWorkflow(workflow.json || workflow);
|
|
99
|
+
const workflowJson = workflow.json || workflow;
|
|
100
|
+
const outputNodeIds = workflow.outputNodeIds || [];
|
|
101
|
+
const outputAliases = workflow.outputAliases || {};
|
|
102
|
+
// Find group by workflow hash, fall back to default
|
|
103
|
+
let groupId = workflowHash;
|
|
104
|
+
if (!this.affinityGroups.has(groupId)) {
|
|
105
|
+
groupId = "default";
|
|
106
|
+
}
|
|
107
|
+
const group = this.affinityGroups.get(groupId);
|
|
108
|
+
if (!group) {
|
|
109
|
+
throw new Error(`No affinity group for workflow hash "${workflowHash}"`);
|
|
110
|
+
}
|
|
111
|
+
// Create job record
|
|
112
|
+
const jobRecord = {
|
|
113
|
+
jobId,
|
|
114
|
+
workflow: workflowJson,
|
|
115
|
+
workflowHash,
|
|
116
|
+
options: {
|
|
117
|
+
maxAttempts: 3,
|
|
118
|
+
retryDelayMs: 1000,
|
|
119
|
+
priority: options?.priority ?? 0,
|
|
120
|
+
// Use per-job preferences if provided, otherwise use group defaults
|
|
121
|
+
preferredClientIds: options?.preferredClientIds?.length ? options.preferredClientIds : group.preferredServerIds,
|
|
122
|
+
excludeClientIds: [],
|
|
123
|
+
metadata: options?.metadata || {}
|
|
124
|
+
},
|
|
125
|
+
attempts: 0,
|
|
126
|
+
enqueuedAt: Date.now(),
|
|
127
|
+
workflowMeta: {
|
|
128
|
+
outputNodeIds,
|
|
129
|
+
outputAliases
|
|
130
|
+
},
|
|
131
|
+
status: "queued"
|
|
132
|
+
};
|
|
133
|
+
// Store job
|
|
134
|
+
this.jobStore.set(jobId, jobRecord);
|
|
135
|
+
// Enqueue to group
|
|
136
|
+
const payload = jobRecord;
|
|
137
|
+
await group.queueAdapter.enqueue(payload, { priority: options?.priority ?? 0 });
|
|
138
|
+
// Emit event
|
|
139
|
+
this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: jobRecord } }));
|
|
140
|
+
// Trigger processing immediately (event-driven)
|
|
141
|
+
setImmediate(() => this.processAffinityGroup(groupId));
|
|
142
|
+
return jobId;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Set workflow affinity - auto-creates group by workflow hash
|
|
146
|
+
* Maps workflow hash to preferred servers
|
|
147
|
+
*/
|
|
148
|
+
setAffinity(workflow, affinity) {
|
|
149
|
+
const workflowHash = hashWorkflow(workflow);
|
|
150
|
+
// Create group with hash as ID if doesn't exist
|
|
151
|
+
if (!this.affinityGroups.has(workflowHash)) {
|
|
152
|
+
this.createAffinityGroup(workflowHash, affinity.preferredClientIds || []);
|
|
153
|
+
}
|
|
154
|
+
const group = this.affinityGroups.get(workflowHash);
|
|
155
|
+
if (group) {
|
|
156
|
+
group.workflowHashes.add(workflowHash);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get job by ID
|
|
161
|
+
*/
|
|
162
|
+
getJob(jobId) {
|
|
163
|
+
return this.jobStore.get(jobId);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Shutdown pool
|
|
167
|
+
*/
|
|
168
|
+
shutdown() {
|
|
169
|
+
// Cancel all timeouts
|
|
170
|
+
for (const group of this.affinityGroups.values()) {
|
|
171
|
+
if (group.idleTimeoutHandle) {
|
|
172
|
+
clearTimeout(group.idleTimeoutHandle);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (this.defaultQueue?.idleTimeoutHandle) {
|
|
176
|
+
clearTimeout(this.defaultQueue.idleTimeoutHandle);
|
|
177
|
+
}
|
|
178
|
+
// Cancel all job timeouts
|
|
179
|
+
for (const ctx of this.executionContexts.values()) {
|
|
180
|
+
if (ctx.timeoutHandle) {
|
|
181
|
+
clearTimeout(ctx.timeoutHandle);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Destroy clients
|
|
185
|
+
for (const client of this.clientMap.values()) {
|
|
186
|
+
try {
|
|
187
|
+
client.destroy();
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
console.error(`[SmartPoolV2] Error destroying client: ${err}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get server performance metrics
|
|
196
|
+
*/
|
|
197
|
+
getServerPerformance(clientId) {
|
|
198
|
+
return this.serverPerformance.get(clientId);
|
|
199
|
+
}
|
|
200
|
+
// =========================================================================
|
|
201
|
+
// PRIVATE: AFFINITY GROUP MANAGEMENT
|
|
202
|
+
// =========================================================================
|
|
203
|
+
createAffinityGroup(groupId, preferredServerIds) {
|
|
204
|
+
const group = {
|
|
205
|
+
id: groupId,
|
|
206
|
+
preferredServerIds,
|
|
207
|
+
workflowHashes: new Set(),
|
|
208
|
+
queueAdapter: new MemoryQueueAdapter(),
|
|
209
|
+
isProcessing: false,
|
|
210
|
+
lastJobCompletedMs: Date.now(),
|
|
211
|
+
jobsEnqueued: 0,
|
|
212
|
+
jobsCompleted: 0,
|
|
213
|
+
jobsFailed: 0
|
|
214
|
+
};
|
|
215
|
+
this.affinityGroups.set(groupId, group);
|
|
216
|
+
return group;
|
|
217
|
+
}
|
|
218
|
+
// =========================================================================
|
|
219
|
+
// PRIVATE: QUEUE PROCESSING (EVENT-DRIVEN)
|
|
220
|
+
// =========================================================================
|
|
221
|
+
/**
|
|
222
|
+
* Process affinity group queue - triggered by events only (no polling)
|
|
223
|
+
*/
|
|
224
|
+
async processAffinityGroup(groupId) {
|
|
225
|
+
const group = this.affinityGroups.get(groupId);
|
|
226
|
+
if (!group)
|
|
227
|
+
return;
|
|
228
|
+
// Reentrancy guard: if already processing, defer
|
|
229
|
+
if (group.isProcessing) {
|
|
230
|
+
if (!group.processingDeferred) {
|
|
231
|
+
group.processingDeferred = new Promise((resolve) => {
|
|
232
|
+
setImmediate(() => {
|
|
233
|
+
group.isProcessing = false;
|
|
234
|
+
this.processAffinityGroup(groupId).then(resolve);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return group.processingDeferred;
|
|
239
|
+
}
|
|
240
|
+
group.isProcessing = true;
|
|
241
|
+
try {
|
|
242
|
+
while (true) {
|
|
243
|
+
// Peek at waiting jobs first
|
|
244
|
+
const waitingJobs = await group.queueAdapter.peek(100);
|
|
245
|
+
if (waitingJobs.length === 0) {
|
|
246
|
+
break; // No waiting jobs
|
|
247
|
+
}
|
|
248
|
+
// Get the first waiting job
|
|
249
|
+
const jobPayload = waitingJobs[0];
|
|
250
|
+
const job = this.jobStore.get(jobPayload.jobId);
|
|
251
|
+
if (!job) {
|
|
252
|
+
// Job not found, discard from queue
|
|
253
|
+
await group.queueAdapter.discard(jobPayload.jobId, new Error("Job not found"));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// Find idle servers compatible with this specific job
|
|
257
|
+
// First check job's preferred clients, then group's preferred servers
|
|
258
|
+
const preferredServerIds = job.options.preferredClientIds?.length
|
|
259
|
+
? job.options.preferredClientIds
|
|
260
|
+
: group.preferredServerIds;
|
|
261
|
+
const compatibleIdleServers = Array.from(this.idleServers).filter((serverId) => {
|
|
262
|
+
// If preferred servers specified (job or group), must match
|
|
263
|
+
if (preferredServerIds.length > 0) {
|
|
264
|
+
return preferredServerIds.includes(serverId);
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
if (compatibleIdleServers.length === 0) {
|
|
269
|
+
break; // No idle compatible servers for this job
|
|
270
|
+
}
|
|
271
|
+
// Sort compatible servers by performance (fastest first)
|
|
272
|
+
const sortedServers = this.sortServersByPerformance(compatibleIdleServers);
|
|
273
|
+
const selectedServerId = sortedServers[0];
|
|
274
|
+
const selectedClient = this.clientMap.get(selectedServerId);
|
|
275
|
+
if (!selectedClient) {
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
// Reserve job
|
|
279
|
+
const reservation = await group.queueAdapter.reserveById(jobPayload.jobId);
|
|
280
|
+
if (!reservation) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// Mark server as no longer idle (synchronous)
|
|
284
|
+
this.idleServers.delete(selectedServerId);
|
|
285
|
+
// Enqueue job on server (synchronous, fires in background)
|
|
286
|
+
await this.enqueueJobOnServer(job, selectedClient, groupId, reservation);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
group.isProcessing = false;
|
|
291
|
+
// Check for deferred processing
|
|
292
|
+
if (group.processingDeferred) {
|
|
293
|
+
group.processingDeferred = undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// =========================================================================
|
|
298
|
+
// PRIVATE: JOB EXECUTION (NO CALLWRAPPER)
|
|
299
|
+
// =========================================================================
|
|
300
|
+
/**
|
|
301
|
+
* Enqueue job on server and manage execution
|
|
302
|
+
*/
|
|
303
|
+
async enqueueJobOnServer(job, client, groupId, reservation) {
|
|
304
|
+
const group = this.affinityGroups.get(groupId);
|
|
305
|
+
if (!group)
|
|
306
|
+
return;
|
|
307
|
+
job.attempts += 1;
|
|
308
|
+
job.status = "running";
|
|
309
|
+
job.clientId = client.apiHost;
|
|
310
|
+
job.startedAt = Date.now();
|
|
311
|
+
try {
|
|
312
|
+
// Clone workflow to avoid mutations
|
|
313
|
+
const workflowJson = JSON.parse(JSON.stringify(job.workflow));
|
|
314
|
+
const outputNodeIds = job.workflowMeta?.outputNodeIds || [];
|
|
315
|
+
// Auto-randomize seeds
|
|
316
|
+
try {
|
|
317
|
+
for (const node of Object.values(workflowJson)) {
|
|
318
|
+
const n = node;
|
|
319
|
+
if (n?.inputs?.seed === -1) {
|
|
320
|
+
n.inputs.seed = Math.floor(Math.random() * 2_147_483_647);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
/* non-fatal */
|
|
326
|
+
}
|
|
327
|
+
// Build prompt
|
|
328
|
+
const pb = new PromptBuilder(workflowJson, [], outputNodeIds);
|
|
329
|
+
for (const nodeId of outputNodeIds) {
|
|
330
|
+
pb.setOutputNode(nodeId, nodeId);
|
|
331
|
+
}
|
|
332
|
+
const promptJson = pb.prompt;
|
|
333
|
+
// Append to server queue
|
|
334
|
+
let queueResponse;
|
|
335
|
+
try {
|
|
336
|
+
queueResponse = await client.ext.queue.appendPrompt(promptJson);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
throw new Error(`Failed to enqueue job: ${err}`);
|
|
340
|
+
}
|
|
341
|
+
const promptId = queueResponse.prompt_id;
|
|
342
|
+
job.promptId = promptId;
|
|
343
|
+
// Create execution context
|
|
344
|
+
const ctx = {
|
|
345
|
+
job,
|
|
346
|
+
groupId,
|
|
347
|
+
promptId
|
|
348
|
+
};
|
|
349
|
+
this.executionContexts.set(job.jobId, ctx);
|
|
350
|
+
// Emit accepted event
|
|
351
|
+
this.dispatchEvent(new CustomEvent("job:accepted", {
|
|
352
|
+
detail: { job, clientId: client.apiHost }
|
|
353
|
+
}));
|
|
354
|
+
this.dispatchEvent(new CustomEvent("job:started", {
|
|
355
|
+
detail: { job, clientId: client.apiHost, promptId }
|
|
356
|
+
}));
|
|
357
|
+
// Set up execution timeout (5 min)
|
|
358
|
+
ctx.timeoutHandle = setTimeout(() => {
|
|
359
|
+
console.warn(`[SmartPoolV2] Job ${job.jobId} execution timeout`);
|
|
360
|
+
this.handleJobTimeout(job.jobId);
|
|
361
|
+
}, this.options.jobExecutionTimeoutMs);
|
|
362
|
+
// Set up event listeners (strict prompt_id matching)
|
|
363
|
+
const outputMap = {};
|
|
364
|
+
let outputsCollected = 0;
|
|
365
|
+
const expectedOutputCount = outputNodeIds.length;
|
|
366
|
+
ctx.executedHandler = (ev) => {
|
|
367
|
+
// Strict prompt_id check
|
|
368
|
+
if (ev.detail.prompt_id !== promptId) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const nodeId = ev.detail.node;
|
|
372
|
+
const output = ev.detail.output;
|
|
373
|
+
outputMap[nodeId] = output;
|
|
374
|
+
outputsCollected++;
|
|
375
|
+
// All outputs collected?
|
|
376
|
+
if (outputsCollected === expectedOutputCount) {
|
|
377
|
+
this.handleJobCompletion(job.jobId, groupId, outputMap, client.apiHost);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
ctx.executionSuccessHandler = async (ev) => {
|
|
381
|
+
if (ev.detail.prompt_id !== promptId) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// Try to get missing outputs from history if needed
|
|
385
|
+
if (outputsCollected < expectedOutputCount) {
|
|
386
|
+
try {
|
|
387
|
+
const history = await client.ext.history.getHistory(promptId);
|
|
388
|
+
if (history?.outputs) {
|
|
389
|
+
for (const [nodeIdStr, nodeOutput] of Object.entries(history.outputs)) {
|
|
390
|
+
const nodeId = nodeIdStr;
|
|
391
|
+
if (!outputMap[nodeId] && nodeOutput) {
|
|
392
|
+
outputMap[nodeId] = nodeOutput;
|
|
393
|
+
outputsCollected++;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* non-fatal */
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Complete job regardless
|
|
403
|
+
this.handleJobCompletion(job.jobId, groupId, outputMap, client.apiHost);
|
|
404
|
+
};
|
|
405
|
+
ctx.executionErrorHandler = (ev) => {
|
|
406
|
+
if (ev.detail.prompt_id !== promptId) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const error = new Error(`Execution error: ${ev.detail.exception_type}`);
|
|
410
|
+
this.handleJobFailure(job.jobId, groupId, error);
|
|
411
|
+
};
|
|
412
|
+
// Attach listeners
|
|
413
|
+
client.on("executed", ctx.executedHandler);
|
|
414
|
+
client.on("execution_success", ctx.executionSuccessHandler);
|
|
415
|
+
client.on("execution_error", ctx.executionErrorHandler);
|
|
416
|
+
// Commit to queue
|
|
417
|
+
await group.queueAdapter.commit(reservation.reservationId);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
421
|
+
// Retry or fail
|
|
422
|
+
if (job.attempts < job.options.maxAttempts) {
|
|
423
|
+
await group.queueAdapter.retry(reservation.reservationId, {
|
|
424
|
+
delayMs: job.options.retryDelayMs
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
await group.queueAdapter.discard(reservation.reservationId, error);
|
|
429
|
+
this.handleJobFailure(job.jobId, groupId, error);
|
|
430
|
+
}
|
|
431
|
+
// Mark server idle again
|
|
432
|
+
this.idleServers.add(client.apiHost);
|
|
433
|
+
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
434
|
+
detail: { clientId: client.apiHost, groupId }
|
|
435
|
+
}));
|
|
436
|
+
// Trigger processing
|
|
437
|
+
setImmediate(() => this.processAffinityGroup(groupId));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Handle job completion
|
|
442
|
+
*/
|
|
443
|
+
handleJobCompletion(jobId, groupId, outputMap, clientId) {
|
|
444
|
+
const job = this.jobStore.get(jobId);
|
|
445
|
+
if (!job)
|
|
446
|
+
return;
|
|
447
|
+
const ctx = this.executionContexts.get(jobId);
|
|
448
|
+
if (ctx?.timeoutHandle) {
|
|
449
|
+
clearTimeout(ctx.timeoutHandle);
|
|
450
|
+
}
|
|
451
|
+
// Clean up listeners
|
|
452
|
+
if (ctx) {
|
|
453
|
+
const client = this.clientMap.get(clientId);
|
|
454
|
+
if (client) {
|
|
455
|
+
if (ctx.executedHandler)
|
|
456
|
+
client.off("executed", ctx.executedHandler);
|
|
457
|
+
if (ctx.executionSuccessHandler)
|
|
458
|
+
client.off("execution_success", ctx.executionSuccessHandler);
|
|
459
|
+
if (ctx.executionErrorHandler)
|
|
460
|
+
client.off("execution_error", ctx.executionErrorHandler);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Update job
|
|
464
|
+
job.status = "completed";
|
|
465
|
+
job.result = outputMap;
|
|
466
|
+
job.completedAt = Date.now();
|
|
467
|
+
const executionTimeMs = job.completedAt - (job.startedAt || job.completedAt);
|
|
468
|
+
this.updateServerPerformance(clientId, executionTimeMs);
|
|
469
|
+
// Update group stats
|
|
470
|
+
const group = this.affinityGroups.get(groupId);
|
|
471
|
+
if (group) {
|
|
472
|
+
group.jobsCompleted++;
|
|
473
|
+
group.lastJobCompletedMs = Date.now();
|
|
474
|
+
// Reset idle timeout for this group
|
|
475
|
+
if (group.idleTimeoutHandle) {
|
|
476
|
+
clearTimeout(group.idleTimeoutHandle);
|
|
477
|
+
}
|
|
478
|
+
group.idleTimeoutHandle = setTimeout(() => {
|
|
479
|
+
this.dispatchEvent(new CustomEvent("group:idle-timeout", {
|
|
480
|
+
detail: { groupId, reason: "No jobs completed in idle threshold" }
|
|
481
|
+
}));
|
|
482
|
+
}, this.options.groupIdleTimeoutMs);
|
|
483
|
+
}
|
|
484
|
+
// Mark server idle
|
|
485
|
+
this.idleServers.add(clientId);
|
|
486
|
+
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
487
|
+
detail: { clientId, groupId }
|
|
488
|
+
}));
|
|
489
|
+
// Emit completed event
|
|
490
|
+
this.dispatchEvent(new CustomEvent("job:completed", { detail: { job } }));
|
|
491
|
+
// Clean up context
|
|
492
|
+
this.executionContexts.delete(jobId);
|
|
493
|
+
// Trigger processing
|
|
494
|
+
setImmediate(() => this.processAffinityGroup(groupId));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Handle job failure
|
|
498
|
+
*/
|
|
499
|
+
handleJobFailure(jobId, groupId, error) {
|
|
500
|
+
const job = this.jobStore.get(jobId);
|
|
501
|
+
if (!job)
|
|
502
|
+
return;
|
|
503
|
+
const ctx = this.executionContexts.get(jobId);
|
|
504
|
+
if (ctx?.timeoutHandle) {
|
|
505
|
+
clearTimeout(ctx.timeoutHandle);
|
|
506
|
+
}
|
|
507
|
+
// Clean up listeners
|
|
508
|
+
if (ctx && job.clientId) {
|
|
509
|
+
const client = this.clientMap.get(job.clientId);
|
|
510
|
+
if (client) {
|
|
511
|
+
if (ctx.executedHandler)
|
|
512
|
+
client.off("executed", ctx.executedHandler);
|
|
513
|
+
if (ctx.executionSuccessHandler)
|
|
514
|
+
client.off("execution_success", ctx.executionSuccessHandler);
|
|
515
|
+
if (ctx.executionErrorHandler)
|
|
516
|
+
client.off("execution_error", ctx.executionErrorHandler);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
job.status = "failed";
|
|
520
|
+
job.lastError = error;
|
|
521
|
+
job.completedAt = Date.now();
|
|
522
|
+
// Update group stats
|
|
523
|
+
const group = this.affinityGroups.get(groupId);
|
|
524
|
+
if (group) {
|
|
525
|
+
group.jobsFailed++;
|
|
526
|
+
}
|
|
527
|
+
// Mark server idle
|
|
528
|
+
if (job.clientId) {
|
|
529
|
+
this.idleServers.add(job.clientId);
|
|
530
|
+
this.dispatchEvent(new CustomEvent("server:idle", {
|
|
531
|
+
detail: { clientId: job.clientId, groupId }
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
// Emit failed event
|
|
535
|
+
this.dispatchEvent(new CustomEvent("job:failed", {
|
|
536
|
+
detail: { job, error, willRetry: false }
|
|
537
|
+
}));
|
|
538
|
+
// Clean up context
|
|
539
|
+
this.executionContexts.delete(jobId);
|
|
540
|
+
// Trigger processing
|
|
541
|
+
setImmediate(() => this.processAffinityGroup(groupId));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Handle job timeout
|
|
545
|
+
*/
|
|
546
|
+
handleJobTimeout(jobId) {
|
|
547
|
+
const job = this.jobStore.get(jobId);
|
|
548
|
+
if (!job)
|
|
549
|
+
return;
|
|
550
|
+
const ctx = this.executionContexts.get(jobId);
|
|
551
|
+
const groupId = ctx?.groupId || "default";
|
|
552
|
+
const error = new Error(`Job execution timeout after ${this.options.jobExecutionTimeoutMs}ms`);
|
|
553
|
+
this.handleJobFailure(jobId, groupId, error);
|
|
554
|
+
}
|
|
555
|
+
// =========================================================================
|
|
556
|
+
// PRIVATE: PERFORMANCE TRACKING
|
|
557
|
+
// =========================================================================
|
|
558
|
+
updateServerPerformance(clientId, executionTimeMs) {
|
|
559
|
+
let metrics = this.serverPerformance.get(clientId);
|
|
560
|
+
if (!metrics) {
|
|
561
|
+
metrics = {
|
|
562
|
+
clientId,
|
|
563
|
+
totalJobsCompleted: 0,
|
|
564
|
+
totalExecutionTimeMs: 0,
|
|
565
|
+
averageExecutionTimeMs: 0
|
|
566
|
+
};
|
|
567
|
+
this.serverPerformance.set(clientId, metrics);
|
|
568
|
+
}
|
|
569
|
+
metrics.totalJobsCompleted++;
|
|
570
|
+
metrics.totalExecutionTimeMs += executionTimeMs;
|
|
571
|
+
metrics.lastJobDurationMs = executionTimeMs;
|
|
572
|
+
metrics.averageExecutionTimeMs = metrics.totalExecutionTimeMs / metrics.totalJobsCompleted;
|
|
573
|
+
}
|
|
574
|
+
sortServersByPerformance(serverIds) {
|
|
575
|
+
return [...serverIds].sort((a, b) => {
|
|
576
|
+
const metricsA = this.serverPerformance.get(a);
|
|
577
|
+
const metricsB = this.serverPerformance.get(b);
|
|
578
|
+
// Untracked servers go to end
|
|
579
|
+
if (!metricsA)
|
|
580
|
+
return 1;
|
|
581
|
+
if (!metricsB)
|
|
582
|
+
return -1;
|
|
583
|
+
return metricsA.averageExecutionTimeMs - metricsB.averageExecutionTimeMs;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
587
|
//# sourceMappingURL=SmartPoolV2.js.map
|