Files
robertvoy-ComfyUI-Distributed/tests/test_serverless_prompt.py
Robert Wojciechowski 87c0cda9f6 fix: preserve serverless capacity and recovery across job failures
Retain durable reservations until provider termination or explicit recovery, protect unresolved pools and credentials, and add scoped reconciliation. Consolidate lifecycle and secret policies and split prompt/media stages with regression coverage.
2026-10-11 06:56:01 +00:00

393 lines
15 KiB
Python

import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "api"
/ "orchestration"
/ "serverless_prompt.py"
)
_spec = importlib.util.spec_from_file_location("serverless_prompt_under_test", MODULE_PATH)
assert _spec is not None and _spec.loader is not None
_serverless_prompt = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_serverless_prompt)
compile_serverless_prompt = _serverless_prompt.compile_serverless_prompt
def _prepared_prompt():
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}, "_meta": {"title": "source"}},
"2": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "seed": ["3", 0]}},
"3": {
"class_type": "DistributedSeed",
"inputs": {"seed": 40, "is_worker": True, "worker_id": "worker_0"},
},
"4": {
"class_type": "DistributedCollector",
"inputs": {"images": ["2", 0], "multi_job_id": "job/42", "worker_id": "worker_0", "is_worker": True},
},
"5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0]}},
"6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
"7": {"class_type": "PreviewImage", "inputs": {"images": ["4", 0]}, "_meta": {"title": "Preview Image (auto-added)"}},
"8": {"class_type": "UnrelatedNode", "inputs": {}},
}
def _run(prompt):
return compile_serverless_prompt(prompt)
def _load_prompt_transform():
module_path = MODULE_PATH.with_name("prompt_transform.py")
package_name = "serverless_prompt_transform_testpkg"
root_pkg = types.ModuleType(package_name)
root_pkg.__path__ = []
sys.modules[package_name] = root_pkg
for suffix in ("api", "api.orchestration", "utils"):
pkg = types.ModuleType(f"{package_name}.{suffix}")
pkg.__path__ = []
sys.modules[f"{package_name}.{suffix}"] = pkg
logging_module = types.ModuleType(f"{package_name}.utils.logging")
setattr(logging_module, "debug_log", lambda *_args, **_kwargs: None)
setattr(logging_module, "log", lambda *_args, **_kwargs: None)
sys.modules[f"{package_name}.utils.logging"] = logging_module
spec = importlib.util.spec_from_file_location(
f"{package_name}.api.orchestration.prompt_transform", module_path
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_compiles_prompt_prepared_by_existing_worker_helpers():
prompt_transform = _load_prompt_transform()
original = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
"2": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "seed": ["3", 0]}},
"3": {"class_type": "DistributedSeed", "inputs": {"seed": 100}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["2", 0]}},
"5": {"class_type": "SaveImage", "inputs": {"images": ["4", 0]}},
}
pruned = prompt_transform.prune_prompt_for_worker(original)
prompt_index = prompt_transform.PromptIndex(pruned)
prepared = prompt_transform.apply_participant_overrides(
pruned,
participant_id="worker-a",
enabled_worker_ids=["worker-a"],
job_id_map=prompt_transform.generate_job_id_map(prompt_index, "run"),
master_url="http://master.invalid",
delegate_master=False,
prompt_index=prompt_index,
)
graph, manifest = _run(prepared)
assert graph["2"]["inputs"]["seed"] == 101
assert set(graph) == {"1", "2", "7"}
assert graph["7"]["class_type"] == "SaveImage"
assert manifest[0]["job_id"] == "run_4"
assert manifest[0]["collector_id"] == "4"
def test_compiles_collector_input_to_flat_saveimage_and_prunes_downstream():
original = _prepared_prompt()
graph, manifest = _run(original)
assert manifest == [
{
"collector_id": "4",
"job_id": "job/42",
"node_id": "9",
"kind": "image",
"prefix": graph["9"]["inputs"]["filename_prefix"],
}
]
prefix = manifest[0]["prefix"]
assert graph["9"] == {
"class_type": "SaveImage",
"inputs": {"images": ["2", 0], "filename_prefix": prefix},
"_meta": {"title": "Serverless output: collector 4 (image)"},
}
assert "/" not in prefix and "\\" not in prefix
assert graph["2"]["inputs"]["seed"] == 41
assert set(graph) == {"1", "2", "9"}
assert all("Distributed" not in node["class_type"] for node in graph.values())
assert original["3"]["inputs"]["seed"] == 40
assert original["4"]["inputs"]["images"] == ["2", 0]
def test_manifest_and_prefix_are_repeatable_and_safe_for_untrusted_identity_parts():
prompt = _prepared_prompt()
prompt["4"]["inputs"].update(multi_job_id="../run/a", worker_id="worker/unsafe")
first_graph, first_manifest = _run(prompt)
second_graph, second_manifest = _run(prompt)
assert first_manifest == second_manifest
assert first_graph == second_graph
prefix = first_manifest[0]["prefix"]
assert prefix == first_graph[first_manifest[0]["node_id"]]["inputs"]["filename_prefix"]
assert not any(char in prefix for char in ("/", "\\", ":"))
assert first_manifest[0]["job_id"] == "../run/a"
def test_compiles_multiple_disconnected_collectors_in_stable_order():
prompt = {
"10": {"class_type": "LoadImage", "inputs": {}},
"20": {"class_type": "LoadImage", "inputs": {}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": ["20", 0], "multi_job_id": "b", "worker_id": "worker_1"}},
"1": {"class_type": "DistributedCollector", "inputs": {"images": ["10", 0], "multi_job_id": "a", "worker_id": "worker_0"}},
"30": {"class_type": "PreviewImage", "inputs": {"images": ["1", 0]}},
}
graph, manifest = _run(prompt)
assert [item["collector_id"] for item in manifest] == ["1", "2"]
assert [item["node_id"] for item in manifest] == ["31", "32"]
assert graph["31"]["inputs"]["images"] == ["10", 0]
assert graph["32"]["inputs"]["images"] == ["20", 0]
assert set(graph) == {"10", "20", "31", "32"}
def test_rejects_audio_input_instead_of_emitting_audio_saver():
prompt = {
"1": {"class_type": "LoadAudio", "inputs": {}},
"2": {"class_type": "DistributedCollector", "inputs": {"audio": ["1", 0]}},
}
with pytest.raises(ValueError, match="audio.*unsupported"):
_run(prompt)
def test_rejects_collector_without_image_input():
with pytest.raises(ValueError, match="collector.*image"):
_run({"1": {"class_type": "DistributedCollector", "inputs": {}}})
@pytest.mark.parametrize(
"class_type",
["UltimateSDUpscaleDistributed", "DistributedEmptyImage", "ImageBatchDivider", "AudioBatchDivider"],
)
def test_rejects_unsupported_distributed_and_batch_nodes(class_type):
prompt = {
"1": {"class_type": class_type, "inputs": {}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
"3": {"class_type": "LoadImage", "inputs": {}},
}
with pytest.raises(ValueError, match="unsupported"):
_run(prompt)
def test_rejects_collector_to_collector_dependency_through_processing_node():
prompt = {
"1": {"class_type": "LoadImage", "inputs": {}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": ["1", 0]}},
"3": {"class_type": "ImageScale", "inputs": {"image": ["2", 0]}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
}
with pytest.raises(ValueError, match="collector-to-collector"):
_run(prompt)
@pytest.mark.parametrize(
"link",
[["missing", 0], ["1", -1], ["1", "0"], ["1", True]],
)
def test_rejects_malformed_api_links(link):
prompt = {
"1": {"class_type": "LoadImage", "inputs": {}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": link}},
}
with pytest.raises(ValueError, match="link"):
_run(prompt)
def test_rejects_unexpected_output_slot_from_removed_distributed_node():
prompt = {
"1": {"class_type": "DistributedSeed", "inputs": {"seed": 1}},
"2": {"class_type": "KSampler", "inputs": {"seed": ["1", 1]}},
"3": {"class_type": "DistributedCollector", "inputs": {"images": ["2", 0]}},
}
with pytest.raises(ValueError, match="output slot"):
_run(prompt)
def test_distributed_seed_matches_worker_offset_and_master_passthrough():
prompt = {
"1": {"class_type": "DistributedSeed", "inputs": {"seed": 10, "is_worker": True, "worker_id": "worker_1"}},
"2": {"class_type": "DistributedSeed", "inputs": {"seed": 7, "is_worker": False}},
"3": {"class_type": "KSampler", "inputs": {"seed": ["1", 0], "steps": ["2", 0]}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
}
graph, _manifest = _run(prompt)
assert graph["3"]["inputs"] == {"seed": 12, "steps": 7}
assert "1" not in graph and "2" not in graph
def test_distributed_value_uses_one_based_worker_values_and_type_coercion():
prompt = {
"1": {"class_type": "DistributedValue", "inputs": {"default_value": "3.9", "worker_values": json.dumps({"_type": "INT", "1": "8.7"}), "is_worker": True, "worker_id": "worker_0"}},
"2": {"class_type": "DistributedValue", "inputs": {"default_value": "3.9", "worker_values": json.dumps({"_type": "INT", "2": ""}), "is_worker": True, "worker_id": "worker_1"}},
"3": {"class_type": "KSampler", "inputs": {"steps": ["1", 0], "seed": ["2", 0]}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
}
graph, _manifest = _run(prompt)
assert graph["3"]["inputs"] == {"steps": 8, "seed": 3}
assert "1" not in graph and "2" not in graph
def test_resolves_stock_primitive_links_for_distributed_seed_and_value():
prompt = {
"1": {"class_type": "PrimitiveInt", "inputs": {"value": 25}},
"2": {"class_type": "DistributedSeed", "inputs": {"seed": ["1", 0], "is_worker": True, "worker_id": "worker_0"}},
"3": {"class_type": "PrimitiveString", "inputs": {"value": "4.8"}},
"4": {"class_type": "PrimitiveString", "inputs": {"value": '{"_type":"FLOAT","1":"2.75"}'}},
"5": {"class_type": "DistributedValue", "inputs": {"default_value": ["3", 0], "worker_values": ["4", 0], "is_worker": True, "worker_id": "worker_0"}},
"6": {"class_type": "KSampler", "inputs": {"seed": ["2", 0], "cfg": ["5", 0]}},
"7": {"class_type": "DistributedCollector", "inputs": {"images": ["6", 0]}},
}
graph, _manifest = _run(prompt)
assert graph["6"]["inputs"] == {"seed": 26, "cfg": 2.75}
assert not {"1", "2", "3", "4", "5"}.intersection(graph)
def test_rejects_dynamic_linked_seed_or_value_without_executing_it():
prompt = {
"1": {"class_type": "ArbitraryNode", "inputs": {}},
"2": {"class_type": "DistributedSeed", "inputs": {"seed": ["1", 0], "is_worker": True, "worker_id": "worker_0"}},
"3": {"class_type": "KSampler", "inputs": {"seed": ["2", 0]}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
}
with pytest.raises(ValueError, match="literal or supported Primitive"):
_run(prompt)
def test_primitive_resolution_detects_cycles():
prompt = {
"1": {"class_type": "PrimitiveInt", "inputs": {"value": ["2", 0]}},
"2": {"class_type": "PrimitiveInt", "inputs": {"value": ["1", 0]}},
"3": {"class_type": "DistributedSeed", "inputs": {"seed": ["1", 0]}},
"4": {"class_type": "KSampler", "inputs": {"seed": ["3", 0]}},
"5": {"class_type": "DistributedCollector", "inputs": {"images": ["4", 0]}},
}
with pytest.raises(ValueError, match="cycle"):
_run(prompt)
def test_model_name_trivial_text_passthrough_is_inlined():
prompt = {
"1": {"class_type": "DistributedModelName", "inputs": {"text": "model.safetensors"}},
"2": {"class_type": "KSampler", "inputs": {"model_name": ["1", 0]}},
"3": {"class_type": "DistributedCollector", "inputs": {"images": ["2", 0]}},
}
graph, _manifest = _run(prompt)
assert graph["2"]["inputs"]["model_name"] == "model.safetensors"
assert "1" not in graph
def test_rejects_dynamic_linked_model_name_text():
prompt = {
"1": {"class_type": "ArbitraryNode", "inputs": {}},
"2": {"class_type": "DistributedModelName", "inputs": {"text": ["1", 0]}},
"3": {"class_type": "KSampler", "inputs": {"model_name": ["2", 0]}},
"4": {"class_type": "DistributedCollector", "inputs": {"images": ["3", 0]}},
}
with pytest.raises(ValueError, match="DistributedModelName.*literal or supported Primitive"):
_run(prompt)
def test_resolves_boolean_and_float_primitive_links():
prompt = {
"1": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
"2": {"class_type": "PrimitiveFloat", "inputs": {"value": 1.25}},
"3": {"class_type": "DistributedValue", "inputs": {"default_value": ["1", 0], "is_worker": False}},
"4": {"class_type": "DistributedValue", "inputs": {"default_value": ["2", 0], "worker_values": '{"_type":"FLOAT","1":"2.5"}', "is_worker": True, "worker_id": "worker_0"}},
"5": {"class_type": "KSampler", "inputs": {"enabled": ["3", 0], "cfg": ["4", 0]}},
"6": {"class_type": "DistributedCollector", "inputs": {"images": ["5", 0]}},
}
graph, _manifest = _run(prompt)
assert graph["5"]["inputs"] == {"enabled": True, "cfg": 2.5}
assert not {"1", "2", "3", "4"}.intersection(graph)
def test_compiled_graph_matches_golden_prompt_and_manifest():
prompt = _prepared_prompt()
prompt["3"]["inputs"]["seed"] = ["10", 0]
prompt["10"] = {"class_type": "PrimitiveInt", "inputs": {"value": 40}}
prompt["11"] = {"class_type": "PrimitiveFloat", "inputs": {"value": 1.25}}
prompt["12"] = {
"class_type": "DistributedValue",
"inputs": {
"default_value": ["11", 0],
"worker_values": json.dumps({"_type": "FLOAT", "1": "2.5"}),
"is_worker": True,
"worker_id": "worker_0",
},
}
prompt["13"] = {"class_type": "PrimitiveString", "inputs": {"value": "model.safetensors"}}
prompt["14"] = {"class_type": "DistributedModelName", "inputs": {"text": ["13", 0]}}
prompt["2"]["inputs"].update(cfg=["12", 0], model_name=["14", 0])
graph, manifest = _run(prompt)
expected_prefix = (
"serverless_job_42_worker_0_4_image_"
"1c81da108a2eb60282bd436512acfc59903aba73e49b19b6f47bbdcafd30d244"
)
assert list(graph) == ["1", "2", "15"]
assert graph == {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {},
"_meta": {"title": "source"},
},
"2": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 41,
"cfg": 2.5,
"model_name": "model.safetensors",
},
},
"15": {
"class_type": "SaveImage",
"inputs": {"images": ["2", 0], "filename_prefix": expected_prefix},
"_meta": {"title": "Serverless output: collector 4 (image)"},
},
}
assert manifest == [
{
"collector_id": "4",
"job_id": "job/42",
"node_id": "15",
"kind": "image",
"prefix": expected_prefix,
}
]