Skip to content

Commit 199d954

Browse files
DeanChensjcopybara-github
authored andcommitted
feat: expose max_parallel_workers parameter on workflow.node decorator and Node class
Expose `max_parallel_workers` on the `@workflow.node` decorator and `Node` class when `parallel_worker=True`. This allows throttling the concurrency limit when executing parallel workers. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 940663519
1 parent 38d715c commit 199d954

6 files changed

Lines changed: 169 additions & 36 deletions

File tree

docs/guides/workflow/parallel_worker/index.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
When processing a list of items (e.g., a list of documents to analyze, queries to run, or topics to explain), running them sequentially can be slow, especially if the processing node performs I/O-bound operations like calling an LLM or an external API. `ParallelWorker` solves this by executing the wrapped node concurrently for all items in the input list, significantly reducing total execution time.
88

99
Key features:
10-
- **Concurrency**: Runs multiple instances of the wrapped node in parallel.
10+
- **Concurrency**: Runs multiple instances of the wrapped node in parallel (optionally throttled via `max_parallel_workers`).
1111
- **Aggregation**: Gathers all outputs and returns them as a single list, maintaining the original order of the inputs.
1212
- **Error Propagation**: If any parallel task fails, all other pending tasks are cancelled, and the error is raised immediately.
1313

@@ -64,7 +64,6 @@ If the wrapped node triggers an interrupt (e.g., `RequestInput`), the parallel w
6464

6565
- **List Input**: The worker always expects a list and returns a list. If your upstream node doesn't produce a list, it will be treated as a list of one item.
6666
- **Fail-Fast**: The failure of a single item fails the entire worker and cancels all other items. There is currently no "continue on error" option to collect partial results.
67-
- **No Concurrency Limit**: Setting `max_concurrency` to limit the number of parallel tasks is not supported yet. All items in the list will be processed concurrently.
6867

6968
## Related samples
7069

src/google/adk/cli/agent_test_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def _make_nodes_sequential(obj, visited=None):
257257
for node in obj.graph.nodes:
258258
_make_nodes_sequential(node, visited)
259259
elif isinstance(obj, _ParallelWorker):
260-
obj.max_concurrency = 1
260+
obj.max_parallel_workers = 1
261261
if hasattr(obj, "_node"):
262262
_make_nodes_sequential(obj._node, visited)
263263

src/google/adk/workflow/_node.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from typing import TypeVar
2626

2727
from pydantic import Field
28+
from pydantic import model_validator
2829
from pydantic import PrivateAttr
2930
from typing_extensions import override
3031

