Skip to content

fix(streams): prevent unbounded queue memory spikes and task leaks in streams.concat - #167

Open
Rajeev91691 wants to merge 2 commits into
google-gemini:mainfrom
Rajeev91691:fix/streams-concat-memory-spike
Open

fix(streams): prevent unbounded queue memory spikes and task leaks in streams.concat#167
Rajeev91691 wants to merge 2 commits into
google-gemini:mainfrom
Rajeev91691:fix/streams-concat-memory-spike

Conversation

@Rajeev91691

Copy link
Copy Markdown

This PR resolves #164.

Description

In genai_processors/streams.py, streams.concat(*contents) is used to concatenate multiple asynchronous streams. Previously, it initialized internal asyncio.Queue() instances with unbounded size (maxsize=0) and spawned concurrent background tasks calling put_nowait(c). This caused all subsequent streams to buffer entirely in RAM while the consumer was still reading the first stream, leading to memory spikes and potential OOM issues.

Furthermore:

  1. If the consumer stopped iterating early (due to a break or exception), the background tasks remained running, resulting in task/resource leaks.
  2. If an enqueue task was cancelled while blocked on a queue put, the finally: await queue.put(None) block blocked indefinitely on a full queue, causing the task to hang.

This PR:

  1. Enforces bounded buffer capacities (queue_maxsize: int = 1 by default) in streams.concat to provide backpressure.
  2. Wraps the queue consumption loop in a try...finally block to cancel and clean up background tasks if iteration finishes early or errors.
  3. Fixes enqueue to prevent hanging on cancellation by avoiding blocking queue operations in the CancelledError path.
  4. Adds a unit test test_concat_backpressure_and_cancellation to genai_processors/tests/streams_test.py to verify this behavior.

@google-cla

google-cla Bot commented Jul 30, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces backpressure support to the concat stream processor by adding a queue_maxsize parameter and improves task cancellation handling. The review feedback highlights a critical bug where concat could hang indefinitely if a background task raises an exception, suggesting the use of a task group manager (context.context()) to safely manage background tasks. Additionally, the reviewer points out a redundant try...except block in enqueue and an unnecessary sleep in the test that could lead to flakiness.

Comment thread genai_processors/streams.py Outdated
Comment on lines +81 to +123
async def concat(
*contents: AsyncIterable[_T],
queue_maxsize: int = 1,
) -> AsyncIterable[_T]:
"""Concatenate multiple streams into one.

The streams are looped over concurrently before being assembled into a single
output stream.

Args:
*contents: each stream to concat as a separate argument.
queue_maxsize: The maximum number of items to buffer in an internal queue
for each input stream. Set to 0 to use an unbounded queue.

Yields:
The concatenation of all streams.
"""
output_queues = [asyncio.Queue() for _ in contents]
output_queues = [asyncio.Queue(maxsize=queue_maxsize) for _ in contents]

async def _stream_outputs(
idx: int,
):
async for c in contents[idx]:
output_queues[idx].put_nowait(c)
# Adds None to indicate end of output.
output_queues[idx].put_nowait(None)
async def _stream_outputs(idx: int):
try:
async for c in contents[idx]:
await output_queues[idx].put(c)
await output_queues[idx].put(None)
except asyncio.CancelledError:
raise

tasks = []
for idx, _ in enumerate(contents):
tasks.append(context.create_task(_stream_outputs(idx)))
try:
for idx, _ in enumerate(contents):
tasks.append(context.create_task(_stream_outputs(idx)))

for q in output_queues:
while (part := await q.get()) is not None:
for q in output_queues:
while (part := await q.get()) is not None:
q.task_done()
yield part
q.task_done()
yield part
finally:
for t in tasks:
if not t.done():
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Bug: Potential Hang on Exception in Background Tasks

If one of the background _stream_outputs tasks raises an exception (for example, during iteration of contents[idx]), it will terminate early without putting None into its corresponding queue.

Because concat is currently manually managing tasks and awaiting q.get() sequentially, the main loop will block indefinitely on await q.get() for that queue. Since the background task is already dead and will never put None or any other item, the generator hangs forever, and the finally block is never reached.

Solution

Instead of manually managing tasks, cancelling them, and handling exceptions, we should leverage the custom context.context() task group manager. This ensures that if any background task fails, the task group automatically cancels all other tasks and the main task, propagating the exception correctly and preventing any hangs.

async def concat(
    *contents: AsyncIterable[_T],
    queue_maxsize: int = 1,
) -> AsyncIterable[_T]:
  """Concatenate multiple streams into one.

  The streams are looped over concurrently before being assembled into a single
  output stream.

  Args:
    *contents: each stream to concat as a separate argument.
    queue_maxsize: The maximum number of items to buffer in an internal queue
      for each input stream. Set to 0 to use an unbounded queue.

  Yields:
    The concatenation of all streams.
  """
  output_queues = [asyncio.Queue(maxsize=queue_maxsize) for _ in contents]

  async def _stream_outputs(idx: int):
    async for c in contents[idx]:
      await output_queues[idx].put(c)
    await output_queues[idx].put(None)

  async with context.context() as tg:
    for idx in range(len(contents)):
      tg.create_task(_stream_outputs(idx))

    for q in output_queues:
      while (part := await q.get()) is not None:
        q.task_done()
        yield part
      q.task_done()

Comment thread genai_processors/streams.py Outdated
Comment on lines +225 to +230
try:
async for part in content:
await queue.put(part)
finally:
await queue.put(None)
except asyncio.CancelledError:
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redundant try...except Block

The try...except asyncio.CancelledError: raise block is completely redundant here. In Python, if a CancelledError (or any other exception) is raised during async for part in content or await queue.put(part), it will naturally propagate out of the function immediately.

Since there is no finally block or broad except Exception block that we need to bypass, wrapping the code in this try...except block has no functional effect and can be safely removed to simplify the code.

  async for part in content:
    await queue.put(part)
  await queue.put(None)

Comment thread genai_processors/tests/streams_test.py Outdated
Comment on lines +184 to +185
await asyncio.sleep(0.05)
self.assertTrue(task_cancelled.is_set())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Unnecessary Sleep and Potential Flakiness

Because concat is an async generator, when we break from the async for loop, Python automatically calls aclose() on the generator. The generator's cleanup (either via the finally block or the async with block) awaits the cancellation and completion of all background tasks before aclose() completes.

Therefore, by the time the async for loop exits, the background producer task is guaranteed to have been fully cancelled and completed. We can assert task_cancelled.is_set() immediately without any arbitrary sleep, which avoids potential flakiness in busy CI environments.

Suggested change
await asyncio.sleep(0.05)
self.assertTrue(task_cancelled.is_set())
self.assertTrue(task_cancelled.is_set())

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Prevent unbounded queue memory spikes and task leaks in streams.concat

1 participant