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.
421 lines
16 KiB
Python
421 lines
16 KiB
Python
"""Compile prepared distributed prompts into worker-side ComfyUI image jobs.
|
|
|
|
This module is deliberately pure: it only copies and rewrites an API prompt
|
|
mapping and never imports or executes ComfyUI nodes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import re
|
|
|
|
|
|
_SUPPORTED_DISTRIBUTED = {
|
|
"DistributedCollector",
|
|
"DistributedSeed",
|
|
"DistributedValue",
|
|
"DistributedModelName",
|
|
}
|
|
_REMOVED_SCALAR_NODES = {
|
|
"DistributedSeed",
|
|
"DistributedValue",
|
|
"DistributedModelName",
|
|
}
|
|
_PRIMITIVE_NODES = {
|
|
"PrimitiveBoolean": False,
|
|
"PrimitiveFloat": 0.0,
|
|
"PrimitiveInt": 0,
|
|
"PrimitiveString": "",
|
|
}
|
|
_REMOVED_NODE_OUTPUTS = {
|
|
"DistributedCollector": {0, 1},
|
|
"DistributedSeed": {0},
|
|
"DistributedValue": {0},
|
|
"DistributedModelName": {0},
|
|
}
|
|
_SEED_DEFAULT = 1125899906842
|
|
|
|
|
|
def _is_link(value):
|
|
return isinstance(value, list) and len(value) == 2
|
|
|
|
|
|
def _is_unsupported_distributed(class_type):
|
|
if not isinstance(class_type, str) or class_type in _SUPPORTED_DISTRIBUTED:
|
|
return False
|
|
lowered = class_type.lower()
|
|
return "distributed" in lowered or "batchdivider" in lowered
|
|
|
|
|
|
def _safe_component(value):
|
|
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value)).strip("_-")
|
|
return cleaned[:24] or "empty"
|
|
|
|
|
|
def _filename_prefix(job_id, worker_id, collector_id, kind):
|
|
identity = json.dumps(
|
|
[job_id, worker_id, collector_id, kind],
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)
|
|
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
parts = (
|
|
"serverless",
|
|
_safe_component(job_id),
|
|
_safe_component(worker_id),
|
|
_safe_component(collector_id),
|
|
kind,
|
|
digest,
|
|
)
|
|
return "_".join(parts)
|
|
|
|
|
|
def _worker_index(worker_id):
|
|
"""Mirror nodes.utilities worker-index parsing; None means fallback."""
|
|
try:
|
|
if worker_id.startswith("worker_"):
|
|
return int(worker_id.split("_")[1])
|
|
return int(worker_id)
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def _coerce(value, value_type):
|
|
if value_type == "INT":
|
|
return int(float(value))
|
|
if value_type == "FLOAT":
|
|
return float(value)
|
|
return value
|
|
|
|
|
|
def _coerce_safe(value, value_type):
|
|
try:
|
|
return _coerce(value, value_type)
|
|
except (TypeError, ValueError):
|
|
return value
|
|
|
|
|
|
def _stringify_model_name(value):
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, (int, float, bool)):
|
|
return str(value)
|
|
try:
|
|
return json.dumps(value, indent=4)
|
|
except (TypeError, ValueError):
|
|
return str(value)
|
|
|
|
|
|
def _model_name_result(text):
|
|
if isinstance(text, list):
|
|
values = [_stringify_model_name(value) for value in text]
|
|
return values[0] if len(values) == 1 else values
|
|
return _stringify_model_name(text)
|
|
|
|
|
|
def _validate_prepared_prompt(prepared_prompt):
|
|
"""Validate graph shape, links, collectors, and supported topology."""
|
|
if not isinstance(prepared_prompt, dict):
|
|
raise ValueError("prepared prompt must be an API graph mapping")
|
|
|
|
source_nodes = {}
|
|
for raw_id, node in prepared_prompt.items():
|
|
node_id = str(raw_id)
|
|
if node_id in source_nodes:
|
|
raise ValueError("prompt has duplicate node IDs after string normalization")
|
|
if not isinstance(node, dict):
|
|
raise ValueError(f"prompt node {node_id!r} must be an object")
|
|
class_type = node.get("class_type")
|
|
if not isinstance(class_type, str):
|
|
raise ValueError(f"prompt node {node_id!r} is missing a class_type")
|
|
if _is_unsupported_distributed(class_type):
|
|
raise ValueError(f"unsupported distributed or batch node: {class_type}")
|
|
inputs = node.get("inputs", {})
|
|
if not isinstance(inputs, dict):
|
|
raise ValueError(f"prompt node {node_id!r} inputs must be an object")
|
|
source_nodes[node_id] = node
|
|
|
|
if not source_nodes:
|
|
raise ValueError("prepared prompt contains no nodes")
|
|
|
|
# Validate every API link before any graph rewriting. Slots on the nodes
|
|
# removed by this compiler have a known schema and are checked exactly.
|
|
for node_id, node in source_nodes.items():
|
|
for input_name, value in node.get("inputs", {}).items():
|
|
if not _is_link(value):
|
|
continue
|
|
upstream_id, output_slot = value
|
|
upstream_id = str(upstream_id)
|
|
if upstream_id not in source_nodes:
|
|
raise ValueError(
|
|
f"invalid link at node {node_id!r} input {input_name!r}: "
|
|
f"source {upstream_id!r} does not exist"
|
|
)
|
|
if isinstance(output_slot, bool) or not isinstance(output_slot, int) or output_slot < 0:
|
|
raise ValueError(
|
|
f"invalid link at node {node_id!r} input {input_name!r}: "
|
|
"output slot must be a nonnegative integer"
|
|
)
|
|
upstream_class = source_nodes[upstream_id]["class_type"]
|
|
valid_slots = _REMOVED_NODE_OUTPUTS.get(upstream_class)
|
|
if valid_slots is not None and output_slot not in valid_slots:
|
|
raise ValueError(
|
|
f"invalid output slot {output_slot} for removed node "
|
|
f"{upstream_class} {upstream_id!r}"
|
|
)
|
|
|
|
collector_ids = sorted(
|
|
(node_id for node_id, node in source_nodes.items() if node["class_type"] == "DistributedCollector"),
|
|
key=str,
|
|
)
|
|
if not collector_ids:
|
|
raise ValueError("prepared prompt contains no DistributedCollector nodes")
|
|
|
|
# Only image collectors are supported by the current generic handler.
|
|
for collector_id in collector_ids:
|
|
inputs = source_nodes[collector_id].get("inputs", {})
|
|
if "audio" in inputs:
|
|
raise ValueError("collector audio input is unsupported by this image-only compiler")
|
|
if not _is_link(inputs.get("images")):
|
|
raise ValueError(f"collector {collector_id!r} is missing a linked image input")
|
|
source_id = str(inputs["images"][0])
|
|
source_class = source_nodes[source_id]["class_type"]
|
|
if source_class in _REMOVED_SCALAR_NODES:
|
|
raise ValueError(f"collector {collector_id!r} image input must link to an image node")
|
|
|
|
# A collector cannot be used, directly or through processing, as another
|
|
# collector's input: this first compiler handles disconnected jobs only.
|
|
for collector_id in collector_ids:
|
|
pending = [str(source_nodes[collector_id]["inputs"]["images"][0])]
|
|
seen = set()
|
|
while pending:
|
|
node_id = pending.pop()
|
|
if node_id in seen:
|
|
continue
|
|
seen.add(node_id)
|
|
if source_nodes[node_id]["class_type"] == "DistributedCollector":
|
|
raise ValueError("collector-to-collector dependence is unsupported")
|
|
for value in source_nodes[node_id].get("inputs", {}).values():
|
|
if _is_link(value):
|
|
pending.append(str(value[0]))
|
|
|
|
return source_nodes, collector_ids
|
|
|
|
|
|
def _make_scalar_resolver(source_nodes):
|
|
"""Build pure resolvers for distributed scalar nodes and stock primitives."""
|
|
# Resolve only stock primitive nodes. A visited set makes malformed
|
|
# primitive-to-primitive cycles fail clearly instead of recursing forever.
|
|
def resolve_primitive(node_id, visiting=None):
|
|
node_id = str(node_id)
|
|
node = source_nodes[node_id]
|
|
class_type = node["class_type"]
|
|
if class_type not in _PRIMITIVE_NODES:
|
|
raise ValueError("linked value must be literal or supported PrimitiveInt/Float/String/Boolean")
|
|
if visiting is None:
|
|
visiting = set()
|
|
if node_id in visiting:
|
|
raise ValueError(f"cycle while resolving primitive value at node {node_id!r}")
|
|
visiting.add(node_id)
|
|
value = node.get("inputs", {}).get("value", _PRIMITIVE_NODES[class_type])
|
|
if _is_link(value):
|
|
parent_id, output_slot = str(value[0]), value[1]
|
|
if output_slot != 0:
|
|
raise ValueError(f"invalid output slot {output_slot} for primitive node {parent_id!r}")
|
|
if source_nodes[parent_id]["class_type"] not in _PRIMITIVE_NODES:
|
|
raise ValueError("linked primitive value must come from a stock Primitive node")
|
|
result = resolve_primitive(parent_id, visiting)
|
|
else:
|
|
result = copy.deepcopy(value)
|
|
visiting.remove(node_id)
|
|
return result
|
|
|
|
def resolve_literal_or_primitive(value, context):
|
|
if _is_link(value):
|
|
source_id, output_slot = str(value[0]), value[1]
|
|
if output_slot != 0 or source_nodes[source_id]["class_type"] not in _PRIMITIVE_NODES:
|
|
raise ValueError(f"{context} must be a literal or supported Primitive link")
|
|
try:
|
|
return resolve_primitive(source_id)
|
|
except ValueError as exc:
|
|
if "cycle" in str(exc):
|
|
raise
|
|
raise ValueError(f"{context} must be a literal or supported Primitive link") from None
|
|
return copy.deepcopy(value)
|
|
|
|
def resolve_hidden(node_id, name, default):
|
|
value = source_nodes[node_id].get("inputs", {}).get(name, default)
|
|
if _is_link(value):
|
|
raise ValueError(f"{name} on distributed node {node_id!r} must be a literal")
|
|
return copy.deepcopy(value)
|
|
|
|
resolved_distributed = {}
|
|
|
|
def resolve_distributed(node_id, output_slot):
|
|
node_id = str(node_id)
|
|
class_type = source_nodes[node_id]["class_type"]
|
|
if output_slot != 0:
|
|
raise ValueError(f"invalid output slot {output_slot} for {class_type} {node_id!r}")
|
|
cache_key = (node_id, output_slot)
|
|
if cache_key in resolved_distributed:
|
|
return copy.deepcopy(resolved_distributed[cache_key])
|
|
inputs = source_nodes[node_id].get("inputs", {})
|
|
if class_type == "DistributedSeed":
|
|
seed = resolve_literal_or_primitive(inputs.get("seed", _SEED_DEFAULT), f"seed on {node_id!r}")
|
|
is_worker = resolve_hidden(node_id, "is_worker", False)
|
|
worker_id = resolve_hidden(node_id, "worker_id", "")
|
|
if is_worker:
|
|
worker_index = _worker_index(worker_id)
|
|
if worker_index is not None:
|
|
seed = seed + worker_index + 1
|
|
result = seed
|
|
elif class_type == "DistributedValue":
|
|
default_value = resolve_literal_or_primitive(
|
|
inputs.get("default_value", ""), f"default_value on {node_id!r}"
|
|
)
|
|
worker_values = resolve_literal_or_primitive(
|
|
inputs.get("worker_values", "{}"), f"worker_values on {node_id!r}"
|
|
)
|
|
is_worker = resolve_hidden(node_id, "is_worker", False)
|
|
worker_id = resolve_hidden(node_id, "worker_id", "")
|
|
try:
|
|
values = json.loads(worker_values) if isinstance(worker_values, str) else worker_values
|
|
except json.JSONDecodeError:
|
|
values = {}
|
|
if not isinstance(values, dict):
|
|
values = {}
|
|
value_type = values.get("_type", "STRING")
|
|
fallback = _coerce_safe(default_value, value_type)
|
|
result = fallback
|
|
if is_worker:
|
|
worker_index = _worker_index(worker_id)
|
|
if worker_index is not None:
|
|
raw_value = values.get(str(worker_index + 1), "")
|
|
if raw_value:
|
|
try:
|
|
result = _coerce(raw_value, value_type)
|
|
except (ValueError, IndexError):
|
|
result = fallback
|
|
elif class_type == "DistributedModelName":
|
|
text = resolve_literal_or_primitive(
|
|
inputs.get("text", ""), f"DistributedModelName text on {node_id!r}"
|
|
)
|
|
result = _model_name_result(text)
|
|
else:
|
|
raise ValueError(f"unsupported distributed node: {class_type}")
|
|
resolved_distributed[cache_key] = copy.deepcopy(result)
|
|
return result
|
|
|
|
return resolve_distributed
|
|
|
|
|
|
def _build_savers(source_nodes, collector_ids):
|
|
"""Construct stable SaveImage nodes and their collector metadata."""
|
|
# The prepared collector values define the artifact identity. The fallback
|
|
# for absent hidden values is stable and retains the collector's job scope.
|
|
collector_specs = []
|
|
max_numeric_id = 0
|
|
for node_id in source_nodes:
|
|
try:
|
|
max_numeric_id = max(max_numeric_id, int(node_id))
|
|
except ValueError:
|
|
pass
|
|
next_numeric_id = max_numeric_id + 1
|
|
for collector_id in collector_ids:
|
|
inputs = source_nodes[collector_id].get("inputs", {})
|
|
raw_job_id = inputs.get("multi_job_id", collector_id)
|
|
if _is_link(raw_job_id):
|
|
raise ValueError(f"collector {collector_id!r} multi_job_id must be a literal")
|
|
job_id = str(collector_id if raw_job_id in (None, "") else raw_job_id)
|
|
raw_worker_id = inputs.get("worker_id", "")
|
|
if _is_link(raw_worker_id):
|
|
raise ValueError(f"collector {collector_id!r} worker_id must be a literal")
|
|
worker_id = "" if raw_worker_id is None else str(raw_worker_id)
|
|
saver_id = str(next_numeric_id)
|
|
next_numeric_id += 1
|
|
prefix = _filename_prefix(job_id, worker_id, collector_id, "image")
|
|
collector_specs.append(
|
|
{
|
|
"collector_id": collector_id,
|
|
"job_id": job_id,
|
|
"node_id": saver_id,
|
|
"kind": "image",
|
|
"prefix": prefix,
|
|
"source_link": copy.deepcopy(inputs["images"]),
|
|
}
|
|
)
|
|
|
|
saver_nodes = {}
|
|
for spec in collector_specs:
|
|
saver_nodes[spec["node_id"]] = {
|
|
"class_type": "SaveImage",
|
|
"inputs": {
|
|
"images": spec["source_link"],
|
|
"filename_prefix": spec["prefix"],
|
|
},
|
|
"_meta": {"title": f"Serverless output: collector {spec['collector_id']} (image)"},
|
|
}
|
|
|
|
return collector_specs, saver_nodes
|
|
|
|
|
|
def _prune_graph(source_nodes, collector_specs, saver_nodes, resolve_distributed):
|
|
"""Keep saver ancestors, replacing distributed scalar links with literals."""
|
|
all_nodes = {node_id: copy.deepcopy(node) for node_id, node in source_nodes.items()}
|
|
all_nodes.update(copy.deepcopy(saver_nodes))
|
|
needed = set()
|
|
pending = [spec["node_id"] for spec in collector_specs]
|
|
while pending:
|
|
node_id = pending.pop()
|
|
if node_id in needed:
|
|
continue
|
|
needed.add(node_id)
|
|
node = all_nodes[node_id]
|
|
if node["class_type"] == "DistributedCollector":
|
|
raise ValueError("collector-to-collector dependence is unsupported")
|
|
for input_name, value in list(node.get("inputs", {}).items()):
|
|
if not _is_link(value):
|
|
continue
|
|
upstream_id, output_slot = str(value[0]), value[1]
|
|
upstream_class = source_nodes[upstream_id]["class_type"]
|
|
if upstream_class in _REMOVED_SCALAR_NODES:
|
|
node["inputs"][input_name] = resolve_distributed(upstream_id, output_slot)
|
|
else:
|
|
pending.append(upstream_id)
|
|
|
|
compiled = {
|
|
node_id: copy.deepcopy(node)
|
|
for node_id, node in all_nodes.items()
|
|
if node_id in needed
|
|
}
|
|
if any("distributed" in node["class_type"].lower() for node in compiled.values()):
|
|
raise ValueError("compiled prompt still contains a distributed dependency")
|
|
|
|
return compiled
|
|
|
|
|
|
def compile_serverless_prompt(prepared_prompt: dict) -> tuple[dict, list[dict]]:
|
|
"""Compile a prepared worker prompt to an image-only serverless graph.
|
|
|
|
Every DistributedCollector's original ``images`` input is connected to a
|
|
new SaveImage node. Only those savers and their upstream dependencies are
|
|
retained. DistributedSeed/Value/ModelName links are resolved as scalar
|
|
literals; unsupported node execution is never attempted.
|
|
"""
|
|
source_nodes, collector_ids = _validate_prepared_prompt(prepared_prompt)
|
|
resolve_distributed = _make_scalar_resolver(source_nodes)
|
|
collector_specs, saver_nodes = _build_savers(source_nodes, collector_ids)
|
|
compiled = _prune_graph(
|
|
source_nodes,
|
|
collector_specs,
|
|
saver_nodes,
|
|
resolve_distributed,
|
|
)
|
|
manifest = [
|
|
{key: spec[key] for key in ("collector_id", "job_id", "node_id", "kind", "prefix")}
|
|
for spec in collector_specs
|
|
]
|
|
return compiled, manifest
|