Skip to content

asyncio.gather leaks orphaned tasks in MultiRetriever/MultiQueryTextRetriever/MultiQueryEmbeddingRetriever when one concurrent call fails #11965

Description

@ErenAta16

Bug Description

MultiRetriever.run_async, MultiQueryTextRetriever.run_async, and MultiQueryEmbeddingRetriever.run_async all fan out concurrent work with asyncio.gather(...) (no return_exceptions=True) over per-query/per-retriever coroutines that do not catch every exception internally. When one of the concurrent calls fails while others are still in flight, gather propagates the first exception immediately, but it does not cancel or await the sibling tasks. They keep running detached from the caller, and their eventual result (or exception) is silently discarded, or logged as "Task exception was never retrieved" if the event loop is still alive when they finish, or produces a "Task was destroyed but it is pending" warning if the loop shuts down first.

Affected code:

  • haystack/components/retrievers/multi_retriever.py, _run_one (re-raises as RuntimeError from except Exception, still propagates past gather):
async def _run_one(name: str, retriever: TextRetriever) -> list[Document]:
    try:
        result = await _execute_component_async(retriever, **run_kwargs)
        return result.get("documents", [])
    except Exception as e:
        raise RuntimeError(f"Retriever '{name}' failed: {e}") from e

document_lists = list(await asyncio.gather(*[_run_one(name, r) for name, r in retrievers_to_run.items()]))
  • haystack/components/retrievers/multi_query_text_retriever.py, _run_one_async (no try/except at all):
async def _run_one_async(self, query: str, retriever_kwargs: dict[str, Any]) -> list[Document] | None:
    result = await _execute_component_async(self.retriever, query=query, **retriever_kwargs)
    ...

results = await asyncio.gather(*[self._run_one_async(q, retriever_kwargs) for q in queries])
  • haystack/components/retrievers/multi_query_embedding_retriever.py, same pattern as above.

Steps to Reproduce

Minimal reproduction of the leak, independent of Haystack, showing the underlying asyncio.gather semantics these components rely on:

import asyncio

async def _run_one(name, should_fail, delay):
    try:
        if should_fail:
            await asyncio.sleep(0.01)
            raise ValueError(f"{name} broke")
        await asyncio.sleep(delay)
        return [f"doc_from_{name}"]
    except Exception as e:
        raise RuntimeError(f"Retriever '{name}' failed: {e}") from e

async def main():
    before = asyncio.all_tasks()
    try:
        await asyncio.gather(
            _run_one("fast_failing", True, 0),
            _run_one("slow_retriever", False, 2.0),
        )
    except RuntimeError as e:
        print("gather raised as expected:", e)
    leaked = asyncio.all_tasks() - before
    print("orphaned tasks immediately after gather raises:", len(leaked))

asyncio.run(main())

Output:

gather raised as expected: Retriever 'fast_failing' failed: fast_failing broke
orphaned tasks immediately after gather raises: 1

A second run simulating a pipeline step that calls the component, catches the exception, and returns to its own caller confirms the caller regains control while the slow retriever's task is still pending, running fully detached:

pipeline.run_async: caught retriever failure -> fast_failing_retriever broke
caller received exception, pipeline call has now returned: fast_failing_retriever broke
tasks still pending right after the pipeline call raised: 1

Expected Behavior

When one query/retriever fails, the other in-flight tasks in the same batch should be cancelled (or gathered with return_exceptions=True and drained) before the exception propagates, so no task from that batch is left running unsupervised past the point where the caller has already moved on. This matters most for retrievers backed by real I/O (HTTP calls to a vector DB or search API, DB cursors), where the leaked task holds those resources open for however long it takes to finish or get torn down by the loop.

System

deepset-ai/haystack main branch (current HEAD as of this report). Affects any usage of MultiRetriever.run_async, MultiQueryTextRetriever.run_async, or MultiQueryEmbeddingRetriever.run_async where at least one concurrent call can fail independently of the others, which is the normal case for retrievers hitting separate backends.

Metadata

Metadata

Assignees

Labels

P2Medium priority, add to the next sprint if no P1 available

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions