diff --git a/README.md b/README.md index aeb77e0..d68b400 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ Remote runtime behavior: - Persisted remote node-cache hits are now idempotent within one execution: if a node output was restored from the distributed Modal Dict under a given `NC_...` key and nothing invalidated that key during the run, the persist phase skips rewriting that same record instead of churning the Dict on a pure cache hit. - The distributed cache hot path is also lighter now: `restored-hit` nodes short-circuit before any output serialization work in the persist phase, and restore fan-outs independent Modal Dict lookups so mapped item runs spend less time waiting on node-cache bookkeeping. - Distributed cache restore now preserves required boundary producers. If a downstream cache hit would skip an upstream node whose output must be collected as a boundary or session bridge, Modal-Sync ignores that downstream hit for the current subgraph so ComfyUI materializes the required producer instead of failing with a missing local cache entry. +- Distributed cache signature rebuilds now distinguish real prompt links from nested runtime data. This keeps first-run cache restore from misreading list-shaped values such as `CONDITIONING` payloads as graph edges. - The remote headless `PromptServer.instance` now implements ComfyUI's progress-text method, so API nodes such as `GetImageSize` can report node text without crashing in Modal. - Mapped remote components can now contain both one-time execute targets and per-item execute targets. A common case is two remote `KSampler` nodes sharing one upstream `Load Diffusion Model`, where only the sampler fed by `ModalMapInput` should fan out per latent while the sibling sampler still runs exactly once. - Queue-time rewrite now materializes explicit static-to-mapped boundaries for invariant upstream outputs. That lets the mapped phase consume only the values it really depends on instead of carrying the whole coarse component prompt back through every item run. diff --git a/comfyui_modal_sync_cloud.py b/comfyui_modal_sync_cloud.py index b89ba5f..1ed57c8 100644 --- a/comfyui_modal_sync_cloud.py +++ b/comfyui_modal_sync_cloud.py @@ -2450,9 +2450,9 @@ def _build_node_output_cache_immediate_signature( boundary_input_signatures = node.get(_BOUNDARY_INPUT_SIGNATURES_KEY) for key in sorted(inputs.keys()): input_value = inputs[key] - if _is_link(input_value): - ancestor_id = str(input_value[0]) - ancestor_socket = int(input_value[1]) + ancestor_socket = _cache_signature_link_output_index(input_value) + ancestor_id = str(input_value[0]) if ancestor_socket is not None else None + if ancestor_socket is not None and ancestor_id in ancestor_order_mapping: ancestor_index = int(ancestor_order_mapping[ancestor_id]) signature.append((key, ("ANCESTOR", ancestor_index, ancestor_socket))) else: @@ -2468,6 +2468,21 @@ def _build_node_output_cache_immediate_signature( return signature +def _cache_signature_link_output_index(value: Any) -> int | None: + """Return a prompt-link output index when `value` is safe to treat as graph wiring.""" + if not _is_link(value): + return None + + output_index = _normalize_link_output_index(value[1]) + if isinstance(output_index, bool): + return None + + try: + return int(output_index) + except (TypeError, ValueError): + return None + + async def _build_node_output_cache_signature_from_key_set_async( cache_key_set: Any, node_id: str, diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 6990bcf..56d4032 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -278,3 +278,4 @@ - The implicit mapped dispatcher needs an explicit ordinary-subgraph bypass. When a batch-like boundary targets an `INPUT_IS_LIST` node, the helper correctly decides not to fan out, but re-entering the public dispatcher without disabling implicit mapping just rediscovers the same split and recurses forever. - Mapped `CONDITIONING` outputs need explicit item-boundary metadata. A single ComfyUI conditioning value is already list-shaped, so shape alone cannot distinguish "one conditioning payload to broadcast" from "ordered conditioning payloads from a mapped CLIP run"; downstream implicit mapping must split only the latter. - Remote session bridge resolution must recurse through list-shaped outputs. Over-list Comfy execution can produce one output slot that contains several bridge refs, and resolving only top-level refs leaves downstream samplers seeing unresolved refs or repeated first-item state. +- Rebuilt remote node-cache signatures must use stricter prompt-link detection than general prompt rewriting. Runtime values such as `CONDITIONING` can contain nested two-item lists, and treating those data lists as `[node_id, output_index]` edges makes first-run distributed cache restore fail before the target node executes. diff --git a/tests/test_modal_executor_node.py b/tests/test_modal_executor_node.py index 2cc6c59..6038682 100644 --- a/tests/test_modal_executor_node.py +++ b/tests/test_modal_executor_node.py @@ -3051,6 +3051,61 @@ def test_modal_cloud_node_cache_key_uses_boundary_source_signature_for_unhashabl assert different_key != first_key +def test_modal_cloud_node_cache_key_treats_nested_two_item_lists_as_data( + modal_cloud_module: Any, + monkeypatch: Any, +) -> None: + """Nested conditioning-style two-item lists should not be mistaken for prompt links.""" + + class FakeDynPrompt: + """Minimal dynamic prompt wrapper for one data-only node.""" + + def has_node(self, node_id: str) -> bool: + """Return whether the requested node exists.""" + return str(node_id) == "545" + + def get_node(self, node_id: str) -> dict[str, Any]: + """Return a node with nested list data shaped like Comfy conditioning.""" + if not self.has_node(node_id): + raise KeyError(node_id) + return { + "class_type": "AnythingToMarkdown", + "inputs": { + "anything": [ + [ + {"pooled_output": "summary"}, + [["not-a-node-id"], ["not-an-output-index"]], + ] + ] + }, + } + + cache_key_set = types.SimpleNamespace( + dynprompt=FakeDynPrompt(), + is_changed_cache=types.SimpleNamespace(is_changed={"545": False}), + get_ordered_ancestry=lambda current_dynprompt, node_id: ([], {}), + include_node_id_in_input=lambda: False, + get_data_key=lambda node_id: None, + ) + monkeypatch.setattr( + modal_cloud_module, + "_load_nodes_module", + lambda: types.SimpleNamespace( + NODE_CLASS_MAPPINGS={"AnythingToMarkdown": type("AnythingToMarkdown", (), {})} + ), + ) + monkeypatch.setattr( + modal_cloud_module, + "_include_unique_id_in_input_signature", + lambda class_type: False, + ) + + cache_key = modal_cloud_module._node_output_cache_key_from_key_set_sync(cache_key_set, "545") + + assert isinstance(cache_key, str) + assert cache_key.startswith("NC_") + + def test_modal_cloud_ignores_heavy_comfyui_paths( modal_cloud_module: Any, ) -> None: