Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

1316 lines
63 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import contextlib
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from typing import Any, ClassVar, cast
from haystack import logging, tracing
from haystack.core.component import Component
from haystack.core.errors import BreakpointException, PipelineInvalidPipelineSnapshotError, PipelineRuntimeError
from haystack.core.pipeline.base import (
_COMPONENT_INPUT,
_COMPONENT_OUTPUT,
_COMPONENT_VISITS,
ComponentPriority,
PipelineBase,
_validate_component_output_keys,
)
from haystack.core.pipeline.breakpoint import (
SnapshotCallback,
_create_pipeline_snapshot,
_save_pipeline_snapshot,
_validate_break_point_against_pipeline,
_validate_pipeline_snapshot_against_pipeline,
)
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
from haystack.dataclasses import AsyncStreamingCallbackT, StreamingCallbackT, StreamingChunk, select_streaming_callback
from haystack.dataclasses.breakpoints import Breakpoint, PipelineSnapshot
from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback
from haystack.telemetry import pipeline_running
from haystack.utils import _deserialize_value_with_schema
from haystack.utils.async_utils import _execute_component_async
from haystack.utils.misc import _get_output_dir
logger = logging.getLogger(__name__)
class _EndOfStream:
"""Sentinel type indicating no more chunks will arrive on the stream."""
class PipelineStreamHandle:
"""
Handle returned by `Pipeline.stream()`.
Async-iterable over `StreamingChunk`s produced by streaming components in the pipeline. After iteration ends,
`result` holds the final pipeline output dict.
By default, iteration cleans up automatically: if the consumer abandons iteration, the underlying pipeline task is
cancelled. `aclose()` is also available for explicit cleanup.
"""
_END_OF_STREAM: ClassVar[_EndOfStream] = _EndOfStream()
_CLEANUP_TIMEOUT_SECONDS: ClassVar[float] = 1.0
def __init__(
self,
queue: asyncio.Queue["StreamingChunk | _EndOfStream"],
task: asyncio.Task[dict[str, Any]],
cancel_on_abandon: bool = True,
) -> None:
self._queue = queue
self._task = task
self._cancel_on_abandon = cancel_on_abandon
async def __aiter__(self) -> AsyncIterator[StreamingChunk]:
"""
Drain the queue and cancel the pipeline task if iteration is abandoned.
`__aiter__` is an async generator function: each `async for` call gets a generator and `try/finally` runs on
exit. When `cancel_on_abandon` is True (default), abandoned iteration cancels the pipeline task; when False,
the task is left running to completion.
"""
try:
while True:
item = await self._queue.get()
if item is self._END_OF_STREAM:
await self._task # called to make exceptions surface
return
yield cast(StreamingChunk, item) # at this point, item is guaranteed to be a StreamingChunk
finally:
if self._cancel_on_abandon:
await self.aclose()
@property
def result(self) -> dict[str, Any]:
"""
Final pipeline output dict, available only after a successful, complete run.
Raises a `RuntimeError` if the pipeline has not finished or was cancelled. If the pipeline failed, re-raises the
original exception.
"""
if not self._task.done():
raise RuntimeError("Pipeline has not finished; iterate the handle first.")
if self._task.cancelled():
raise RuntimeError("Pipeline was cancelled; no result available.")
exc = self._task.exception()
if exc is not None:
raise exc
return self._task.result()
async def aclose(self) -> None:
"""
Cancel the underlying pipeline task.
Bounded by `_CLEANUP_TIMEOUT_SECONDS` so that components cannot block cleanup indefinitely.
"""
if not self._task.done():
self._task.cancel()
with contextlib.suppress(BaseException):
await asyncio.wait_for(self._task, timeout=self._CLEANUP_TIMEOUT_SECONDS)
class Pipeline(PipelineBase):
"""
Orchestration engine that runs components according to the execution graph.
Supports both a synchronous run path (`run`) and an asynchronous run path
(`run_async`, `run_async_generator`, `stream`).
"""
@staticmethod
def _run_component(
component_name: str,
component: dict[str, Any],
inputs: dict[str, Any],
component_visits: dict[str, int],
parent_span: tracing.Span | None = None,
*,
break_point: Breakpoint | None = None,
) -> Mapping[str, Any]:
"""
Runs a Component with the given inputs.
:param component_name: Name of the Component.
:param component: Component with component metadata.
:param inputs: Inputs for the Component.
:param component_visits: Current state of component visits.
:param parent_span: The parent span to use for the newly created span.
This is to allow tracing to be correctly linked to the pipeline run.
:raises PipelineRuntimeError: If Component doesn't return a dictionary.
:return: The output of the Component.
"""
if (
isinstance(break_point, Breakpoint)
and break_point.component_name == component_name
and break_point.visit_count == component_visits[component_name]
):
raise BreakpointException.from_triggered_breakpoint(break_point=break_point)
instance: Component = component["instance"]
with PipelineBase._create_component_span(
component_name=component_name, instance=instance, inputs=inputs, parent_span=parent_span
) as span:
# deepcopy inputs before passing to the tracer so that even if a tracer mutates them
# the component always receives the original unmodified values
inputs_copy = _deepcopy_with_exceptions(inputs)
span.set_content_tag(_COMPONENT_INPUT, inputs)
logger.info("Running component {component_name}", component_name=component_name)
try:
component_output = instance.run(**inputs_copy)
except BreakpointException as error:
# Re-raise BreakpointException to preserve the original exception context
# This is important when Agent components internally use Pipeline._run_component
# and trigger breakpoints that need to bubble up to the main pipeline
raise error
# Any components that internally use Pipeline._run_component could raise a PipelineRuntimeError with
# additional context (e.g. Agent raises an agent snapshot) so we re-raise here instead of wrapping it in
# another PipelineRuntimeError
except PipelineRuntimeError as runtime_error:
raise runtime_error
# Catch all other exceptions and wrap them in a PipelineRuntimeError
except Exception as error:
raise PipelineRuntimeError.from_exception(component_name, instance.__class__, error) from error
component_visits[component_name] += 1
if not isinstance(component_output, Mapping):
raise PipelineRuntimeError.from_invalid_output(component_name, instance.__class__, component_output)
_validate_component_output_keys(component_name, component, component_output)
span.set_tag(_COMPONENT_VISITS, component_visits[component_name])
span.set_content_tag(_COMPONENT_OUTPUT, component_output)
return component_output
def run( # noqa: PLR0915, PLR0912, C901
self,
data: dict[str, Any],
include_outputs_from: set[str] | None = None,
*,
break_point: Breakpoint | None = None,
pipeline_snapshot: PipelineSnapshot | None = None,
snapshot_callback: SnapshotCallback | None = None,
) -> dict[str, Any]:
"""
Runs the Pipeline with given input data.
`run` executes synchronously and blocks the calling thread until the run completes. In an async context,
use `run_async` instead.
Usage:
```python
from haystack import Pipeline, Document
from haystack.components.builders.answer_builder import AnswerBuilder
from haystack.components.builders.chat_prompt_builder import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.dataclasses import ChatMessage
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.utils import Secret
# Write documents to InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="My name is Jean and I live in Paris."),
Document(content="My name is Mark and I live in Berlin."),
Document(content="My name is Giorgio and I live in Rome.")
])
retriever = InMemoryBM25Retriever(document_store=document_store)
prompt_template = \"\"\"
Given these documents, answer the question.
Documents:
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{question}}
Answer:
\"\"\"
template = [ChatMessage.from_user(prompt_template)]
prompt_builder = ChatPromptBuilder(
template=template,
required_variables=["question", "documents"],
variables=["question", "documents"]
)
llm = OpenAIChatGenerator()
rag_pipeline = Pipeline()
rag_pipeline.add_component("retriever", retriever)
rag_pipeline.add_component("prompt_builder", prompt_builder)
rag_pipeline.add_component("llm", llm)
rag_pipeline.connect("retriever", "prompt_builder.documents")
rag_pipeline.connect("prompt_builder", "llm")
question = "Who lives in Paris?"
results = rag_pipeline.run(
{
"retriever": {"query": question},
"prompt_builder": {"question": question},
}
)
print(results["llm"]["replies"][0].text)
# Jean lives in Paris
```
:param data:
A dictionary of inputs for the pipeline's components. Each key is a component name
and its value is a dictionary of that component's input parameters:
```
data = {
"comp1": {"input1": 1, "input2": 2},
}
```
For convenience, this format is also supported when input names are unique:
```
data = {
"input1": 1, "input2": 2,
}
```
:param include_outputs_from:
Set of component names whose individual outputs are to be
included in the pipeline's output. For components that are
invoked multiple times (in a loop), only the last-produced
output is included.
:param break_point:
A breakpoint that pauses execution before the specified component runs by raising a
`BreakpointException` carrying a `PipelineSnapshot` of the current pipeline state.
:param pipeline_snapshot:
A snapshot of a previously interrupted pipeline execution to resume from. Can be combined with
`break_point` to step through a pipeline: resume from the snapshot and pause again at the next
breakpoint. The `break_point` must target a different component or visit count than the one the
snapshot was created at, otherwise it would trigger again before any progress is made.
:param snapshot_callback:
Optional callback function that is invoked when a pipeline snapshot is created.
The callback receives a `PipelineSnapshot` object and can return an optional string
(e.g., a file path or identifier).
If provided, the callback is used instead of the default file-saving behavior,
allowing custom handling of snapshots (e.g., saving to a database, sending to a remote service).
If not provided, the default behavior saves snapshots to a JSON file.
:returns:
A dictionary where each entry corresponds to a component name
and its output. If `include_outputs_from` is `None`, this dictionary
will only contain the outputs of leaf components, i.e., components
without outgoing connections.
:raises ValueError:
If invalid inputs are provided to the pipeline.
:raises PipelineRuntimeError:
If the Pipeline contains cycles with unsupported connections that would cause
it to get stuck and fail running.
Or if a Component fails or returns output in an unsupported type.
:raises PipelineMaxComponentRuns:
If a Component reaches the maximum number of times it can be run in this Pipeline.
:raises PipelineBreakpointException:
When a pipeline_breakpoint is triggered. Contains the component name, state, and partial results.
"""
pipeline_running(self) # telemetry
if (
break_point
and pipeline_snapshot
and break_point.component_name == pipeline_snapshot.break_point.component_name
and break_point.visit_count == pipeline_snapshot.break_point.visit_count
):
msg = (
"The provided break_point targets the same component and visit count as the break_point of the "
"pipeline_snapshot. It would trigger again before the resumed component runs, so the pipeline "
"could not make any progress. Provide a break_point with a different component or visit count."
)
raise PipelineInvalidPipelineSnapshotError(message=msg)
# make sure all breakpoints are valid, i.e. reference components in the pipeline
if break_point:
_validate_break_point_against_pipeline(break_point, self.graph)
# warm up the pipeline by running each component's warm_up method
self.warm_up()
if include_outputs_from is None:
include_outputs_from = set()
pipeline_outputs: dict[str, Any] = {}
if not pipeline_snapshot:
# normalize `data`
data = self._prepare_component_input_data(data)
# Raise ValueError if input is malformed in some way
self.validate_input(data)
# We create a list of components in the pipeline sorted by name, so that the algorithm runs
# deterministically and independent of insertion order into the pipeline.
ordered_component_names = sorted(self.graph.nodes.keys())
# We track component visits to decide if a component can run.
component_visits = dict.fromkeys(ordered_component_names, 0)
else:
# Validate the pipeline snapshot against the current pipeline graph
_validate_pipeline_snapshot_against_pipeline(pipeline_snapshot, self.graph)
# Handle resuming the pipeline from a snapshot
component_visits = pipeline_snapshot.pipeline_state.component_visits
ordered_component_names = pipeline_snapshot.ordered_component_names
data = _deserialize_value_with_schema(pipeline_snapshot.pipeline_state.inputs)
# include_outputs_from from the snapshot when resuming
include_outputs_from = pipeline_snapshot.include_outputs_from
# also intermediate_outputs from the snapshot when resuming
pipeline_outputs = _deserialize_value_with_schema(pipeline_snapshot.pipeline_state.pipeline_outputs)
cached_topological_sort = None
# We need to access a component's receivers multiple times during a pipeline run.
# We store them here for easy access.
cached_receivers = {name: self._find_receivers_from(name) for name in ordered_component_names}
with tracing.tracer.trace(
"haystack.pipeline.run",
tags={
"haystack.pipeline.input_data": data,
"haystack.pipeline.output_data": pipeline_outputs,
"haystack.pipeline.metadata": self.metadata,
"haystack.pipeline.max_runs_per_component": self._max_runs_per_component,
"haystack.pipeline.execution_mode": "sync",
},
) as span:
inputs = self._convert_to_internal_format(pipeline_inputs=data)
priority_queue = self._fill_queue(ordered_component_names, inputs, component_visits)
# check if pipeline is blocked before execution
self.validate_pipeline(priority_queue)
while True:
candidate = self._get_next_runnable_component(priority_queue, component_visits)
# If there are no runnable components left, we can exit the loop.
# In practice this rarely happens because the queue is constantly refilled even with components that
# have already run. They just get a BLOCKED priority since their inputs have already been consumed.
if candidate is None:
break
priority, component_name, component = candidate
# If the next component is blocked, we do a check to see if the pipeline is possibly blocked and raise
# a warning if it is.
if priority == ComponentPriority.BLOCKED:
if self._is_pipeline_possibly_blocked(current_pipeline_outputs=pipeline_outputs):
# Pipeline is most likely blocked (most likely a configuration issue) so we raise a warning.
self._find_components_blocking_pipeline(
priority_queue=priority_queue, component_visits=component_visits, inputs=inputs
)
# We always exit the loop since we cannot run the next component.
break
if len(priority_queue) > 0 and priority == ComponentPriority.DEFER:
component_name, topological_sort = self._tiebreak_waiting_components(
component_name=component_name,
priority=priority,
priority_queue=priority_queue,
topological_sort=cached_topological_sort,
)
cached_topological_sort = topological_sort
component = self._get_component_with_graph_metadata_and_visits(
component_name, component_visits[component_name]
)
if pipeline_snapshot:
is_resume = pipeline_snapshot.break_point.component_name == component_name
else:
is_resume = False
component_inputs = self._consume_component_inputs(
component_name=component_name, component=component, inputs=inputs, is_resume=is_resume
)
# We need to add missing defaults using default values from input sockets because the run signature
# might not provide these defaults for components with inputs defined dynamically upon component
# initialization
component_inputs = self._add_missing_input_defaults(component_inputs, component["input_sockets"])
# Scenario 1: Pipeline snapshot is provided to resume the pipeline at a specific component
# Deserialize the component_inputs if they are passed in the pipeline_snapshot.
# this check will prevent other component_inputs generated at runtime from being deserialized
if pipeline_snapshot and component_name in pipeline_snapshot.pipeline_state.inputs.keys():
for key, value in component_inputs.items():
component_inputs[key] = _deserialize_value_with_schema(value)
try:
component_outputs = self._run_component(
component_name=component_name,
component=component,
inputs=component_inputs, # the inputs to the current component
component_visits=component_visits,
parent_span=span,
break_point=break_point,
)
except (BreakpointException, PipelineRuntimeError) as error:
saved_break_point: Breakpoint
if isinstance(error, PipelineRuntimeError):
saved_break_point = Breakpoint(
component_name=component_name,
visit_count=component_visits[component_name],
snapshot_file_path=_get_output_dir("pipeline_snapshot"),
)
else:
saved_break_point = error.break_point
# Create a snapshot of the state of the pipeline before the error occurred.
pipeline_snapshot = _create_pipeline_snapshot(
inputs=_deepcopy_with_exceptions(inputs),
component_inputs=_deepcopy_with_exceptions(component_inputs),
break_point=saved_break_point,
component_visits=component_visits,
original_input_data=data,
ordered_component_names=ordered_component_names,
include_outputs_from=include_outputs_from,
pipeline_outputs=pipeline_outputs,
)
# Attach the pipeline snapshot to the error before re-raising
error.pipeline_snapshot = pipeline_snapshot
full_file_path = _save_pipeline_snapshot(
pipeline_snapshot=pipeline_snapshot,
raise_on_failure=isinstance(error, BreakpointException),
snapshot_callback=snapshot_callback,
)
error.pipeline_snapshot_file_path = full_file_path
raise error
# Updates global input state with component outputs and returns outputs that should go to
# pipeline outputs.
component_pipeline_outputs = self._write_component_outputs(
component_name=component_name,
component_outputs=component_outputs,
inputs=inputs,
receivers=cached_receivers[component_name],
include_outputs_from=include_outputs_from,
)
if component_pipeline_outputs or component_name in include_outputs_from:
pipeline_outputs[component_name] = component_pipeline_outputs
if self._is_queue_stale(priority_queue):
priority_queue = self._fill_queue(ordered_component_names, inputs, component_visits)
if isinstance(break_point, Breakpoint):
logger.warning(
"The given breakpoint {break_point} was never triggered. This is because:\n"
"1. The provided component is not a part of the pipeline execution path.\n"
"2. The component did not reach the visit count specified in the pipeline_breakpoint",
pipeline_breakpoint=break_point,
)
return pipeline_outputs
@staticmethod
async def _run_component_async(
component_name: str,
component: dict[str, Any],
component_inputs: dict[str, Any],
component_visits: dict[str, int],
parent_span: tracing.Span | None = None,
*,
break_point: Breakpoint | None = None,
) -> Mapping[str, Any]:
"""
Executes a single component asynchronously.
If the component supports async execution, it is awaited directly as it will run async;
otherwise the component is offloaded to executor.
The method also updates the `visits` count of the component, writes outputs to `inputs_state`,
and returns pruned outputs that get stored in `pipeline_outputs`.
:param component_name: The name of the component.
:param component_inputs: Inputs for the component.
:returns: Outputs from the component that can be yielded from run_async_generator.
"""
if (
isinstance(break_point, Breakpoint)
and break_point.component_name == component_name
and break_point.visit_count == component_visits[component_name]
):
raise BreakpointException.from_triggered_breakpoint(break_point=break_point)
instance: Component = component["instance"]
with PipelineBase._create_component_span(
component_name=component_name, instance=instance, inputs=component_inputs, parent_span=parent_span
) as span:
# deepcopy inputs before passing to the tracer so that even if a tracer mutates them
# the component always receives the original unmodified values
component_inputs_copy = _deepcopy_with_exceptions(component_inputs)
span.set_content_tag(_COMPONENT_INPUT, component_inputs)
logger.info("Running component {component_name}", component_name=component_name)
try:
# For sync-only components, _run_component_async dispatches to a thread via asyncio.to_thread,
# which copies the current contextvars context — preserving e.g. the active tracing span.
outputs = await _execute_component_async(instance, **component_inputs_copy)
except Exception as error:
raise PipelineRuntimeError.from_exception(component_name, instance.__class__, error) from error
component_visits[component_name] += 1
if not isinstance(outputs, Mapping):
raise PipelineRuntimeError.from_invalid_output(component_name, instance.__class__, outputs)
_validate_component_output_keys(component_name, component, outputs)
span.set_tag(_COMPONENT_VISITS, component_visits[component_name])
span.set_content_tag(_COMPONENT_OUTPUT, outputs)
return outputs
@staticmethod
async def _wait_for_tasks(
running_tasks: dict[asyncio.Task, str], scheduled_components: set[str], *, return_when: str
) -> AsyncIterator[dict[str, Any]]:
"""
Waits for running tasks to finish and yields their partial outputs.
:param running_tasks: Mapping of in-flight tasks to the name of the component they run. Finished tasks are
removed in place.
:param scheduled_components: Set of component names that are scheduled but not yet finished. Finished
components are discarded in place.
:param return_when: Either `asyncio.FIRST_COMPLETED` to wait for a single task or `asyncio.ALL_COMPLETED` to
wait for every running task.
:returns: An async iterator of partial outputs, one per finished component that produced an output.
"""
if not running_tasks:
return
done, _pending = await asyncio.wait(running_tasks.keys(), return_when=return_when)
for finished in done:
finished_component_name = running_tasks.pop(finished)
try:
partial_result = finished.result()
except Exception:
# A component failed. Cancel and drain the remaining in-flight tasks so they don't keep running in
# the background (and leak) after the run is aborted, then re-raise the original error.
await Pipeline._cancel_in_flight_tasks(running_tasks, scheduled_components)
raise
scheduled_components.discard(finished_component_name)
if partial_result:
yield {finished_component_name: _deepcopy_with_exceptions(partial_result)}
@staticmethod
async def _cancel_in_flight_tasks(running_tasks: dict[asyncio.Task, str], scheduled_components: set[str]) -> None:
"""
Cancels all in-flight tasks and waits for the cancellations to settle.
Called when a component fails or when the run is abandoned early so that sibling tasks don't keep running in
the background after the pipeline run is aborted. Exceptions from the cancelled tasks are suppressed since we
are already unwinding.
Note: cancellation is only effective for components that run natively async. Sync components are offloaded to
a thread via `asyncio.to_thread` and a running thread cannot be interrupted: cancelling its task abandons
the await, but the thread keeps running until the component's `run` returns. Its outputs are then discarded
(the task never writes them to the pipeline state), so state stays consistent, but side effects (e.g. API
calls) still complete and the thread can outlive this cleanup.
:param running_tasks: Mapping of in-flight tasks to component names. Cleared in place.
:param scheduled_components: Set of scheduled-but-unfinished component names. Cleared in place.
"""
for task in running_tasks:
task.cancel()
# return_exceptions=True so a failing or cancelled sibling doesn't mask the original error we re-raise.
await asyncio.gather(*running_tasks.keys(), return_exceptions=True)
for component_name in running_tasks.values():
scheduled_components.discard(component_name)
running_tasks.clear()
async def _run_component_in_isolation(
self,
*,
component_name: str,
inputs: dict[str, dict[str, list[dict[str, Any]]]],
pipeline_outputs: dict[str, Any],
component_visits: dict[str, int],
running_tasks: dict[asyncio.Task, str],
scheduled_components: set[str],
cached_receivers: dict[str, Any],
include_outputs_from: set[str],
parent_span: tracing.Span | None,
) -> AsyncIterator[dict[str, Any]]:
"""
Runs a component with HIGHEST priority in isolation.
We need to run components with HIGHEST priority (i.e. components with a GreedyVariadic input socket) by
themselves, without any other components running concurrently. Otherwise, downstream components could produce
additional inputs for the GreedyVariadic socket.
:param component_name: The name of the component to run.
:param inputs: The global input state shared by all components. Mutated in place.
:param pipeline_outputs: The accumulated pipeline outputs. Mutated in place.
:param component_visits: Current state of component visits. Mutated in place.
:param running_tasks: Mapping of in-flight tasks to component names. Drained in place before running.
:param scheduled_components: Set of scheduled-but-unfinished component names. Mutated in place.
:param cached_receivers: Precomputed mapping of component name to its downstream receivers.
:param include_outputs_from: Set of component names whose outputs should always be included in the output.
:param parent_span: The parent tracing span for the pipeline run.
:returns: An async iterator of partial outputs.
"""
# 1) Wait for all in-flight tasks to finish so the HIGHEST component runs alone.
async for partial_outputs in self._wait_for_tasks(
running_tasks, scheduled_components, return_when=asyncio.ALL_COMPLETED
):
yield partial_outputs
if component_name in scheduled_components:
# If it's already scheduled for some reason, skip.
return
# 2) Run the HIGHEST component by itself.
scheduled_components.add(component_name)
component = self._get_component_with_graph_metadata_and_visits(component_name, component_visits[component_name])
component_inputs = self._consume_component_inputs(component_name, component, inputs)
component_inputs = self._add_missing_input_defaults(component_inputs, component["input_sockets"])
component_outputs = await self._run_component_async(
component_name=component_name,
component=component,
component_inputs=component_inputs,
component_visits=component_visits,
parent_span=parent_span,
)
pruned = self._write_component_outputs(
component_name=component_name,
component_outputs=component_outputs,
inputs=inputs,
receivers=cached_receivers[component_name],
include_outputs_from=include_outputs_from,
)
if pruned or component_name in include_outputs_from:
pipeline_outputs[component_name] = pruned
scheduled_components.remove(component_name)
if pruned or component_name in include_outputs_from:
yield {component_name: _deepcopy_with_exceptions(pruned)}
def _schedule_component(
self,
*,
component_name: str,
inputs: dict[str, dict[str, list[dict[str, Any]]]],
pipeline_outputs: dict[str, Any],
component_visits: dict[str, int],
running_tasks: dict[asyncio.Task, str],
scheduled_components: set[str],
ready_sem: asyncio.Semaphore,
cached_receivers: dict[str, Any],
include_outputs_from: set[str],
parent_span: tracing.Span | None,
) -> None:
"""
Schedules a component to run as a background task without waiting for it to finish.
Inputs are consumed synchronously here (before the task is created) so that other components scheduled in the
same iteration of the scheduling loop observe the updated input state and don't race for the same inputs.
:param component_name: The name of the component to schedule.
:param inputs: The global input state shared by all components. Mutated in place.
:param pipeline_outputs: The accumulated pipeline outputs. Mutated in place by the task once it finishes.
:param component_visits: Current state of component visits. Mutated in place by the task once it finishes.
:param running_tasks: Mapping of in-flight tasks to component names. The new task is registered here.
:param scheduled_components: Set of scheduled-but-unfinished component names. Mutated in place.
:param ready_sem: Semaphore bounding how many components run concurrently.
:param cached_receivers: Precomputed mapping of component name to its downstream receivers.
:param include_outputs_from: Set of component names whose outputs should always be included in the output.
:param parent_span: The parent tracing span for the pipeline run.
"""
if component_name in scheduled_components:
return # already scheduled, do nothing
scheduled_components.add(component_name)
component = self._get_component_with_graph_metadata_and_visits(component_name, component_visits[component_name])
component_inputs = self._consume_component_inputs(component_name, component, inputs)
component_inputs = self._add_missing_input_defaults(component_inputs, component["input_sockets"])
async def _runner() -> Mapping[str, Any]:
async with ready_sem:
component_outputs = await self._run_component_async(
component_name=component_name,
component=component,
component_inputs=component_inputs,
component_visits=component_visits,
parent_span=parent_span,
)
pruned = self._write_component_outputs(
component_name=component_name,
component_outputs=component_outputs,
inputs=inputs,
receivers=cached_receivers[component_name],
include_outputs_from=include_outputs_from,
)
if pruned or component_name in include_outputs_from:
pipeline_outputs[component_name] = pruned
scheduled_components.remove(component_name)
return pruned
task = asyncio.create_task(_runner())
running_tasks[task] = component_name
async def run_async_generator( # noqa: PLR0915,C901
self, data: dict[str, Any], include_outputs_from: set[str] | None = None, concurrency_limit: int = 4
) -> AsyncGenerator[dict[str, Any], None]:
"""
Executes the pipeline step by step asynchronously, yielding partial outputs when any component finishes.
Usage:
```python
from haystack import Document
from haystack.components.builders import ChatPromptBuilder
from haystack.dataclasses import ChatMessage
from haystack.utils import Secret
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack import Pipeline
import asyncio
# Write documents to InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="My name is Jean and I live in Paris."),
Document(content="My name is Mark and I live in Berlin."),
Document(content="My name is Giorgio and I live in Rome.")
])
prompt_template = [
ChatMessage.from_user(
'''
Given these documents, answer the question.
Documents:
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{question}}
Answer:
''')
]
# Create and connect pipeline components
retriever = InMemoryBM25Retriever(document_store=document_store)
prompt_builder = ChatPromptBuilder(template=prompt_template)
llm = OpenAIChatGenerator()
rag_pipeline = Pipeline()
rag_pipeline.add_component("retriever", retriever)
rag_pipeline.add_component("prompt_builder", prompt_builder)
rag_pipeline.add_component("llm", llm)
rag_pipeline.connect("retriever", "prompt_builder.documents")
rag_pipeline.connect("prompt_builder", "llm")
# Prepare input data
question = "Who lives in Paris?"
data = {
"retriever": {"query": question},
"prompt_builder": {"question": question},
}
# Process results as they become available
async def process_results():
async for partial_output in rag_pipeline.run_async_generator(
data=data,
include_outputs_from={"retriever", "llm"}
):
# Each partial_output contains the results from a completed component
if "retriever" in partial_output:
print("Retrieved documents:", len(partial_output["retriever"]["documents"]))
if "llm" in partial_output:
print("Generated answer:", partial_output["llm"]["replies"][0])
asyncio.run(process_results())
```
:param data: Initial input data to the pipeline.
:param concurrency_limit: The maximum number of components that are allowed to run concurrently.
:param include_outputs_from:
Set of component names whose individual outputs are to be
included in the pipeline's output. For components that are
invoked multiple times (in a loop), only the last-produced
output is included.
:return: An async iterator containing partial (and final) outputs.
:raises ValueError:
If invalid inputs are provided to the pipeline, or if `concurrency_limit` is less than 1.
:raises PipelineMaxComponentRuns:
If a component exceeds the maximum number of allowed executions within the pipeline.
:raises PipelineRuntimeError:
If the Pipeline contains cycles with unsupported connections that would cause
it to get stuck and fail running.
Or if a Component fails or returns output in an unsupported type.
"""
if concurrency_limit < 1:
raise ValueError("concurrency_limit must be greater than or equal to 1.")
pipeline_running(self) # telemetry
# warm up the pipeline by running each component's warm_up_async (or warm_up) method
await self.warm_up_async()
if include_outputs_from is None:
include_outputs_from = set()
pipeline_outputs: dict[str, Any] = {}
# Normalize `data` and raise ValueError if the input is malformed in some way.
data = self._prepare_component_input_data(data)
# Raise ValueError if input is malformed in some way
self.validate_input(data)
# We create a list of components in the pipeline sorted by name, so that the algorithm runs
# deterministically and independent of insertion order into the pipeline.
ordered_component_names = sorted(self.graph.nodes.keys())
# We track component visits to decide if a component can run.
component_visits = dict.fromkeys(ordered_component_names, 0)
cached_topological_sort = None
# We need to access a component's receivers multiple times during a pipeline run.
# We store them here for easy access.
cached_receivers = {name: self._find_receivers_from(name) for name in ordered_component_names}
# Ephemeral concurrency state shared (and mutated in place) by the scheduling helpers below.
ready_sem = asyncio.Semaphore(concurrency_limit)
running_tasks: dict[asyncio.Task, str] = {}
# A set of component names that have been scheduled but not finished.
scheduled_components: set[str] = set()
with tracing.tracer.trace(
"haystack.pipeline.run",
tags={
"haystack.pipeline.input_data": data,
"haystack.pipeline.output_data": pipeline_outputs,
"haystack.pipeline.metadata": self.metadata,
"haystack.pipeline.max_runs_per_component": self._max_runs_per_component,
"haystack.pipeline.execution_mode": "async",
},
) as parent_span:
inputs = self._convert_to_internal_format(pipeline_inputs=data)
# check if pipeline is blocked before execution
self.validate_pipeline(self._fill_queue(ordered_component_names, inputs, component_visits))
try:
while True:
# We rebuild the priority queue every iteration: each iteration waits for one or more concurrent
# tasks to finish, which mutates `inputs` and can change many components' priorities at once, so
# we rebuild to give every scheduling decision an up-to-date view.
priority_queue = self._fill_queue(ordered_component_names, inputs, component_visits)
candidate = self._get_next_runnable_component(priority_queue, component_visits)
# If we can't make progress with the queue but tasks are running, we wait for one to finish and
# retry to potentially unblock the priority queue.
if (candidate is None or candidate[0] == ComponentPriority.BLOCKED) and running_tasks:
async for partial_outputs in self._wait_for_tasks(
running_tasks, scheduled_components, return_when=asyncio.FIRST_COMPLETED
):
yield partial_outputs
continue
# If there are no runnable components left and nothing is running, we can exit the loop.
if candidate is None and not running_tasks:
break
priority, component_name, component = candidate # type: ignore
# If the next component is blocked, we do a check to see if the pipeline is possibly blocked and
# raise a warning if it is.
if priority == ComponentPriority.BLOCKED and not running_tasks:
if self._is_pipeline_possibly_blocked(current_pipeline_outputs=pipeline_outputs):
# Pipeline is most likely blocked (most likely a configuration issue) so we raise a warning.
self._find_components_blocking_pipeline(
priority_queue=priority_queue, component_visits=component_visits, inputs=inputs
)
# We always exit the loop since we cannot run the next component.
break
# If the next component is already scheduled, we wait for a task to finish to make progress.
if component_name in scheduled_components:
async for partial_outputs in self._wait_for_tasks(
running_tasks, scheduled_components, return_when=asyncio.FIRST_COMPLETED
):
yield partial_outputs
continue
if priority == ComponentPriority.HIGHEST:
# A HIGHEST priority component must run alone, so we hand off to the isolation helper.
async for partial_outputs in self._run_component_in_isolation(
component_name=component_name,
inputs=inputs,
pipeline_outputs=pipeline_outputs,
component_visits=component_visits,
running_tasks=running_tasks,
scheduled_components=scheduled_components,
cached_receivers=cached_receivers,
include_outputs_from=include_outputs_from,
parent_span=parent_span,
):
yield partial_outputs
continue
if priority == ComponentPriority.READY:
# Schedule this component, then schedule as many additional READY components as concurrency
# allows.
self._schedule_component(
component_name=component_name,
inputs=inputs,
pipeline_outputs=pipeline_outputs,
component_visits=component_visits,
running_tasks=running_tasks,
scheduled_components=scheduled_components,
ready_sem=ready_sem,
cached_receivers=cached_receivers,
include_outputs_from=include_outputs_from,
parent_span=parent_span,
)
# Possibly schedule more READY tasks if concurrency not fully used
while len(priority_queue) > 0 and not ready_sem.locked():
peek_priority, peek_name = priority_queue.peek()
if peek_priority != ComponentPriority.READY:
# We stop scheduling: the next component is BLOCKED (can't run), HIGHEST (must run
# alone), or DEFER (waiting for more inputs - we only schedule it once it becomes
# READY).
break
priority_queue.pop()
self._schedule_component(
component_name=peek_name,
inputs=inputs,
pipeline_outputs=pipeline_outputs,
component_visits=component_visits,
running_tasks=running_tasks,
scheduled_components=scheduled_components,
ready_sem=ready_sem,
cached_receivers=cached_receivers,
include_outputs_from=include_outputs_from,
parent_span=parent_span,
)
# We only schedule components with priority DEFER when no other tasks are running.
elif priority == ComponentPriority.DEFER and not running_tasks:
if len(priority_queue) > 0:
component_name, cached_topological_sort = self._tiebreak_waiting_components(
component_name=component_name,
priority=priority,
priority_queue=priority_queue,
topological_sort=cached_topological_sort,
)
self._schedule_component(
component_name=component_name,
inputs=inputs,
pipeline_outputs=pipeline_outputs,
component_visits=component_visits,
running_tasks=running_tasks,
scheduled_components=scheduled_components,
ready_sem=ready_sem,
cached_receivers=cached_receivers,
include_outputs_from=include_outputs_from,
parent_span=parent_span,
)
# To make progress, we wait for one task to complete before restarting the loop.
async for partial_outputs in self._wait_for_tasks(
running_tasks, scheduled_components, return_when=asyncio.FIRST_COMPLETED
):
yield partial_outputs
# Safety net: drain any leftover tasks once the scheduling loop has finished. With the current loop
# both `break` paths require `running_tasks` to be empty, so this is a no-op. We keep it so that a
# future change adding a `break` that leaves tasks in flight doesn't lose outputs.
async for partial_outputs in self._wait_for_tasks(
running_tasks, scheduled_components, return_when=asyncio.ALL_COMPLETED
):
yield partial_outputs
# Yield the final pipeline outputs.
yield pipeline_outputs
finally:
# If iteration is abandoned early (e.g. the consumer stops iterating the generator and closes it) or
# the run is cancelled, cancel any tasks still in flight so they don't leak.
# This is a no-op on normal completion and on a component error, since no tasks are left running by then
await self._cancel_in_flight_tasks(running_tasks, scheduled_components)
async def run_async(
self, data: dict[str, Any], include_outputs_from: set[str] | None = None, concurrency_limit: int = 4
) -> dict[str, Any]:
"""
Provides an asynchronous interface to run the pipeline with provided input data.
This method allows the pipeline to be integrated into an asynchronous workflow, enabling non-blocking
execution of pipeline components.
Usage:
```python
import asyncio
from haystack import Document
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack import Pipeline
from haystack.dataclasses import ChatMessage
from haystack.document_stores.in_memory import InMemoryDocumentStore
# Write documents to InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="My name is Jean and I live in Paris."),
Document(content="My name is Mark and I live in Berlin."),
Document(content="My name is Giorgio and I live in Rome.")
])
prompt_template = [
ChatMessage.from_user(
'''
Given these documents, answer the question.
Documents:
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{question}}
Answer:
''')
]
retriever = InMemoryBM25Retriever(document_store=document_store)
prompt_builder = ChatPromptBuilder(template=prompt_template)
llm = OpenAIChatGenerator()
rag_pipeline = Pipeline()
rag_pipeline.add_component("retriever", retriever)
rag_pipeline.add_component("prompt_builder", prompt_builder)
rag_pipeline.add_component("llm", llm)
rag_pipeline.connect("retriever", "prompt_builder.documents")
rag_pipeline.connect("prompt_builder", "llm")
# Ask a question
question = "Who lives in Paris?"
async def run_inner(data, include_outputs_from):
return await rag_pipeline.run_async(data=data, include_outputs_from=include_outputs_from)
data = {
"retriever": {"query": question},
"prompt_builder": {"question": question},
}
results = asyncio.run(run_inner(data, include_outputs_from={"retriever", "llm"}))
print(results["llm"]["replies"])
# [ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text='Jean lives in Paris.')],
# _name=None, _meta={'model': 'gpt-5-mini', 'index': 0, 'finish_reason': 'stop', 'usage':
# {'completion_tokens': 6, 'prompt_tokens': 69, 'total_tokens': 75,
# 'completion_tokens_details': CompletionTokensDetails(accepted_prediction_tokens=0,
# audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0), 'prompt_tokens_details':
# PromptTokensDetails(audio_tokens=0, cached_tokens=0)}})]
```
:param data:
A dictionary of inputs for the pipeline's components. Each key is a component name
and its value is a dictionary of that component's input parameters:
```
data = {
"comp1": {"input1": 1, "input2": 2},
}
```
For convenience, this format is also supported when input names are unique:
```
data = {
"input1": 1, "input2": 2,
}
```
:param include_outputs_from:
Set of component names whose individual outputs are to be
included in the pipeline's output. For components that are
invoked multiple times (in a loop), only the last-produced
output is included.
:param concurrency_limit: The maximum number of components that should be allowed to run concurrently.
:returns:
A dictionary where each entry corresponds to a component name
and its output. If `include_outputs_from` is `None`, this dictionary
will only contain the outputs of leaf components, i.e., components
without outgoing connections.
:raises ValueError:
If invalid inputs are provided to the pipeline, or if `concurrency_limit` is less than 1.
:raises PipelineRuntimeError:
If the Pipeline contains cycles with unsupported connections that would cause
it to get stuck and fail running.
Or if a Component fails or returns output in an unsupported type.
:raises PipelineMaxComponentRuns:
If a Component reaches the maximum number of times it can be run in this Pipeline.
"""
final: dict[str, Any] = {}
async for partial in self.run_async_generator(
data=data, concurrency_limit=concurrency_limit, include_outputs_from=include_outputs_from
):
final = partial
return final or {}
def stream(
self,
data: dict[str, Any],
*,
streaming_components: list[str] | None = None,
include_outputs_from: set[str] | None = None,
concurrency_limit: int = 4,
cancel_on_abandon: bool = True,
) -> PipelineStreamHandle:
"""
Run the pipeline and return a handle that streams `StreamingChunk`s as they arrive.
Iterate the handle with `async for` to consume chunks; after iteration ends, `handle.result` holds the final
pipeline output dict (same as `run_async`). By default, if iteration is abandoned, the underlying pipeline task
is cancelled automatically. Pass `cancel_on_abandon=False` to instead let the pipeline run to completion.
For every async-capable component that exposes a `streaming_callback` input socket, a forwarder is injected at
runtime that pushes chunks onto the handle's queue. If a `streaming_callback` is provided at component init or
at runtime (inside `data`, e.g. `data={"llm": {"streaming_callback": cb}}`), it is also invoked for each chunk.
Async callbacks are preferred; a sync callback is accepted but will run synchronously on the event loop and
may block it.
Usage:
```python
import asyncio
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack import Pipeline
from haystack.dataclasses import ChatMessage
pipe = Pipeline()
pipe.add_component(
"prompt_builder",
ChatPromptBuilder(template=[ChatMessage.from_user("Tell me about {{topic}}")]),
)
pipe.add_component("llm", OpenAIChatGenerator())
pipe.connect("prompt_builder.prompt", "llm.messages")
async def main():
handle = pipe.stream(data={"prompt_builder": {"topic": "Italy"}})
async for chunk in handle:
print(chunk.content, end="", flush=True)
return handle.result
result = asyncio.run(main())
print(result["llm"]["replies"])
```
:param data:
A dictionary of inputs for the pipeline's components. Each key is a component name
and its value is a dictionary of that component's input parameters:
```
data = {
"comp1": {"input1": 1, "input2": 2},
}
```
For convenience, this format is also supported when input names are unique:
```
data = {
"input1": 1, "input2": 2,
}
```
:param streaming_components: Names of components to stream from. If `None` (default), every streaming-capable
component is forwarded. If a list, only the listed components are forwarded; unknown names or names of
components that do not support streaming raise `ValueError`.
:param include_outputs_from:
Set of component names whose individual outputs are to be
included in the pipeline's output. For components that are
invoked multiple times (in a loop), only the last-produced
output is included.
:param concurrency_limit: The maximum number of components that should be allowed to run concurrently.
:param cancel_on_abandon: If `True` (default), the underlying pipeline task is cancelled when iteration is
abandoned. If `False`, the pipeline runs to completion even when the consumer stops reading.
:returns:
A `PipelineStreamHandle` that is async-iterable over `StreamingChunk`s. After iteration ends,
`handle.result` holds the final pipeline output dict (same shape as `run_async`).
:raises ValueError:
If `streaming_components` contains unknown component names or components that do not support streaming,
or if invalid inputs are provided to the pipeline, or if `concurrency_limit` is less than 1.
:raises PipelineRuntimeError:
Surfaced during iteration. If the Pipeline contains cycles with unsupported connections that would cause
it to get stuck and fail running, or if a Component fails or returns output in an unsupported type.
:raises PipelineMaxComponentRuns:
Surfaced during iteration. If a Component reaches the maximum number of times it can be run in this
Pipeline.
"""
streaming_capable = {
name
for name in self.graph.nodes
if getattr(self.graph.nodes[name]["instance"], "__haystack_supports_async__", False)
and "streaming_callback" in self.graph.nodes[name]["instance"].__haystack_input__
}
if streaming_components is not None:
requested = set(streaming_components)
unknown = requested - set(self.graph.nodes)
non_streaming = requested - unknown - streaming_capable
if unknown:
raise ValueError(f"Unknown components in streaming_components: {sorted(unknown)}")
if non_streaming:
raise ValueError(f"These components do not support streaming: {sorted(non_streaming)}")
queue: asyncio.Queue[StreamingChunk | _EndOfStream] = asyncio.Queue()
def make_forwarder(user_callback: StreamingCallbackT | None) -> AsyncStreamingCallbackT:
async def forwarder(chunk: StreamingChunk) -> None:
await queue.put(chunk)
if user_callback is not None:
await _invoke_streaming_callback(user_callback, chunk)
return forwarder
new_data: dict[str, Any] = self._prepare_component_input_data(data)
for name in streaming_capable:
if streaming_components is not None and name not in streaming_components:
continue
instance = self.graph.nodes[name]["instance"]
comp_inputs = new_data.setdefault(name, {})
user_callback = select_streaming_callback(
init_callback=getattr(instance, "streaming_callback", None),
runtime_callback=comp_inputs.get("streaming_callback"),
requires_async=True,
)
comp_inputs["streaming_callback"] = make_forwarder(user_callback)
async def runner() -> dict[str, Any]:
try:
return await self.run_async(
new_data, include_outputs_from=include_outputs_from, concurrency_limit=concurrency_limit
)
finally:
await queue.put(PipelineStreamHandle._END_OF_STREAM)
task = asyncio.create_task(runner())
return PipelineStreamHandle(queue=queue, task=task, cancel_on_abandon=cancel_on_abandon)