Skip to content

Duplicate node executions when two independent iterators feed a node whose collection output is iterated again #9485

Description

@lstein

Summary

When two independent iterators both feed a node whose collection output is then iterated again, the body of the inner iteration is prepared twice for the off-diagonal iteration paths. The duplicate exec nodes carry the same iteration path, both execute, and both land in the downstream collector — so the collected list contains every item twice for those branches.

With 2 × 2 outer items the body runs 12 times instead of 8, and two of the four collected groups are doubled.

Found while adversarially reviewing #9483. It is not caused by that PR — reproduced identically at a21d74b8bd (its base) and at the PR head.

Repro

src1 → iter_a ─┐
               ├→ pairer → inner_iter → body → collect
src2 → iter_b ─┘
Script (run from repo root with PYTHONPATH=.)
from typing import Any

from invokeai.app.invocations.baseinvocation import BaseInvocation, InvocationContext, invocation
from invokeai.app.invocations.fields import InputField
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,
)


@invocation("issue_pair", version="1.0.0")
class PairTestInvocation(BaseInvocation):
    a: Any = InputField(default=None)
    b: Any = InputField(default=None)

    def invoke(self, context: InvocationContext) -> PromptCollectionTestInvocationOutput:
        return PromptCollectionTestInvocationOutput(collection=[f"{self.a}{self.b}.0", f"{self.a}{self.b}.1"])


g = Graph()
for n in [
    PromptCollectionTestInvocation(id="src1", collection=["a", "b"]),
    PromptCollectionTestInvocation(id="src2", collection=["x", "y"]),
    IterateInvocation(id="iter_a"),
    IterateInvocation(id="iter_b"),
    PairTestInvocation(id="pairer"),
    IterateInvocation(id="inner_iter"),
    PromptTestInvocation(id="body"),
    CollectInvocation(id="collect"),
]:
    g.add_node(n)
for e in [
    ("src1", "collection", "iter_a", "collection"),
    ("src2", "collection", "iter_b", "collection"),
    ("iter_a", "item", "pairer", "a"),
    ("iter_b", "item", "pairer", "b"),
    ("pairer", "collection", "inner_iter", "collection"),
    ("inner_iter", "item", "body", "prompt"),
    ("body", "prompt", "collect", "item"),
]:
    g.add_edge(create_edge(*e))

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

print("inner_iter instances:", len(session.source_prepared_mapping["inner_iter"]), "(expected 8)")
print("body instances:      ", len(session.source_prepared_mapping["body"]), "(expected 8)")
print("body iteration paths:", sorted(session._get_iteration_path(p) for p in session.source_prepared_mapping["body"]))
for p in sorted(session.source_prepared_mapping["collect"], key=session._get_iteration_path):
    print(" collect", session._get_iteration_path(p), session.results[p].collection)

Observed

inner_iter instances: 8 (expected 8)
body instances:       12 (expected 8)
body iteration paths: [(0,0,0), (0,0,1), (0,1,0), (0,1,0), (0,1,1), (0,1,1),
                       (1,0,0), (1,0,0), (1,0,1), (1,0,1), (1,1,0), (1,1,1)]
 collect (0, 0) ['ax.0', 'ax.1']
 collect (0, 1) ['ay.0', 'ay.0', 'ay.1', 'ay.1']
 collect (1, 0) ['bx.0', 'bx.0', 'bx.1', 'bx.1']
 collect (1, 1) ['by.0', 'by.1']

Note inner_iter is correct at 8 instances — the duplication is introduced when body is prepared against them. The diagonal paths (0,0,*) and (1,1,*) are correct; only the off-diagonal ones are doubled.

Expected

body instances: 8
 collect (0, 0) ['ax.0', 'ax.1']
 collect (0, 1) ['ay.0', 'ay.1']
 collect (1, 0) ['bx.0', 'bx.1']
 collect (1, 1) ['by.0', 'by.1']

Analysis

For body, get_node_iterators() returns all three iterators (inner_iter, iter_a, iter_b), so _get_parent_iteration_mappings() takes the itertools.product path over their prepared nodes — 2 × 2 × 8 = 32 combinations. For each it resolves body's single parent inner_iter via _get_indexed_iteration_node() / get_iteration_node(), and every combination that resolves yields a mapping that prepare() turns into a body exec node.

Instrumenting that call confirms it yields 12 mappings, with each off-diagonal inner_iter exec node selected twice:

body: iterators=['inner_iter', 'iter_a', 'iter_b'] mappings_yielded=12
    (0, 0, 0): 1      (0, 1, 0): 2      (1, 0, 0): 2      (1, 1, 0): 1
    (0, 0, 1): 1      (0, 1, 1): 2      (1, 0, 1): 2      (1, 1, 1): 1

The (iter_a, iter_b) pair drawn from the product is never constrained to agree with the prefix of the inner_iter exec node it resolves to. Matching goes through _matches_parent_iterators(), which asks nx.has_path(execution_graph, iterator_exec_id, candidate) — satisfied by any ancestor iterator exec node on the path — so a combination whose pair disagrees with the resolved path is indistinguishable from the one that agrees. A fix should reject a mapping whose product-selected iterator exec nodes are inconsistent with the resolved parent's iteration path.

Environment

Reproduced at a21d74b8bd (current main) and at abb324a7d0 (#9483 head), identical output. Python 3.12.13.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions