Skip to content

run_async runs a lazy variadic component early, once per partial input, when its sender can still run again #13284

Closed as not planned

Description

@pcbeingused333

Describe the bug

When a lazy variadic socket gets its input from a component that can still run again, Pipeline.run_async() (and run_async_generator()) runs the receiving component early, once per partial input. Pipeline.run() waits and runs it once with all the inputs. The final outputs differ, and every component downstream also runs twice in async mode.

The smallest case uses only built-in joiners: a BranchJoiner (greedy variadic) fed by two branches, one of them a step longer, followed by a ListJoiner (lazy variadic). No loops.

To Reproduce

import asyncio

from haystack import Pipeline, component
from haystack.components.joiners import BranchJoiner, ListJoiner


@component
class Step:
    """Stand-in for any component: passes its input through and records the call."""

    calls: list[str] = []

    def __init__(self, name: str):
        self.name = name

    @component.output_types(out=list[str])
    def run(self, items: list[str]):
        Step.calls.append(self.name)
        return {"out": items}


def build() -> Pipeline:
    p = Pipeline()
    p.add_component("source", Step("source"))
    p.add_component("slow_branch", Step("slow_branch"))
    p.add_component("branch_joiner", BranchJoiner(list[str]))
    p.add_component("list_joiner", ListJoiner(list[str]))
    p.add_component("sink", Step("sink"))
    p.connect("source.out", "slow_branch.items")
    p.connect("source.out", "branch_joiner.value")
    p.connect("slow_branch.out", "branch_joiner.value")
    p.connect("branch_joiner.value", "list_joiner.values")
    p.connect("list_joiner.values", "sink.items")
    return p


data = {"source": {"items": ["a"]}}
Step.calls.clear()
print("run:      ", build().run(data, include_outputs_from={"list_joiner"}), Step.calls)
Step.calls.clear()
print("run_async:", asyncio.run(build().run_async(data, include_outputs_from={"list_joiner"})), Step.calls)
run:       {'list_joiner': {'values': ['a', 'a']}, 'sink': {'out': ['a', 'a']}} ['source', 'slow_branch', 'sink']
run_async: {'list_joiner': {'values': ['a']}, 'sink': {'out': ['a']}} ['source', 'slow_branch', 'sink', 'sink']

The result is the same on every run, so this is not the completion-order nondeterminism from #13204.

Expected behavior

run_async() gives the same result as run(). branch_joiner runs twice, once per branch, and list_joiner runs once with both outputs, so sink runs once.

What happens

  1. source runs. branch_joiner (HIGHEST priority) runs in isolation with source's output and sends it to list_joiner.
  2. list_joiner is now READY: _calculate_priority returns READY when all_predecessors_executed(), and its only predecessor, branch_joiner, has executed once. run_async_generator schedules READY components together, so list_joiner runs at the same time as slow_branch, with one input.
  3. slow_branch finishes, branch_joiner runs again, and list_joiner and sink run a second time. The second outputs replace the first ones in the pipeline outputs.

run() gives the expected result partly because of when it rebuilds its queue. It only refills the priority queue when _is_queue_stale(). So after branch_joiner's first run it pops slow_branch, which is still READY from the earlier fill, before it sees that list_joiner became READY. By the time it refills, branch_joiner has a second input and runs first (HIGHEST). The root cause looks shared: READY means "every predecessor has run at least once", not "no predecessor can still send". run() doesn't hit it here because of its scheduling order.

Additional context

  • I found this by differential fuzzing run() against run_async() on random small acyclic pipelines (sum, router, optional-input and greedy-variadic components). 22 of 3,000 seeds differed, and every difference involved a lazy variadic or optional input downstream of a greedy-variadic component whose upstream was still running.
  • A possible direction: treat a lazy variadic socket as READY only when none of its senders can still receive input. That is the reachability _is_pipeline_possibly_blocked / can_not_receive_inputs_from_pipeline already reason about. I'm happy to work on a PR if you think that's the right approach. It changes core scheduling, so I wanted to check first.

System:

  • Haystack: main @ aca9fc2 (3.1.0rc0)
  • Python 3.12.3, Linux (WSL2)

👋 Hello there! This issue will be handled internally and isn't open for external contributions. If you'd like to contribute, please take a look at issues labeled contributions welcome or good first issue. We'd really appreciate it!

Activity

  1. added
    handled internallyHandled by the team, not open for external contributions
    on Oct 16, 2026
  2. added theissue type on Oct 16, 2026
  3. sjrl commented on Oct 16, 2026

    @sjrl
    Contributor

    Thanks for the detailed report and the fuzzing work, the repro is really clear!

    I believe this is outside what BranchJoiner is designed for though. It's meant for joining branches where only one fires per run (e.g. after a router, or closing a loop), which is also why it raises ValueError if it receives more than one input at once. Since it's greedy, it runs as soon as any value arrives, so when both branches fire it runs once per value and everything downstream does too.

    When several branches are expected to produce output, a lazy variadic joiner like ListJoiner or DocumentJoiner is the right fit. If you replace the BranchJoiner in your repro with a ListJoiner (or connect both branches directly to the ListJoiner), run() and run_async() both return ['a', 'a'] and sink runs once.

    We'll clarify this in the BranchJoiner docstring and docs page.

    Let us know if you have a use case where BranchJoiner with several firing branches is needed and a lazy joiner doesn't work!

  4. removed theissue type on Oct 16, 2026
  5. added theissue type on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    handled internallyHandled by the team, not open for external contributions

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions