Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/stacks/executionRuntimeController.js
T
Ewald b2ef3d0f80 fix(assets): improve grid reload and scroll behavior
- Adjusting the infinite scroll loading point to 4 card rows from the bottom
- Improving grid state restoration after reloads
- Enhancing the early fetch mechanism for faster initial loads
- Optimizing execution runtime to handle large numbers of assets efficiently
- Updating viewer and run button behavior for better integration with ComfyUI
- Fix various bugs and edge cases
2026-04-20 23:10:42 +02:00

612 lines
23 KiB
JavaScript

import { APP_CONFIG } from "../../app/config.js";
import { EVENTS } from "../../app/events.js";
import { createExecutionAssetBuffer } from "./executionAssetBuffer.js";
import { createJobFinalizeQueue } from "./finalizeQueue.js";
import { createLiveAssetGate } from "./liveAssetGate.js";
import { createTTLCache } from "../../utils/ttlCache.js";
const DEDUPE_TTL_MS = 2000;
const RECENT_FILES_MAX = 2000;
const INDEX_RETRYABLE_CODES = new Set([
"DB_MAINTENANCE",
"TIMEOUT",
"NETWORK_ERROR",
"SERVICE_UNAVAILABLE",
]);
const INDEX_RETRY_MAX_ATTEMPTS = 8;
const INDEX_RETRY_BASE_DELAY_MS = 1200;
const INDEX_RETRY_MAX_DELAY_MS = 15_000;
const EXECUTION_START_TTL_MS = 10 * 60 * 1000;
const EXECUTION_STARTS_MAX = 500;
const ORPHAN_GENERATION_RETRY_MAX_ATTEMPTS = 6;
const ORPHAN_GENERATION_ABSOLUTE_TIMEOUT_MS = 30_000;
const POST_EXECUTION_BUFFER_FLUSH_FAST_MS = 500;
export function createExecutionRuntimeController({
post,
ENDPOINTS,
reportError,
extractOutputFiles,
ensureExecutionRuntime,
emitRuntimeStatus,
refreshGeneratedFeedHosts,
getActiveGridContainer: _getActiveGridContainer,
}) {
const recentFiles = createTTLCache({ ttlMs: DEDUPE_TTL_MS, maxSize: RECENT_FILES_MAX });
const executionStarts = createTTLCache({
ttlMs: EXECUTION_START_TTL_MS,
maxSize: EXECUTION_STARTS_MAX,
});
const executionIdleGraceMs = Math.max(
0,
Number(APP_CONFIG.EXECUTION_IDLE_GRACE_MS) || 6000,
);
const orphanGenerationEntries = new Map();
const executionAssetBuffer = createExecutionAssetBuffer();
const liveAssetGate = createLiveAssetGate();
let lastStacksUpdateSignature = "";
let lastStacksUpdateAt = 0;
let postExecutionWorkTimer = null;
function isJobTrackingDebugEnabled() {
try {
if (typeof window === "undefined") return false;
if (window.__MJR_DEBUG_JOB_TRACKING__ === true) return true;
return String(window.localStorage?.getItem("mjr.debug.jobTracking") || "").trim() === "1";
} catch (e) {
console.debug?.(e);
return false;
}
}
function debugJobTracking(stage, detail = {}) {
if (!isJobTrackingDebugEnabled()) return;
try {
console.debug("[Majoor][job-tracking]", stage, detail);
} catch (e) {
console.debug?.(e);
}
}
function isExecutionGroupingEnabled() {
return !!APP_CONFIG.EXECUTION_GROUPING_ENABLED;
}
function getExecutionRuntimePolicy() {
const groupingEnabled = isExecutionGroupingEnabled();
return {
groupingEnabled,
useExecutionBuffer: groupingEnabled,
usePostExecutionScan: !groupingEnabled,
allowLiveStackFinalize: groupingEnabled,
};
}
function notifyStacksUpdated(detail = {}) {
debugJobTracking("stacks-updated", {
targeted_job_id: String(detail?.targeted_job_id || ""),
stacks: Array.isArray(detail?.stacks)
? detail.stacks.map((item) => ({
job_id: String(item?.job_id || ""),
stack_id: String(item?.stack_id || ""),
asset_count: Number(item?.asset_count || 0) || 0,
}))
: [],
});
try {
const targeted = String(detail?.targeted_job_id || "").trim();
if (targeted) liveAssetGate.clearPendingJob(targeted);
const stacks = Array.isArray(detail?.stacks) ? detail.stacks : [];
for (const item of stacks) {
const jobId = String(item?.job_id || "").trim();
if (jobId) liveAssetGate.clearPendingJob(jobId);
}
} catch (e) {
console.debug?.(e);
}
try {
const stacks = Array.isArray(detail?.stacks) ? detail.stacks : [];
const signature = JSON.stringify({
targeted_job_id: String(detail?.targeted_job_id || ""),
stacks: stacks.map((item) => ({
job_id: String(item?.job_id || ""),
stack_id: String(item?.stack_id || ""),
asset_count: Number(item?.asset_count || 0) || 0,
})),
});
const now = Date.now();
if (
signature &&
signature === lastStacksUpdateSignature &&
now - lastStacksUpdateAt < 1500
) {
return;
}
lastStacksUpdateSignature = signature;
lastStacksUpdateAt = now;
} catch (e) {
console.debug?.(e);
}
try {
window.dispatchEvent(
new CustomEvent("mjr:stacks-updated", { detail: { ...(detail || {}) } }),
);
} catch (e) {
console.debug?.(e);
}
try {
refreshGeneratedFeedHosts();
} catch (e) {
console.debug?.(e);
}
}
const stackFinalizeQueue = createJobFinalizeQueue({
defaultDelayMs: 900,
async postJob(targetJobId) {
debugJobTracking("auto-stack:start", { job_id: targetJobId });
try {
const res = await post(ENDPOINTS.STACKS_AUTO_STACK, {
mode: "job_id",
job_id: targetJobId,
});
if (!res?.ok) {
liveAssetGate.clearPendingJob(targetJobId);
reportError(
new Error(String(res?.error || "Auto stack failed")),
"executionRuntime.execution_end.auto_stack",
);
return;
}
debugJobTracking("auto-stack:done", {
job_id: targetJobId,
stacks: Array.isArray(res?.data?.stacks) ? res.data.stacks.length : 0,
});
notifyStacksUpdated(res?.data || { targeted_job_id: targetJobId });
} catch (error) {
reportError(error, "executionRuntime.execution_end.auto_stack");
}
},
});
function rememberExecutionStart(promptId, timestampMs) {
if (!promptId) return;
const ts = Number(timestampMs) || Date.now();
executionStarts.set(String(promptId), ts, { at: ts });
executionStarts.prune();
}
function dedupeFiles(files) {
recentFiles.prune();
const fresh = [];
for (const f of files) {
const key = `${f.type || ""}|${f.subfolder || ""}|${f.filename || ""}`.toLowerCase();
if (!recentFiles.has(key)) {
recentFiles.set(key, true);
fresh.push(f);
}
}
return fresh;
}
function indexRetryDelayMs(attempt) {
const a = Math.max(1, Number(attempt) || 1);
const exp = Math.min(6, a - 1);
return Math.min(INDEX_RETRY_MAX_DELAY_MS, INDEX_RETRY_BASE_DELAY_MS * 2 ** exp);
}
function clearPostExecutionWorkTimer() {
if (postExecutionWorkTimer) {
clearTimeout(postExecutionWorkTimer);
postExecutionWorkTimer = null;
}
}
function schedulePostExecutionWork(work, delayMs = executionIdleGraceMs) {
const waitMs = Math.max(0, Number(delayMs) || 0);
clearPostExecutionWorkTimer();
postExecutionWorkTimer = setTimeout(() => {
postExecutionWorkTimer = null;
try {
work?.();
} catch (error) {
reportError(error, "executionRuntime.post_execution");
}
}, waitMs);
}
function shouldRetryIndexResponse(res) {
const code = String(res?.code || "")
.trim()
.toUpperCase();
return INDEX_RETRYABLE_CODES.has(code);
}
function scheduleGenerationIndex(files, attempt = 1, meta = {}) {
const payloadFiles = Array.isArray(files) ? files : [];
if (!payloadFiles.length) return;
const payload = { files: payloadFiles, origin: "generation" };
if (meta?.prompt_id || meta?.promptId) {
payload.prompt_id = String(meta.prompt_id || meta.promptId);
}
debugJobTracking("index:direct", {
prompt_id: String(payload.prompt_id || ""),
attempt,
files: payloadFiles.map((item) => String(item?.filename || item?.filepath || "")),
});
post(ENDPOINTS.INDEX_FILES, payload)
.then((res) => {
if (res?.ok) return;
if (shouldRetryIndexResponse(res) && attempt < INDEX_RETRY_MAX_ATTEMPTS) {
const delayMs = indexRetryDelayMs(attempt);
setTimeout(
() => scheduleGenerationIndex(payloadFiles, attempt + 1, meta),
delayMs,
);
return;
}
try {
const code = String(res?.code || "INDEX_FAILED");
const msg = String(res?.error || "Indexing generated files failed");
reportError(new Error(`${code}: ${msg}`), "executionRuntime.executed.index");
} catch (e) {
console.debug?.(e);
}
})
.catch((error) => reportError(error, "executionRuntime.executed.index"));
}
function resolveGenerationPromptId(meta = {}) {
const explicitPromptId = String(meta?.prompt_id || meta?.promptId || "").trim();
if (explicitPromptId) return explicitPromptId;
try {
return String(ensureExecutionRuntime()?.active_prompt_id || "").trim();
} catch (e) {
console.debug?.(e);
return "";
}
}
function orphanGenerationKey(files) {
if (!Array.isArray(files) || !files.length) return "";
return files
.map((item) => String(item?.filepath || item?.filename || ""))
.filter(Boolean)
.sort()
.join("|");
}
function enqueueOrphanGenerationIndex(files, meta = {}, attempt = 1) {
const payloadFiles = Array.isArray(files) ? files : [];
if (!payloadFiles.length) return;
const key = orphanGenerationKey(payloadFiles);
if (!key) {
scheduleGenerationIndex(payloadFiles, 1, meta);
return;
}
const existing = orphanGenerationEntries.get(key);
if (existing?.timer) clearTimeout(existing.timer);
const firstAttemptAt = existing?.firstAttemptAt ?? Date.now();
if (Date.now() - firstAttemptAt >= ORPHAN_GENERATION_ABSOLUTE_TIMEOUT_MS) {
scheduleGenerationIndex(payloadFiles, 1, meta);
return;
}
const nextAttempt = Math.max(1, Number(attempt) || 1);
const delayMs = Math.min(2000, 250 * nextAttempt);
const timer = setTimeout(() => {
orphanGenerationEntries.delete(key);
bufferGenerationIndex(payloadFiles, meta, nextAttempt + 1);
}, delayMs);
orphanGenerationEntries.set(key, {
files: payloadFiles,
meta,
attempt: nextAttempt,
timer,
firstAttemptAt,
});
}
function flushOrphanGenerationEntries(promptId) {
const normalizedPromptId = String(promptId || "").trim();
if (!normalizedPromptId || orphanGenerationEntries.size <= 0) return;
for (const [key, entry] of Array.from(orphanGenerationEntries.entries())) {
try {
if (entry?.timer) clearTimeout(entry.timer);
} catch (e) {
console.debug?.(e);
}
orphanGenerationEntries.delete(key);
executionAssetBuffer.add(normalizedPromptId, entry?.files || []);
}
}
function clearOrphanGenerationEntries() {
for (const [key, entry] of Array.from(orphanGenerationEntries.entries())) {
try {
if (entry?.timer) clearTimeout(entry.timer);
} catch (e) {
console.debug?.(e);
}
orphanGenerationEntries.delete(key);
}
}
function bufferGenerationIndex(files, meta = {}, attempt = 1) {
const promptId = resolveGenerationPromptId(meta);
debugJobTracking("buffer:add", {
prompt_id: promptId,
explicit_prompt_id: String(meta?.prompt_id || meta?.promptId || ""),
attempt,
file_count: Array.isArray(files) ? files.length : 0,
files: Array.isArray(files)
? files.map((item) => String(item?.filename || item?.filepath || ""))
: [],
});
if (!promptId) {
if (attempt < ORPHAN_GENERATION_RETRY_MAX_ATTEMPTS) {
enqueueOrphanGenerationIndex(files, meta, attempt);
return;
}
scheduleGenerationIndex(files, 1, meta);
return;
}
executionAssetBuffer.add(promptId, files);
}
function scheduleFinalizeExecutionStacks(jobId, delayMs = 900) {
if (!isExecutionGroupingEnabled()) return;
debugJobTracking("finalize:schedule", { job_id: String(jobId || ""), delay_ms: delayMs });
stackFinalizeQueue.schedule(jobId, delayMs);
}
function schedulePostExecutionScan(delayMs = 1200) {
schedulePostExecutionWork(() => {
post(ENDPOINTS.SCAN, {
recursive: true,
incremental: true,
fast: true,
background_metadata: true,
}).catch((error) => reportError(error, "executionRuntime.execution_end.scan"));
}, delayMs);
}
function flushBufferedGenerationIndex(jobId) {
const policy = getExecutionRuntimePolicy();
const promptId = String(jobId || "").trim();
if (!promptId) return;
const files = executionAssetBuffer.take(promptId);
debugJobTracking("buffer:flush", {
prompt_id: promptId,
file_count: files.length,
files: files.map((item) => String(item?.filename || item?.filepath || "")),
});
if (policy.allowLiveStackFinalize) {
liveAssetGate.markPendingJob(promptId, { files, expectedCount: files.length });
}
if (!files.length) {
if (policy.allowLiveStackFinalize) {
scheduleFinalizeExecutionStacks(promptId);
}
return;
}
post(ENDPOINTS.INDEX_FILES, {
files,
origin: "generation",
prompt_id: promptId,
})
.then((res) => {
if (!res?.ok) {
if (policy.allowLiveStackFinalize) {
liveAssetGate.clearPendingJob(promptId);
}
reportError(
new Error(String(res?.error || "Indexing generated files failed")),
"executionRuntime.executed.index",
);
return;
}
debugJobTracking("index:flush:done", {
prompt_id: promptId,
file_count: files.length,
});
if (policy.allowLiveStackFinalize) {
scheduleFinalizeExecutionStacks(promptId, 1800);
}
})
.catch((error) => {
if (policy.allowLiveStackFinalize) {
liveAssetGate.clearPendingJob(promptId);
}
reportError(error, "executionRuntime.executed.index");
});
}
function hasBufferedGenerationFiles(jobId) {
const promptId = String(jobId || "").trim();
if (!promptId) return false;
try {
const bucket = executionAssetBuffer?._jobs?.get?.(promptId);
return !!bucket && typeof bucket.size === "number" && bucket.size > 0;
} catch (e) {
console.debug?.(e);
return false;
}
}
function prepareLiveAssetEvent(detail) {
if (!isExecutionGroupingEnabled()) {
return { detail, jobId: "", defer: false };
}
try {
return liveAssetGate.prepare(detail);
} catch (e) {
console.debug?.(e);
return { detail, jobId: "", defer: false };
}
}
function isRenderableLiveAsset(detail) {
const kind = String(detail?.kind || "").trim();
const filename = String(detail?.filename || "").trim();
const filepath = String(detail?.filepath || "").trim();
return !!kind && (!!filename || !!filepath);
}
function handleExecutedEvent(event, { appRef }) {
const policy = getExecutionRuntimePolicy();
if (!policy.useExecutionBuffer) return;
const outputFiles = dedupeFiles(extractOutputFiles(event?.detail?.output));
if (!outputFiles.length) return;
const promptId = event?.detail?.prompt_id || event?.detail?.promptId;
const sourceNodeId = String(event?.detail?.node || event?.detail?.display_node || "").trim();
const startTs = promptId ? executionStarts.get(String(promptId)) : null;
const genTimeMs = startTs ? Math.max(0, Date.now() - startTs) : 0;
let sourceNodeType = "";
try {
if (sourceNodeId && typeof appRef !== "undefined" && appRef?.graph) {
const graphNode = appRef.graph.getNodeById(Number(sourceNodeId));
if (graphNode) {
sourceNodeType = String(graphNode.type || graphNode.comfyClass || "").trim();
}
}
} catch (e) {
console.debug?.(e);
}
const files = outputFiles.map((f) => ({
...f,
...(genTimeMs > 0 ? { generation_time_ms: genTimeMs } : {}),
...(sourceNodeId ? { source_node_id: sourceNodeId } : {}),
...(sourceNodeType ? { source_node_type: sourceNodeType } : {}),
}));
debugJobTracking("executed:event", {
prompt_id: String(promptId || ""),
source_node_id: sourceNodeId,
source_node_type: sourceNodeType,
file_count: files.length,
files: files.map((item) => String(item?.filename || item?.filepath || "")),
});
try {
window.dispatchEvent(
new CustomEvent(EVENTS.NEW_GENERATION_OUTPUT, {
detail: {
files,
prompt_id: String(promptId || ""),
promptId: String(promptId || ""),
},
}),
);
} catch (e) {
console.debug?.(e);
}
const immediateFiles = files.length > 0 ? [files[0]] : [];
const bufferedFiles = files.length > 1 ? files.slice(1) : [];
// Prioritize the first renderable asset from each executed payload so the
// placeholder is replaced by a real card almost immediately, while the
// remaining outputs continue through the grouped post-execution flow.
if (immediateFiles.length > 0) {
scheduleGenerationIndex(immediateFiles, 1, { prompt_id: promptId });
}
if (bufferedFiles.length > 0) {
bufferGenerationIndex(bufferedFiles, { prompt_id: promptId });
}
}
function handleExecutionStart(event, { setCurrentJobId }) {
const promptId = event?.detail?.prompt_id || event?.detail?.promptId;
const ts = event?.detail?.timestamp;
debugJobTracking("execution:start", {
prompt_id: String(promptId || ""),
timestamp: Number(ts) || null,
});
rememberExecutionStart(promptId, ts);
clearPostExecutionWorkTimer();
setCurrentJobId?.(promptId || null);
emitRuntimeStatus({
active_prompt_id: promptId || null,
cached_nodes: [],
progress_node: null,
progress_value: null,
progress_max: null,
});
if (promptId) {
flushOrphanGenerationEntries(promptId);
}
}
function handleExecutionEnd(event, { setCurrentJobId }) {
const policy = getExecutionRuntimePolicy();
const promptId = event?.detail?.prompt_id || event?.detail?.promptId;
debugJobTracking("execution:end", {
type: String(event?.type || ""),
prompt_id: String(promptId || ""),
});
if (promptId) {
executionStarts.delete(String(promptId));
}
executionStarts.prune();
setCurrentJobId?.(null);
emitRuntimeStatus({
active_prompt_id: null,
cached_nodes: [],
progress_node: null,
progress_value: null,
progress_max: null,
});
if (promptId && String(event?.type || "") !== "execution_success") {
debugJobTracking("buffer:clear", {
prompt_id: String(promptId || ""),
reason: String(event?.type || ""),
});
clearPostExecutionWorkTimer();
executionAssetBuffer.clear(promptId);
if (policy.allowLiveStackFinalize) {
liveAssetGate.clearPendingJob(promptId);
}
clearOrphanGenerationEntries();
}
if (String(event?.type || "") === "execution_success") {
if (policy.usePostExecutionScan) {
schedulePostExecutionScan(executionIdleGraceMs);
} else {
const flushDelayMs = hasBufferedGenerationFiles(promptId)
? Math.min(executionIdleGraceMs, POST_EXECUTION_BUFFER_FLUSH_FAST_MS)
: executionIdleGraceMs;
schedulePostExecutionWork(() => {
if (promptId) {
flushOrphanGenerationEntries(promptId);
}
flushBufferedGenerationIndex(promptId);
}, flushDelayMs);
}
}
}
function handleNodeOutputsUpdated(nodeOutputs) {
if (!isExecutionGroupingEnabled()) return;
const files = [];
for (const [, outputs] of nodeOutputs instanceof Map ? nodeOutputs.entries() : []) {
const found = extractOutputFiles(outputs);
if (found?.length) {
for (const f of found) files.push(f);
}
}
if (!files.length || !isExecutionGroupingEnabled()) return;
debugJobTracking("node-outputs:updated", {
prompt_id: resolveGenerationPromptId(),
file_count: files.length,
});
bufferGenerationIndex(files, { prompt_id: resolveGenerationPromptId() });
}
return {
notifyStacksUpdated,
prepareLiveAssetEvent,
isRenderableLiveAsset,
handleExecutedEvent,
handleExecutionStart,
handleExecutionEnd,
handleNodeOutputsUpdated,
};
}