Skip to content

Fix graph stall for collectors downstream of empty iterators#9349

Open
JPPhoto wants to merge 2 commits into
invoke-ai:mainfrom
JPPhoto:fix-empty-collector-stall
Open

Fix graph stall for collectors downstream of empty iterators#9349
JPPhoto wants to merge 2 commits into
invoke-ai:mainfrom
JPPhoto:fix-empty-collector-stall

Conversation

@JPPhoto

@JPPhoto JPPhoto commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes graph execution stalling when a Collect node is downstream of an iterator over an empty collection.

Zero-iteration expansions are now explicitly recorded as complete. This allows downstream collectors to execute with an empty collection, typed consumers to receive [], and the session to report completion.

Collector output validation now handles materialized collectors with no runtime input edges. In this case, the already-validated source graph remains authoritative for type compatibility.

Related Issues / Discussions

Closes #9347

QA Instructions

Added coverage for:

  • IntegerCollection([]) -> Iterate -> Add -> Collect -> typed consumer
  • Nested empty Iterate -> Collect -> Iterate -> Collect chains
  • Collector output and typed consumer both receiving []
  • GraphExecutionState.is_complete() returning True

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@JPPhoto JPPhoto added the 6.14.x label Jul 11, 2026
@JPPhoto JPPhoto moved this to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Jul 11, 2026
@github-actions github-actions Bot added python PRs that change python files services PRs that change app services python-tests PRs that change python tests labels Jul 11, 2026
@lstein

lstein commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

I ran a review of this branch at 495656c (multi-agent find + adversarial verify; every finding below was checked against the code, and the main one was reproduced by running it). The flat empty-iterator fix itself holds up well — completion logic, JSON round-trip/resume, event consumers, and the frontend all tolerate the empty-marked nodes (the If-branch skip path already set that precedent). But the fallback is wrong for nested iteration.

1. Confirmed bug: [((), [])] fallback produces wrong results when the empty collector sits inside a non-empty outer iterator

graph.py:704 — when _get_collect_iteration_mapping_groups returns [], the fallback hardcodes a single collect exec node at iteration path (). If the collector is nested under a non-empty outer iterator (outer iterates [a, b], inner iterator gets an empty collection for each outer item), the collector should materialize once per outer iteration at paths (0,) and (1,). Instead one node at () is created, downstream per-outer-iteration consumers run once instead of twice, and the outer collector under-counts.

Observed on this branch (repro below):

=== (a) ALL-EMPTY: outer=[a,b], inner=[] for both ===
is_complete: True
  inner_collect: 1 exec node(s), iteration paths=[()], results=[[]]
  per_outer_consumer: 1 exec node(s), iteration paths=[()], results=[[]]
  outer_collect: 1 exec node(s), iteration paths=[()], results=[[[]]]   <-- should be [[], []]

On main this graph stalls (the bug this PR fixes), so the PR converts a visible stall into a silently wrong result for the nested case. The fallback path set needs to be derived from the collector's enclosing iterators' prepared exec nodes rather than hardcoded to ().

Related but pre-existing (identical output on main, so not caused by this PR): in the mixed case — inner collection empty for outer item a, non-empty for b_get_collect_iteration_mapping_groups returns only b's group, the fallback never fires, and branch a's collector instance is silently missing (outer_collect = [['b']] instead of [[], ['b']]). A path-aware fallback would likely fix both.

Repro script (run from repo root with PYTHONPATH=.)
"""Repro: nested iterator with empty inner collection under a non-empty outer iterator."""

from invokeai.app.invocations.baseinvocation import BaseInvocation, InvocationContext
from invokeai.app.services.shared.graph import (
    CollectInvocation,
    Graph,
    GraphExecutionState,
    IterateInvocation,
)
from tests.test_nodes import (
    PromptCollectionTestInvocation,
    PromptCollectionTestInvocationOutput,
    PromptTestInvocation,
    create_edge,
    run_session_with_mock_context,
)


class EmptyIfATestInvocation(BaseInvocation):
    """Outputs [] if prompt == 'a' (or always), else [prompt]."""

    prompt: str = ""
    always_empty: bool = True

    def invoke(self, context: InvocationContext) -> PromptCollectionTestInvocationOutput:
        if self.always_empty or self.prompt == "a":
            return PromptCollectionTestInvocationOutput(collection=[])
        return PromptCollectionTestInvocationOutput(collection=[self.prompt])


