Files
ComfyUI/tests-unit/execution_test/test_partial_execution.py

951 lines
36 KiB
Python

import asyncio
import gc
import weakref
import pytest
import torch
import comfy.model_management
import nodes
import comfy_extras.nodes_loop as nodes_loop
from comfy_execution.graph import DynamicPrompt, ExecutionList
from comfy_execution.graph_utils import GraphBuilder
from comfy_execution.validation import validate_loops
from execution import (
NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT,
NODE_FAILURE_POLICY_EXTRA_DATA_KEY,
CacheType,
PromptExecutor,
)
STATE = {}
class Payload:
pass
class Constant:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value):
return (value,)
class MakePayload:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("PAYLOAD",)
FUNCTION = "execute"
def execute(self, value):
payload = Payload()
STATE["payload"] = weakref.ref(payload)
return (payload,)
class Fail:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("*",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value):
STATE["fail_calls"] = STATE.get("fail_calls", 0) + 1
raise RuntimeError("node failure")
class SlowFail:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("*",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
async def execute(self, value):
await asyncio.sleep(0.05)
STATE["fail_calls"] = STATE.get("fail_calls", 0) + 1
raise RuntimeError("node failure")
class Capture:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("*",)}}
RETURN_TYPES = ()
FUNCTION = "execute"
OUTPUT_NODE = True
def execute(self, value):
STATE.setdefault("captured", []).append(value)
return ()
class WaitForFailure:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
async def execute(self, value):
while not STATE.get("fail_calls"):
await asyncio.sleep(0.01)
await asyncio.sleep(0.05)
return (value,)
class PayloadProbe:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ()
FUNCTION = "execute"
OUTPUT_NODE = True
def execute(self, value):
STATE["executor"].caches.outputs.ram_release(10 ** 30, free_active=True)
STATE["payload_alive"] = STATE["payload"]() is not None
return ()
class LazyPick:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"gate": ("INT",), "value": ("INT", {"lazy": True})}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def check_lazy_status(self, gate, value=None):
return ["value"] if value is None else []
def execute(self, gate, value):
return (value,)
class ExpandWithFailingSideBranch:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("PAYLOAD",)
FUNCTION = "execute"
def execute(self, value):
STATE["expand_calls"] = STATE.get("expand_calls", 0) + 1
graph = GraphBuilder()
payload = graph.node("PartialMakePayload", "payload", value=value)
failure = graph.node("PartialFail", "failure", value=value)
graph.node("PartialCapture", "side_output", value=failure.out(0))
return {"result": (payload.out(0),), "expand": graph.finalize()}
class ExpandWithLateFailingSideBranch(ExpandWithFailingSideBranch):
def execute(self, value):
STATE["expand_calls"] = STATE.get("expand_calls", 0) + 1
graph = GraphBuilder()
payload = graph.node("PartialMakePayload", "payload", value=value)
failure = graph.node("PartialSlowFail", "failure", value=value)
graph.node("PartialCapture", "side_output", value=failure.out(0))
return {"result": (payload.out(0),), "expand": graph.finalize()}
class UsePayload:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"payload": ("PAYLOAD",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, payload):
STATE["use_calls"] = STATE.get("use_calls", 0) + 1
return (1,)
class ExpandAroundFailingExpansion:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value):
STATE["outer_calls"] = STATE.get("outer_calls", 0) + 1
graph = GraphBuilder()
inner = graph.node("PartialExpandWithFailingSideBranch", "inner", value=value)
mid = graph.node("PartialUsePayload", "mid", payload=inner.out(0))
return {"result": (mid.out(0),), "expand": graph.finalize()}
class ExpandAroundFailingExpansionNeverCached(ExpandAroundFailingExpansion):
@classmethod
def IS_CHANGED(cls, value):
return float("nan")
class LazySwitch:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"select": ("STRING",), "a": ("INT", {"lazy": True}), "b": ("INT", {"lazy": True})}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def check_lazy_status(self, select, a=None, b=None):
return [select] if {"a": a, "b": b}[select] is None else []
def execute(self, select, a=None, b=None):
return (a if select == "a" else b,)
class Increment:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value):
return (value + 1,)
class CapturePassthrough:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
OUTPUT_NODE = True
def execute(self, value):
STATE.setdefault("captured", []).append(value)
return (value,)
class FailWhen:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",), "when": ("INT",)}, "optional": {"error": ("STRING",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value, when, error="runtime"):
if value == when:
if error == "memory":
raise MemoryError("host memory")
if error == "interrupt":
raise comfy.model_management.InterruptProcessingException()
if error == "accelerator":
raise torch.AcceleratorError("device fault")
raise RuntimeError("node failure")
return (value,)
class FailOutput:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("*",)}}
RETURN_TYPES = ()
FUNCTION = "execute"
OUTPUT_NODE = True
def execute(self, value):
raise RuntimeError("output failure")
class ExpandWithUnknownDisplayId:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, value):
graph = GraphBuilder()
failure = graph.node("PartialFail", "failure", value=value)
failure.set_override_display_id("missing")
return {"result": (failure.out(0),), "expand": graph.finalize()}
class Join:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"first": ("*",), "second": ("*",)}}
RETURN_TYPES = ("INT",)
FUNCTION = "execute"
def execute(self, first, second):
return (1,)
class Server:
client_id = None
def send_sync(self, *args, **kwargs):
pass
class Progress:
def send_progress_text(self, text, node_id):
pass
@pytest.fixture(autouse=True)
def register_nodes(monkeypatch):
STATE.clear()
classes = {
"StartLoop": nodes_loop.StartLoop,
"EndLoop": nodes_loop.EndLoop,
"LoopIteration": nodes_loop.LoopIteration,
"LoopProgress": nodes_loop.LoopProgress,
"LoopResult": nodes_loop.LoopResult,
"PartialConstant": Constant,
"PartialMakePayload": MakePayload,
"PartialFail": Fail,
"PartialCapture": Capture,
"PartialWaitForFailure": WaitForFailure,
"PartialPayloadProbe": PayloadProbe,
"PartialLazyPick": LazyPick,
"PartialExpandWithFailingSideBranch": ExpandWithFailingSideBranch,
"PartialSlowFail": SlowFail,
"PartialExpandWithLateFailingSideBranch": ExpandWithLateFailingSideBranch,
"PartialUsePayload": UsePayload,
"PartialExpandAroundFailingExpansion": ExpandAroundFailingExpansion,
"PartialExpandAroundFailingExpansionNeverCached": ExpandAroundFailingExpansionNeverCached,
"PartialLazySwitch": LazySwitch,
"PartialJoin": Join,
"PartialExpandWithUnknownDisplayId": ExpandWithUnknownDisplayId,
"PartialIncrement": Increment,
"PartialCapturePassthrough": CapturePassthrough,
"PartialFailWhen": FailWhen,
"PartialFailOutput": FailOutput,
}
for name, node in classes.items():
monkeypatch.setitem(nodes.NODE_CLASS_MAPPINGS, name, node)
monkeypatch.setattr(nodes_loop, "PromptServer", type("PromptServer", (), {"instance": Progress()}))
def run(prompt, outputs, continue_independent=True, cache_type=CacheType.NONE, executor=None):
starts = {node_id for node_id, node in prompt.items() if node["class_type"] == "StartLoop"}
ends = {node_id for node_id, node in prompt.items() if node["class_type"] == "EndLoop"}
if starts:
validate_loops(prompt, set(outputs), prompt, starts, ends)
if executor is None:
executor = PromptExecutor(Server(), cache_type=cache_type, cache_args={"ram": 0, "ram_inactive": 0, "lru": 100})
STATE["executor"] = executor
extra_data = {}
if continue_independent:
extra_data[NODE_FAILURE_POLICY_EXTRA_DATA_KEY] = NODE_FAILURE_POLICY_CONTINUE_INDEPENDENT
asyncio.run(asyncio.wait_for(executor.execute_async(prompt, "partial-execution-test", extra_data, outputs), 30))
return executor
def events(executor):
return [event for event, _ in executor.status_messages]
def independent_branch_prompt():
return {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"fail": {"class_type": "PartialFail", "inputs": {"value": ["seed", 0]}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
def test_failure_drops_only_dependents():
executor = run(independent_branch_prompt(), ["blocked", "independent"])
assert executor.success
assert STATE["captured"] == [1]
assert events(executor) == ["execution_start", "execution_cached", "execution_node_error", "execution_success"]
assert executor.execution_summary == {
"has_errors": True,
"execution_error_count": 1,
"failed_node_ids": ["fail"],
"blocked_node_ids": ["blocked"],
"blocked_output_node_ids": ["blocked"],
"successful_output_node_ids": ["independent"],
"completion_status": "partial_success",
}
def test_fail_fast_stays_default():
executor = run(independent_branch_prompt(), ["blocked", "independent"], continue_independent=False)
assert not executor.success
assert executor.execution_summary is None
assert events(executor)[-1] == "execution_error"
def test_failure_without_surviving_output_is_an_error():
prompt = independent_branch_prompt()
del prompt["independent"]
executor = run(prompt, ["blocked"])
assert not executor.success
assert events(executor) == ["execution_start", "execution_cached", "execution_node_error", "execution_error"]
class RecordingServer(Server):
def __init__(self):
self.sent = []
def send_sync(self, event, data, sid=None):
self.sent.append((event, data))
def test_dropping_many_nodes_sends_one_progress_state():
prompt = independent_branch_prompt()
for i in range(20):
prompt[f"blocked{i}"] = {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}}
outputs = ["independent"] + [f"blocked{i}" for i in range(20)]
executor = PromptExecutor(RecordingServer(), cache_type=CacheType.NONE, cache_args={"ram": 0, "ram_inactive": 0, "lru": 100})
run(prompt, outputs, executor=executor)
blocked_messages = [
data["nodes"] for event, data in executor.server.sent
if event == "progress_state" and any(node["state"] == "blocked" for node in data["nodes"].values())
]
assert len(blocked_messages) == 1
assert {node_id for node_id, node in blocked_messages[0].items() if node["state"] == "blocked"} == {f"blocked{i}" for i in range(20)}
assert blocked_messages[0]["fail"]["state"] == "error"
def test_loop_body_failure_blocks_the_loop_without_hanging():
prompt = {
"loop": {"class_type": "StartLoop", "inputs": {"mode": "simple", "mode.num_iterations": 3}},
"body": {"class_type": "PartialFail", "inputs": {"value": ["loop", 0]}},
"close": {"class_type": "EndLoop", "inputs": {"next_iteration_value": ["body", 0], "accumulate": False}},
"after": {"class_type": "PartialCapture", "inputs": {"value": ["close", 0]}},
"seed": {"class_type": "PartialConstant", "inputs": {"value": 7}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["after", "independent"])
assert executor.success
assert STATE["fail_calls"] == 1
assert STATE["captured"] == [7]
assert executor.execution_summary["completion_status"] == "partial_success"
assert executor.execution_summary["blocked_output_node_ids"] == ["after"]
assert "close" in executor.execution_summary["blocked_node_ids"]
def test_failure_feeding_the_loop_body_before_the_loop_starts_does_not_hang():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 7}},
"fail": {"class_type": "PartialFail", "inputs": {"value": ["seed", 0]}},
"gate": {"class_type": "PartialWaitForFailure", "inputs": {"value": ["seed", 0]}},
"loop": {"class_type": "StartLoop", "inputs": {"mode": "simple", "mode.num_iterations": 3, "initial_iteration_value": ["gate", 0]}},
"body": {"class_type": "PartialJoin", "inputs": {"first": ["loop", 4], "second": ["fail", 0]}},
"close": {"class_type": "EndLoop", "inputs": {"output_value": ["body", 0], "next_iteration_value": ["body", 0], "accumulate": False}},
"after": {"class_type": "PartialCapture", "inputs": {"value": ["close", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["after", "independent"])
assert executor.success
assert STATE["fail_calls"] == 1
assert STATE["captured"] == [7]
assert executor.execution_summary["blocked_output_node_ids"] == ["after"]
assert {"body", "close"} <= set(executor.execution_summary["blocked_node_ids"])
@pytest.mark.parametrize("cache_type", [CacheType.NONE, CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_late_lazy_link_never_reruns_failed_node(cache_type):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"fail": {"class_type": "PartialFail", "inputs": {"value": ["seed", 0]}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"gate": {"class_type": "PartialWaitForFailure", "inputs": {"value": ["seed", 0]}},
"pick": {"class_type": "PartialLazyPick", "inputs": {"gate": ["gate", 0], "value": ["fail", 0]}},
"late": {"class_type": "PartialCapture", "inputs": {"value": ["pick", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["blocked", "late", "independent"], cache_type=cache_type)
assert STATE["fail_calls"] == 1
assert STATE["captured"] == [1]
assert executor.execution_summary["blocked_node_ids"] == ["blocked", "late", "pick"]
assert executor.execution_summary["successful_output_node_ids"] == ["independent"]
def test_failed_node_inputs_are_released_while_prompt_continues():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"payload": {"class_type": "PartialMakePayload", "inputs": {"value": ["seed", 0]}},
"fail": {"class_type": "PartialFail", "inputs": {"value": ["payload", 0]}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"gate": {"class_type": "PartialWaitForFailure", "inputs": {"value": ["seed", 0]}},
"probe": {"class_type": "PartialPayloadProbe", "inputs": {"value": ["gate", 0]}},
}
gc.disable()
try:
executor = run(prompt, ["blocked", "probe"], cache_type=CacheType.RAM_PRESSURE)
finally:
gc.enable()
assert executor.execution_summary["completion_status"] == "partial_success"
assert STATE["payload_alive"] is False
def test_outputs_are_only_held_by_the_cache_and_pending_consumers():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"use": {"class_type": "PartialUsePayload", "inputs": {"payload": ["expand", 0]}},
"probe": {"class_type": "PartialPayloadProbe", "inputs": {"value": ["use", 0]}},
}
gc.disable()
try:
executor = run(prompt, ["probe"], cache_type=CacheType.RAM_PRESSURE)
finally:
gc.enable()
assert executor.execution_summary["completion_status"] == "partial_success"
assert executor.execution_summary["failed_node_ids"] == ["expand"]
assert STATE["payload_alive"] is False
@pytest.mark.parametrize("cache_type", [CacheType.NONE, CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_failed_side_branch_does_not_block_the_expansion_result(cache_type):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["expand", 0]}},
}
executor = run(prompt, ["output"], cache_type=cache_type)
assert executor.success
assert STATE["fail_calls"] == 1
assert len(STATE["captured"]) == 1
assert executor.execution_summary["failed_node_ids"] == ["expand"]
assert executor.execution_summary["successful_output_node_ids"] == ["output"]
@pytest.mark.parametrize("cache_type", [CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
@pytest.mark.parametrize("expander", ["PartialExpandWithFailingSideBranch", "PartialExpandWithLateFailingSideBranch"])
def test_expansion_with_a_failed_side_branch_is_not_cached(cache_type, expander):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": expander, "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["expand", 0]}},
}
executor = run(prompt, ["output"], cache_type=cache_type)
run(prompt, ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert len(STATE["captured"]) == 1
assert executor.execution_summary["completion_status"] == "partial_success"
assert executor.execution_summary["failed_node_ids"] == ["expand"]
assert events(executor).count("execution_cached") == 1
cached = next(data for event, data in executor.status_messages if event == "execution_cached")
assert sorted(cached["nodes"]) == ["output", "seed"]
@pytest.mark.parametrize("cache_type", [CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_expansion_with_a_failed_side_branch_reruns_behind_cached_consumers(cache_type):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"mid": {"class_type": "PartialUsePayload", "inputs": {"payload": ["expand", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["mid", 0]}},
}
executor = run(prompt, ["output"], cache_type=cache_type)
run(prompt, ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert STATE["use_calls"] == 1
assert len(STATE["captured"]) == 1
cached = next(data for event, data in executor.status_messages if event == "execution_cached")
assert sorted(cached["nodes"]) == ["mid", "output", "seed"]
assert executor.execution_summary["failed_node_ids"] == ["expand"]
@pytest.mark.parametrize("cache_type", [CacheType.LRU, CacheType.RAM_PRESSURE])
def test_incomplete_expansion_survives_unrelated_prompts(cache_type):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"mid": {"class_type": "PartialUsePayload", "inputs": {"payload": ["expand", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["mid", 0]}},
}
other = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 2}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["output"], cache_type=cache_type)
run(other, ["output"], cache_type=cache_type, executor=executor)
run(prompt, ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert STATE["use_calls"] == 1
cached = next(data for event, data in executor.status_messages if event == "execution_cached")
assert sorted(cached["nodes"]) == ["mid", "output", "seed"]
assert executor.execution_summary["failed_node_ids"] == ["expand"]
def test_incomplete_expansion_mark_is_kept_under_ram_pressure():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"mid": {"class_type": "PartialUsePayload", "inputs": {"payload": ["expand", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["mid", 0]}},
}
executor = run(prompt, ["output"], cache_type=CacheType.RAM_PRESSURE)
executor.caches.outputs.ram_release(10 ** 30, free_active=True)
assert executor.caches.outputs.get_local("mid") is None
assert executor.caches.outputs.is_incomplete("expand")
run(prompt, ["output"], cache_type=CacheType.RAM_PRESSURE, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
@pytest.mark.parametrize("cache_type", [CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_nested_incomplete_expansion_reruns_behind_a_cached_intermediate(cache_type):
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"outer": {"class_type": "PartialExpandAroundFailingExpansion", "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["outer", 0]}},
}
executor = run(prompt, ["output"], cache_type=cache_type)
assert STATE["fail_calls"] == 1
assert executor.execution_summary["failed_node_ids"] == ["outer"]
run(prompt, ["output"], cache_type=cache_type, executor=executor)
assert STATE["outer_calls"] == 2
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert STATE["use_calls"] == 1
assert len(STATE["captured"]) == 1
assert executor.execution_summary["failed_node_ids"] == ["outer"]
@pytest.mark.parametrize("cache_type", [CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_incomplete_flag_turns_on_for_marked_expansion_children(cache_type):
def prompt():
return {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"outer": {"class_type": "PartialExpandAroundFailingExpansionNeverCached", "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["outer", 0]}},
}
executor = run(prompt(), ["output"], cache_type=cache_type)
assert STATE["fail_calls"] == 1
assert executor.caches.outputs.has_incomplete is False
run(prompt(), ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert executor.caches.outputs.has_incomplete is True
assert STATE["use_calls"] == 1
assert executor.execution_summary["failed_node_ids"] == ["outer"]
@pytest.mark.parametrize("cache_type", [CacheType.CLASSIC, CacheType.LRU, CacheType.RAM_PRESSURE])
def test_incomplete_expansion_behind_a_lazy_input_follows_the_selection(cache_type):
def prompt(select, other):
return {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"other": {"class_type": "PartialConstant", "inputs": {"value": other}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"mid": {"class_type": "PartialUsePayload", "inputs": {"payload": ["expand", 0]}},
"switch": {"class_type": "PartialLazySwitch", "inputs": {"select": select, "a": ["mid", 0], "b": ["other", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["switch", 0]}},
}
executor = run(prompt("a", 2), ["output"], cache_type=cache_type)
assert STATE["fail_calls"] == 1
assert STATE["captured"] == [1]
run(prompt("b", 2), ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 1
assert STATE["captured"] == [1, 2]
assert executor.execution_summary is None
run(prompt("a", 3), ["output"], cache_type=cache_type, executor=executor)
assert STATE["expand_calls"] == 2
assert STATE["fail_calls"] == 2
assert STATE["use_calls"] == 1
assert STATE["captured"] == [1, 2, 1]
assert executor.execution_summary["failed_node_ids"] == ["expand"]
def test_incomplete_expansion_outside_the_requested_outputs_stays_idle():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["expand", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["output", "independent"], cache_type=CacheType.CLASSIC)
run(prompt, ["independent"], cache_type=CacheType.CLASSIC, executor=executor)
assert STATE["expand_calls"] == 1
assert executor.execution_summary is None
run(prompt, ["output", "independent"], cache_type=CacheType.CLASSIC, executor=executor)
assert STATE["expand_calls"] == 2
assert executor.execution_summary["failed_node_ids"] == ["expand"]
def test_outputs_saved_inside_a_loop_count_as_successful_outputs():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 0}},
"loop": {"class_type": "StartLoop", "inputs": {"mode": "simple", "mode.num_iterations": 3, "initial_iteration_value": ["seed", 0]}},
"increment": {"class_type": "PartialIncrement", "inputs": {"value": ["loop", 4]}},
"save": {"class_type": "PartialCapturePassthrough", "inputs": {"value": ["increment", 0]}},
"check": {"class_type": "PartialFailWhen", "inputs": {"value": ["increment", 0], "when": 2}},
"close": {"class_type": "EndLoop", "inputs": {"output_value": ["check", 0], "next_iteration_value": ["check", 0], "accumulate": False, "termination0": ["save", 0]}},
}
executor = run(prompt, ["save"])
assert executor.success
assert STATE["captured"] == [1, 2]
assert executor.execution_summary["completion_status"] == "partial_success"
assert executor.execution_summary["successful_output_node_ids"] == ["save"]
assert executor.execution_summary["failed_node_ids"] == ["loop"]
node_error = next(data for event, data in executor.status_messages if event == "execution_node_error")
assert node_error["display_node_id"] == "check"
assert node_error["node_type"] == "PartialFailWhen"
assert "executed" not in node_error and "current_outputs" not in node_error
def test_unknown_display_id_falls_back_to_the_real_node():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithUnknownDisplayId", "inputs": {"value": ["seed", 0]}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["expand", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["blocked", "independent"])
assert executor.success
assert executor.execution_summary["failed_node_ids"] == ["expand"]
node_error = next(data for event, data in executor.status_messages if event == "execution_node_error")
assert node_error["display_node_id"] == "expand"
assert node_error["node_type"] == "PartialExpandWithUnknownDisplayId"
def test_interrupt_after_a_recoverable_failure_stays_terminal():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"fail": {"class_type": "PartialFail", "inputs": {"value": ["seed", 0]}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"wait": {"class_type": "PartialWaitForFailure", "inputs": {"value": ["seed", 0]}},
"interrupt": {"class_type": "PartialFailWhen", "inputs": {"value": ["wait", 0], "when": 1, "error": "interrupt"}},
"after": {"class_type": "PartialCapture", "inputs": {"value": ["interrupt", 0]}},
}
executor = run(prompt, ["blocked", "after"])
assert not executor.success
assert executor.execution_summary is None
assert events(executor)[-2:] == ["execution_node_error", "execution_interrupted"]
@pytest.mark.skipif(not hasattr(torch, "AcceleratorError"), reason="torch without AcceleratorError")
def test_device_fault_stays_terminal():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"fail": {"class_type": "PartialFailWhen", "inputs": {"value": ["seed", 0], "when": 1, "error": "accelerator"}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["blocked", "independent"])
assert not executor.success
assert "execution_node_error" not in events(executor)
assert events(executor)[-1] == "execution_error"
def test_failed_output_node_is_listed_as_failed_only():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"failing": {"class_type": "PartialFailOutput", "inputs": {"value": ["seed", 0]}},
"ok": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["failing", "ok"])
assert executor.execution_summary == {
"has_errors": True,
"execution_error_count": 1,
"failed_node_ids": ["failing"],
"blocked_node_ids": [],
"blocked_output_node_ids": [],
"successful_output_node_ids": ["ok"],
"completion_status": "partial_success",
}
@pytest.mark.parametrize("cache_type,kept", [(CacheType.CLASSIC, False), (CacheType.LRU, True), (CacheType.RAM_PRESSURE, True)])
def test_incomplete_flag_follows_the_marks(cache_type, kept):
executor = PromptExecutor(Server(), cache_type=cache_type, cache_args={"ram": 0, "ram_inactive": 0, "lru": 100})
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"expand": {"class_type": "PartialExpandWithFailingSideBranch", "inputs": {"value": ["seed", 0]}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["expand", 0]}},
}
other = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 2}},
"output": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
run(prompt, ["output"], executor=executor)
run(other, ["output"], executor=executor)
assert executor.caches.outputs.has_incomplete is False
run(prompt, ["output"], executor=executor)
assert executor.caches.outputs.has_incomplete is kept
def test_host_memory_exhaustion_stays_terminal():
prompt = {
"seed": {"class_type": "PartialConstant", "inputs": {"value": 1}},
"fail": {"class_type": "PartialFailWhen", "inputs": {"value": ["seed", 0], "when": 1, "error": "memory"}},
"blocked": {"class_type": "PartialCapture", "inputs": {"value": ["fail", 0]}},
"independent": {"class_type": "PartialCapture", "inputs": {"value": ["seed", 0]}},
}
executor = run(prompt, ["blocked", "independent"])
assert not executor.success
assert "execution_node_error" not in events(executor)
assert events(executor)[-1] == "execution_error"
def scheduler(blocking, external=None):
result = ExecutionList(DynamicPrompt({}), None)
external = external or {}
for node_id in blocking:
result.pendingNodes[node_id] = True
result.blockCount[node_id] = external.get(node_id, 0)
result.blocking[node_id] = {}
result.execution_cache[node_id] = {}
for from_node_id, blocked_nodes in blocking.items():
for to_node_id in blocked_nodes:
result.blocking[from_node_id][to_node_id] = {0: True}
result.blockCount[to_node_id] += 1
result.externalBlocks = sum(external.values())
return result
def test_make_unavailable_removes_transitive_dependents_only():
graph = scheduler({"failed": ["child"], "child": ["grandchild"], "grandchild": [], "other": ["shared"], "shared": []})
graph.blocking["child"]["shared"] = {0: True}
graph.blockCount["shared"] += 1
graph.staged_node_id = "failed"
removed = graph.make_unavailable("failed")
assert sorted(removed) == ["child", "failed", "grandchild", "shared"]
assert list(graph.pendingNodes) == ["other"]
assert graph.blocking == {"other": {}}
assert graph.staged_node_id is None
assert graph.unavailable == set(removed)
assert set(graph.execution_cache) == {"other"}
def test_make_unavailable_settles_external_blocks():
graph = scheduler({"failed": ["waiting"], "waiting": []}, external={"waiting": 2})
unblock = graph.add_external_block("waiting")
graph.staged_node_id = "failed"
graph.make_unavailable("failed")
unblock()
assert graph.externalBlocks == 0
assert graph.is_empty()
def test_release_external_block_ignores_dropped_nodes_only():
graph = scheduler({"failed": ["waiting"], "waiting": [], "other": []}, external={"waiting": 1})
graph.staged_node_id = "failed"
graph.make_unavailable("failed")
graph.release_external_block("waiting")
assert graph.externalBlocks == 0
assert list(graph.pendingNodes) == ["other"]
with pytest.raises(KeyError):
graph.release_external_block("ghost")
def test_stalled_external_block_is_reported_once_nothing_can_release_it():
graph = scheduler({"failed": ["releaser"], "releaser": [], "released": ["after"], "after": [], "other": []})
graph.add_external_block("released")
graph.staged_node_id = "failed"
graph.make_unavailable("failed")
assert not graph.is_stalled()
graph.staged_node_id = "other"
graph.complete_node_execution()
assert graph.is_stalled()
assert graph.get_externally_blocked_nodes() == ["released"]
removed = [node_id for stalled in graph.get_externally_blocked_nodes() for node_id in graph.make_unavailable(stalled)]
assert sorted(removed) == ["after", "released"]
assert graph.externalBlocks == 0
assert graph.is_empty()
def test_unavailable_node_cannot_reenter_the_scheduler():
graph = scheduler({"failed": ["child"], "child": []})
graph.staged_node_id = "failed"
graph.make_unavailable("failed")
graph.add_node("child")
unblock = graph.add_external_block("child")
unblock()
assert graph.is_empty()
assert graph.externalBlocks == 0