@@ -50,6 +51,7 @@ def node(
5051
retry_config: RetryConfig | None = None,
5152
timeout: float | None = None,
5253
parallel_worker: bool = False,
54+
max_parallel_workers: int | None = None,
5355
auth_config: AuthConfig | None = None,
5456
parameter_binding: Literal['state', 'node_input'] = 'state',
5557
) -> Callable[
@@ -67,6 +69,7 @@ def node(
6769
retry_config: RetryConfig | None = None,
6870
timeout: float | None = None,
6971
parallel_worker: bool = False,
72+
max_parallel_workers: int | None = None,
7073
auth_config: AuthConfig | None = None,
7174
parameter_binding: Literal['state', 'node_input'] = 'state',
7275
) -> base_node.BaseNode:
@@ -81,6 +84,7 @@ def node(
8184
retry_config: RetryConfig | None = None,
8285
timeout: float | None = None,
8386
parallel_worker: bool = False,
87+
max_parallel_workers: int | None = None,
8488
auth_config: AuthConfig | None = None,
8589
parameter_binding: Literal['state', 'node_input'] = 'state',
8690
) -> Any:
@@ -123,6 +127,16 @@ async def my_func3(): ...
123127
a BaseNode instance.
124128
"""
125129

130+
if max_parallel_workers is not None:
131+
if not parallel_worker:
132+
raise ValueError(
133+
'max_parallel_workers can only be set when parallel_worker is True.'
134+
)
135+
if max_parallel_workers < 1:
136+
raise ValueError(
137+
'max_parallel_workers must be greater than or equal to 1.'
138+
)
139+
126140
def wrapper(
127141
func: T,
128142
) -> function_node.FunctionNode | parallel_worker_lib._ParallelWorker:
@@ -138,7 +152,9 @@ def wrapper(
138152
parameter_binding=parameter_binding,
139153
)
140154
if parallel_worker:
141-
return parallel_worker_lib._ParallelWorker(node=built_node)
155+
return parallel_worker_lib._ParallelWorker(
156+
node=built_node, max_parallel_workers=max_parallel_workers
157+
)
142158
return built_node
143159

144160
if node_like is None:
@@ -155,7 +171,9 @@ def wrapper(
155171
parameter_binding=parameter_binding,
156172
)
157173
if parallel_worker:
158-
return parallel_worker_lib._ParallelWorker(node=built_node)
174+
return parallel_worker_lib._ParallelWorker(
175+
node=built_node, max_parallel_workers=max_parallel_workers
176+
)
159177
return built_node
160178

161179

@@ -167,8 +185,22 @@ class Node(base_node.BaseNode):
167185
"""
168186

169187
parallel_worker: bool = Field(default=False, frozen=True)
188+
max_parallel_workers: int | None = Field(default=None, frozen=True)
170189
_inner_node: base_node.BaseNode | None = PrivateAttr(default=None)
171190

191+
@model_validator(mode='after')
192+
def _validate_parallel_worker_config(self) -> Node:
193+
if self.max_parallel_workers is not None:
194+
if not self.parallel_worker:
195+
raise ValueError(
196+
'max_parallel_workers can only be set when parallel_worker is True.'
197+
)
198+
if self.max_parallel_workers < 1:
199+
raise ValueError(
200+
'max_parallel_workers must be greater than or equal to 1.'
201+
)
202+
return self
203+
172204
def model_post_init(self, __context: Any) -> None:
173205
super().model_post_init(__context)
174206
if self.parallel_worker:
@@ -179,7 +211,9 @@ def model_post_init(self, __context: Any) -> None:
179211
# original (essential for LlmAgent and Workflow subclasses).
180212
worker_node = self.model_copy(update={'parallel_worker': False})
181213

182-
inner = parallel_worker_lib._ParallelWorker(node=worker_node)
214+
inner = parallel_worker_lib._ParallelWorker(
215+
node=worker_node, max_parallel_workers=self.max_parallel_workers
216+
)
183217
self._inner_node = inner
184218
# Synchronize rerun_on_resume with the inner node.
185219
self.rerun_on_resume = inner.rerun_on_resume
@@ -193,7 +227,9 @@ def model_copy(
193227

194228
if copied.parallel_worker:
195229
worker_node = copied.model_copy(update={'parallel_worker': False})
196-
copied._inner_node = parallel_worker_lib._ParallelWorker(node=worker_node)
230+
copied._inner_node = parallel_worker_lib._ParallelWorker(
231+
node=worker_node, max_parallel_workers=copied.max_parallel_workers
232+
)
197233
copied.rerun_on_resume = copied._inner_node.rerun_on_resume
198234

199235
return copied

src/google/adk/workflow/_parallel_worker.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,21 @@ class _ParallelWorker(BaseNode):
3636
"""A node that runs a wrapped node in parallel for each item in the input list.
3737
3838
Attributes:
39-
max_concurrency: The maximum number of parallel tasks to run. If None, there
40-
is no limit on concurrency.
39+
max_parallel_workers: The maximum number of parallel tasks to run. If None,
40+
there is no limit on concurrency.
4141
"""
4242

4343
model_config = ConfigDict(arbitrary_types_allowed=True)
4444

45-
max_concurrency: int | None = Field(default=None)
45+
max_parallel_workers: int | None = Field(default=None)
4646

4747
_node: BaseNode = PrivateAttr()
4848

4949
def __init__(
5050
self,
5151
*,
5252
node: NodeLike,
53-
max_concurrency: int | None = None,
53+
max_parallel_workers: int | None = None,
5454
retry_config: RetryConfig | None = None,
5555
timeout: float | None = None,
5656
):
@@ -63,8 +63,12 @@ def __init__(
6363
retry_config=retry_config,
6464
timeout=timeout,
6565
)
66+
if max_parallel_workers is not None and max_parallel_workers < 1:
67+
raise ValueError(
68+
'max_parallel_workers must be greater than or equal to 1.'
69+
)
6670
self._node = built_node
67-
self.max_concurrency = max_concurrency
71+
self.max_parallel_workers = max_parallel_workers
6872

6973
@override
7074
async def _run_impl(
@@ -89,8 +93,8 @@ async def _run_impl(
8993
while input_index < len(node_input) or pending_tasks:
9094
# Check for any inputs waiting to be processed.
9195
while input_index < len(node_input) and (
92-
self.max_concurrency is None
93-
or len(pending_tasks) < self.max_concurrency
96+
self.max_parallel_workers is None
97+
or len(pending_tasks) < self.max_parallel_workers
9498
):
9599
item = node_input[input_index]
96100
task = asyncio.create_task(

tests/unittests/workflow/test_workflow_node.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,97 @@ async def producer():
354354
"subclass",
355355
["subclass: workflow -> input1", "subclass: workflow -> input2"],
356356
) in by_node
357+
358+
359+
def test_node_decorator_parallel_worker_max_parallel_workers():
360+
"""Tests that node() correctly sets max_parallel_workers on ParallelWorker."""
361+
362+
@node(parallel_worker=True, max_parallel_workers=3)
363+
def my_func(node_input):
364+
return node_input
365+
366+
assert isinstance(my_func, ParallelWorker)
367+
assert my_func.max_parallel_workers == 3
368+
369+
370+
def test_node_decorator_invalid_max_parallel_workers():
371+
"""Tests that node() raises ValueError if max_parallel_workers is set without parallel_worker."""
372+
with pytest.raises(
373+
ValueError,
374+
match="max_parallel_workers can only be set when parallel_worker is True",
375+
):
376+
377+
@node(parallel_worker=False, max_parallel_workers=3)
378+
def my_func(node_input):
379+
return node_input
380+
381+
382+
def test_node_subclass_invalid_max_parallel_workers():
383+
"""Tests that Node subclass raises ValidationError if max_parallel_workers is set without parallel_worker."""
384+
from pydantic import ValidationError
385+
386+
with pytest.raises(ValidationError) as exc_info:
387+
_CustomNode(name="subclass", parallel_worker=False, max_parallel_workers=3)
388+
389+
assert (
390+
"max_parallel_workers can only be set when parallel_worker is True"
391+
in str(exc_info.value)
392+
)
393+
394+
395+
def test_node_subclassing_model_copy_preserves_max_parallel_workers():
396+
"""Tests that Node.model_copy preserves max_parallel_workers."""
397+
node_inst = _CustomNode(
398+
name="subclass",
399+
parallel_worker=True,
400+
max_parallel_workers=5,
401+
custom_val="barrier",
402+
)
403+
assert node_inst.parallel_worker is True
404+
assert node_inst.max_parallel_workers == 5
405+
assert node_inst._inner_node.max_parallel_workers == 5
406+
407+
cloned = node_inst.model_copy()
408+
assert isinstance(cloned, _CustomNode)
409+
assert cloned.parallel_worker is True
410+
assert cloned.max_parallel_workers == 5
411+
assert isinstance(cloned._inner_node, ParallelWorker)
412+
assert cloned._inner_node.max_parallel_workers == 5
413+
assert cloned._inner_node._node.parallel_worker is False
414+
415+
416+
def test_node_decorator_invalid_max_parallel_workers_less_than_one():
417+
"""Tests that node() raises ValueError if max_parallel_workers is less than 1."""
418+
with pytest.raises(
419+
ValueError,
420+
match="max_parallel_workers must be greater than or equal to 1",
421+
):
422+
423+
@node(parallel_worker=True, max_parallel_workers=0)
424+
def my_func(node_input):
425+
return node_input
426+
427+
428+
def test_node_subclass_invalid_max_parallel_workers_less_than_one():
429+
"""Tests that Node subclass raises ValidationError if max_parallel_workers is less than 1."""
430+
from pydantic import ValidationError
431+
432+
with pytest.raises(ValidationError) as exc_info:
433+
_CustomNode(name="subclass", parallel_worker=True, max_parallel_workers=0)
434+
435+
assert "max_parallel_workers must be greater than or equal to 1" in str(
436+
exc_info.value
437+
)
438+
439+
440+
def test_parallel_worker_invalid_max_parallel_workers_less_than_one():
441+
"""Tests that ParallelWorker constructor raises ValueError if max_parallel_workers is less than 1."""
442+
443+
def dummy_func(x):
444+
return x
445+
446+
with pytest.raises(
447+
ValueError,
448+
match="max_parallel_workers must be greater than or equal to 1",
449+
):
450+
ParallelWorker(node=dummy_func, max_parallel_workers=0)

0 commit comments

Comments
 (0)