def build_and_run(always_empty: bool, label: str):
    g = Graph()
    n1 = PromptCollectionTestInvocation(id="outer_src", collection=["a", "b"])
    n2 = IterateInvocation(id="outer_iter")
    n3 = EmptyIfATestInvocation(id="inner_src", always_empty=always_empty)
    n4 = IterateInvocation(id="inner_iter")
    n5 = PromptTestInvocation(id="body")
    n6 = CollectInvocation(id="inner_collect")
    n7 = PromptCollectionTestInvocation(id="per_outer_consumer")  # runs per outer iteration
    n8 = CollectInvocation(id="outer_collect")

    for node in [n1, n2, n3, n4, n5, n6, n7, n8]:
        g.add_node(node)

    g.add_edge(create_edge(n1.id, "collection", n2.id, "collection"))
    g.add_edge(create_edge(n2.id, "item", n3.id, "prompt"))
    g.add_edge(create_edge(n3.id, "collection", n4.id, "collection"))
    g.add_edge(create_edge(n4.id, "item", n5.id, "prompt"))
    g.add_edge(create_edge(n5.id, "prompt", n6.id, "item"))
    g.add_edge(create_edge(n6.id, "collection", n7.id, "collection"))
    g.add_edge(create_edge(n7.id, "collection", n8.id, "item"))

    session = GraphExecutionState(graph=g)
    run_session_with_mock_context(session)

    print(f"\n=== {label} ===")
    print(f"is_complete: {session.is_complete()}")
    for src in ["inner_collect", "per_outer_consumer", "outer_collect"]:
        prepared = sorted(session.source_prepared_mapping.get(src, set()))
        paths = [session._get_iteration_path(p) for p in prepared]
        results = [getattr(session.results.get(p), "collection", None) for p in prepared]
        print(f"  {src}: {len(prepared)} exec node(s), iteration paths={paths}, results={results}")


if __name__ == "__main__":
    build_and_run(always_empty=True, label="(a) ALL-EMPTY: outer=[a,b], inner=[] for both")
    build_and_run(always_empty=False, label="(b) MIXED: inner=[] for 'a', ['b'] for 'b'")

2. Minor / non-blocking

  • graph.py:1539 — the and self._get_input_edges(source_node.id) guard applies to user-authored source graphs too, not just materialized execution graphs. Adding collect.collection → consumer before any collector input edge is now silently accepted at add_edge time; the error only surfaces later, at enqueue via _validate_special_nodes (unrelaxed), or attributed to a subsequently-added input edge. Verified not a correctness hole — every path to execution re-validates, and nothing ever runs validate_self on the execution graph (including JSON round-trip resume) — but scoping the exemption to execution-graph wiring would preserve immediate source-graph validation. Also, _get_input_edges builds a full O(E) list just for truthiness on a path that runs per attached edge during materialization; any(e.destination.node_id == source_node.id for e in self.edges) short-circuits. (Folding the skip into _is_collector_connection_valid's zero-inputs check is not equivalent — _validate_special_nodes calls it with no new edge and would then pass inputless collectors at enqueue.)
  • workflow_call_runtime.py:145 — behavior change worth knowing: a saved workflow whose workflow_return sits directly under a zero-iteration iterator (no intervening collector) now completes empty-marked, so the parent fails with ValueError("...unsupported number of workflow_return executions") instead of hanging forever. This is a strict improvement (the ≥2-iteration case already produced the same error), but a hint like "place a collector before workflow_return" would help.
  • graph.py:704 — the empty-group fallback lives at the call site; the sibling _get_parent_iteration_mappings_without_iterators handles its zero-iteration default internally (if not iteration_paths: iteration_paths = [()]). Returning the empty group from _get_collect_iteration_mapping_groups itself would be behavior-identical and protect any future caller.
  • graph.py:720 — with the new early-return above it, the None default in return next(iter(new_node_ids), None) is unreachable; return new_node_ids[0] says what it means. (The adjacent if create_results is not None guards are also dead — create_execution_node returns list[str], never None — but those predate this PR.)

Also verified as not problems, in case anyone else wonders: prepare() now returning a source-node id is inert (its only caller uses it as a loop-continuation sentinel); the if not new_node_ids catch-all can't fire for non-empty-iteration causes given the topological selection guards; and empty-marked nodes appearing in executed/executed_history without results entries is tolerated by every production reader (same as If-branch skips).

🤖 Generated with Claude Code

@JPPhoto JPPhoto force-pushed the fix-empty-collector-stall branch from 495656c to f928e93 Compare July 12, 2026 23:19
@JPPhoto JPPhoto force-pushed the fix-empty-collector-stall branch from f928e93 to b6753cd Compare July 13, 2026 10:49
@JPPhoto

JPPhoto commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

@lstein Thanks for the review. I believe I've addressed those issues with the latest commits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.x python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

Graph execution stalls when a Collect node is fed by an iterator over an empty collection

3 participants