aiml
SearchMy learning
Menu

Lesson 16 of 17Module 3

Parallel fan-out and gather

Run ADK 2.0 workflow branches in parallel and merge them with a JoinNode, then see one failed branch fail the graph and a dynamic parent keep the rest.

Prerequisites

An ADK graph Workflow and a dynamic workflow with ctx.run_node, as in Module 2 of this course.

By the end, you can

  • Fan out to parallel branches with graph edges and gather their outputs with a JoinNode.
  • Predict what a failed branch does to a JoinNode graph, and keep partial results with ctx.run_node and asyncio.gather.
  • Limit concurrent branches with Workflow.max_concurrency and test the limit with a stub model.
Runtime
Python 3.12+; Gemini API key for optional live runs
Dependencies
google-adk 2.12.0 with google-genai 2.32.0

Lab code, tests and fixtures (ZIP, 26 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A release page needs highlights, upgrade steps and a link report. Each task reads the same changelog, and none needs the others’ output. Running them as independent branches lets their waits overlap.

You will build two versions of that docuctl release workflow. A graph fans out to two agents and a link-check function, then gathers their outputs. A dynamic parent adds an explicit policy for an optional link-check failure. The key decision is whether a missing branch should prevent the whole page from being returned.

release_prep.py · build_graph · three rows from load_release · JoinNode gather
data flowfailurerelease fileentries · sitemaptextrelease_prep · Workflowload_releaseReleaseReleasewrite_highlightsAgent · retrywrite_upgrade_stepsAgent · retrycheck_linksfunction · sitemapoutputsgatherJoinNodedictassemblemessagerelease pageno sitemapFileNotFoundErrorrun fails · other branches cancelleddata flowfailurerelease fileentries · sitemaptextrelease_prepload_releaseReleasewrite_highlightswrite_upgrade_stepscheck_linksoutputsgatherJoinNodedictassemblemessagerelease pageno sitemapFileNotFoundErrorrun fails
Three edges from load_release start three branches at once: two model calls and a function. The JoinNode passes on one dictionary keyed by branch name, but only when every branch has an output. If check_links raises, the run fails and the two model branches are cancelled, so no release page is returned.

Get the files

You need Python 3.12+ and familiarity with graph edges and ctx.run_node. Use Download code, extract the ZIP and open adk-parallel-gather-lesson. Run:

Set up the project environmentTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Project structureSource · text
adk-parallel-gather-lesson/
  requirements.txt
  .env.example
  release-3.5.0.md
  release-3.6.0.md
  site-3.5.0/
  harness.py
  run.py
  release_prep.py
  prep-run.json
  test_release_prep.py
  test_runner.py

The comparison below needs no API key. For new replies, copy .env.example to .env and set your Gemini API key.

The release file contains the shared input, including the path of a local sitemap fixture:

release-3.5.0.mdSource · markdownDownload
## 3.5.0
- Build pages in parallel; set the worker count with `--jobs`. See /guide/parallel-builds/.
- BREAKING: `docuctl build` no longer reads `docuctl.yaml`; rename it to `docuctl.toml`. See /reference/config/.
- Fix the search index missing pages whose title contains a colon. See /reference/search/.
sitemap: site-3.5.0/sitemap.txt

site-3.5.0/sitemap.txt is included. The later release points to site-3.6.0/, which is intentionally absent so you can test the difference between a missing link and a check that could not run.

Write both workflows

release_prep.pySource · pythonDownload
"""Prepare a release page from three independent branches that run at the same time."""
import asyncio
import re
from pathlib import Path

from google.adk import Agent, Event, Workflow
from google.adk.workflow import JoinNode, RetryConfig, node
from google.genai import types
from pydantic import BaseModel

HERE = Path(__file__).parent
PAGE = re.compile(r"See (/\S*/)\.")


class Release(BaseModel):
    version: str
    entries: list[str]
    sitemap: str


class Highlights(BaseModel):
    bullets: list[str]


class UpgradeSteps(BaseModel):
    steps: list[str]


class LinkReport(BaseModel):
    missing: list[str]


def load_release(node_input: str):
    """Function node: parse the release file the run starts from."""
    lines = node_input.strip().splitlines()
    entries = [line[2:] for line in lines if line.startswith("- ")]
    sitemap = next(line[8:].strip() for line in lines if line.startswith("sitemap:"))
    return Event(output=Release(version=lines[0][3:], entries=entries, sitemap=sitemap),
                 state={"version": lines[0][3:]})


def check_links(node_input: Release):
    """Function node: every page an entry points to must be in the built site's sitemap.
    It raises if the site has not been built, because then nothing can be checked."""
    sitemap = HERE / node_input.sitemap
    if not sitemap.is_file():
        raise FileNotFoundError(f"{node_input.sitemap} not found: build the site first")
    built = set(sitemap.read_text().split())
    wanted = [page for entry in node_input.entries for page in PAGE.findall(entry)]
    return LinkReport(missing=[page for page in wanted if page not in built])


def assemble(node_input: dict, version: str):
    """Function node after the join: node_input maps each branch name to its output."""
    lines = [f"# docuctl {version} release notes", ""]
    lines += [f"- {b}" for b in node_input["write_highlights"]["bullets"]]
    lines += ["", "## Upgrade", *(f"1. {s}" for s in node_input["write_upgrade_steps"]["steps"])]
    links = node_input.get("check_links")
    if links is None:
        lines += ["", "Link check: not run (see the error above)."]
    elif links["missing"]:
        lines += ["", "Link check: missing " + ", ".join(links["missing"])]
    else:
        lines += ["", "Link check: every linked page exists."]
    return Event(message="\n".join(lines))


def make_agents(model):
    model = model or "gemini-3.8-flash"  # or LiteLlm(model="ollama_chat/gemma4:e2b")
    retry = RetryConfig(max_attempts=3, initial_delay=1.0)
    highlights = Agent(name="write_highlights", model=model,
                       generate_content_config=types.GenerateContentConfig(
                           max_output_tokens=1024,
                           thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)), input_schema=Release,
                       output_schema=Highlights,
                       instruction="Write one short user-facing bullet per entry. No marketing.")
    upgrade = Agent(name="write_upgrade_steps", model=model,
                       generate_content_config=types.GenerateContentConfig(
                           max_output_tokens=1024,
                           thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)), input_schema=Release,
                    output_schema=UpgradeSteps,
                    instruction=("Write the steps a user must take to upgrade, one per entry "
                                 "marked BREAKING. Return an empty list if there are none."))
    return node(highlights, retry_config=retry), node(upgrade, retry_config=retry)


def build_graph(model=None, max_concurrency=None) -> Workflow:
    """Graph fan-out: three rows from load_release, gathered by a JoinNode. Fail-all."""
    highlights, upgrade = make_agents(model)
    gather = JoinNode(name="gather")
    return Workflow(
        name="release_prep",
        max_concurrency=max_concurrency,
        edges=[
            ("START", load_release),
            (load_release, highlights, gather),
            (load_release, upgrade, gather),
            (load_release, check_links, gather),
            (gather, assemble),
        ],
    )


def build_partial(model=None) -> Workflow:
    """Dynamic fan-out: the same branches, but the link check may fail without
    failing the release. Required branches still stop the run."""
    highlights, upgrade = make_agents(model)

    @node(rerun_on_resume=True)
    async def prepare(ctx, node_input: Release):
        branches = {"write_highlights": highlights, "write_upgrade_steps": upgrade,
                    "check_links": check_links}
        results = await asyncio.gather(
            *(ctx.run_node(branch, node_input) for branch in branches.values()),
            return_exceptions=True)
        outputs = dict(zip(branches, results))
        error = outputs["check_links"]  # DynamicNodeFailError wraps the child's exception
        for name in ("write_highlights", "write_upgrade_steps"):
            if isinstance(outputs[name], BaseException):
                raise outputs[name]  # a required branch: no release page without it
        if isinstance(error, BaseException):
            outputs["check_links"] = None
            cause = getattr(error, "error", error)
            yield Event(message=f"{error.error_node_path} failed: {type(cause).__name__}: {cause}")
        yield Event(output={k: v.model_dump() if hasattr(v, "model_dump") else v
                            for k, v in outputs.items()})

    return Workflow(name="release_prep_partial",
                    edges=[("START", load_release, prepare, assemble)])

load_release produces a typed Release. In build_graph, three edges leave that node: write_highlights, write_upgrade_steps and check_links. ADK can schedule them together because they have the same completed predecessor.

JoinNode(name="gather") collects outputs by branch name. assemble receives one dictionary and uses the two writing outputs plus the link report. It checks the optional report with .get so the partial-results version can supply None.

Each model branch is wrapped with RetryConfig(max_attempts=3, initial_delay=1.0). Three attempts includes the first attempt. The current policy retries ordinary exceptions; a narrower exception list would be appropriate when programming or validation errors should fail immediately.

build_partial puts the same branches inside a dynamic parent. asyncio.gather(..., return_exceptions=True) waits for all children and returns errors as values. The parent raises a failed writing branch again, but converts a failed optional link check to None and a message naming the failed child.

An optional branch fails in the graph. check_links raises FileNotFoundError for the absent sitemap. The graph fails, unfinished siblings are cancelled and no page is assembled.

An optional branch fails under the dynamic parent. The writing branches finish and the output says “Link check: not run”.

A required branch keeps failing. After its retries, the dynamic parent raises the writing error and returns no page.

The model agents use LOW thinking and a 1,024-token output cap per request.

Run it

run.py compares three cases: a graph with a readable sitemap, the same graph with a missing sitemap and the dynamic parent with that missing sitemap. Each invocation has a six-call backstop for the two model branches and their retry attempts.

run.pySource · pythonDownload
"""Compare the workflow's cases; replay saved replies unless --live is explicit."""
import argparse
import asyncio
import copy
import json
from pathlib import Path

from dotenv import load_dotenv
import os
from google.adk.agents.run_config import RunConfig
from google.adk.apps import App
from google.adk.runners import InMemoryRunner
from google.genai import types
from harness import ReplayLlm, summarize

HERE = Path(__file__).resolve().parent
from release_prep import build_graph, build_partial

async def compare(*, live=False):
    record = json.loads((HERE / "prep-run.json").read_text())
    for kind, filename in (("graph", "release-3.5.0.md"), ("graph", "release-3.6.0.md"),
                           ("partial", "release-3.6.0.md")):
        saved = next(r for r in record["runs"] if (r["workflow"], r["release"]) == (kind, filename))
        model = None if live else replay(saved, record)
        if live:
            builder = build_graph if kind == "graph" else build_partial
        else:
            original = __import__("importlib").import_module("historical-shipctl-source.release_prep")
            builder = original.build_graph if kind == "graph" else original.build_partial
        root = builder(model)
        try:
            rows = await invoke(root, (HERE / ("" if live else "historical-shipctl-source") / filename).read_text(), max_llm_calls=6)
        except FileNotFoundError as error:
            print(f"\n{kind} | {filename}\n{type(error).__name__}: {error}")
        else:
            display(f"{kind} | {filename}", rows)

async def invoke(root, text, *, max_llm_calls, plugins=()):
    """Run the actual workflow and close its in-memory Runner, including on failure."""
    app = App(name="docs_assistant", root_agent=root, plugins=list(plugins))
    runner = InMemoryRunner(app=app)
    session = await runner.session_service.create_session(app_name=runner.app_name, user_id="dev")
    try:
        return [summarize(event) async for event in runner.run_async(
            user_id="dev", session_id=session.id,
            new_message=types.Content(role="user", parts=[types.Part(text=text)]),
            run_config=RunConfig(max_llm_calls=max_llm_calls))]
    finally:
        await runner.close()


def replay(saved, record):
    return ReplayLlm(model=record["environment"]["model"],
                     calls=copy.deepcopy(saved["model_calls"]))


def display(label, rows):
    print(f"\n{label}")
    for row in rows:
        if row.get("route"):
            print("route:", row["route"])
    # The parent may emit a diagnostic before the assembled page.
    # Keep both so a partial page's "see the error above" has its actual cause.
    for row in rows:
        if row.get("message"):
            print(row["message"])


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--live", action="store_true", help="request new Gemini replies instead of replaying saved replies")
    args = parser.parse_args()
    if args.live:
        load_dotenv(HERE / ".env", override=False)
        if not (os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")):
            parser.error("Set your Gemini API key in .env before using --live")
    print("Mode:", "live Gemini requests" if args.live else "offline replay of saved model replies")
    asyncio.run(compare(live=args.live))


if __name__ == "__main__":
    main()
Compare joined output, a failed graph and partial resultsTerminal · bash
python run.py

The default replays saved replies through the preserved shipctl workflows in historical-shipctl-source/. The current docuctl workflows run with --live and have no new model recording. It makes no model request and does not replace fixtures. Expect a release page, a FileNotFoundError and a partial page that says “Link check: not run”.

A completed link check can report a missing page; that differs from failing to open the sitemap. Here are the successful fixture’s outputs before assemble formats the page:

Recorded example. The archived “Parallel fan-out and gather” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Saved outputs keyed by branch nameRecorded output · jsonDownload
{
  "write_highlights": {
    "bullets": [
      "Added support for parallel page builds using the `--jobs` flag.",
      "Required renaming `shipctl.yaml` to `shipctl.toml` for `shipctl build`.",
      "Fixed an issue where pages with a colon in their title were excluded from the search index."
    ]
  },
  "write_upgrade_steps": {
    "steps": [
      "Rename `shipctl.yaml` to `shipctl.toml`."
    ]
  },
  "check_links": {
    "missing": [
      "/reference/search/"
    ]
  }
}

To request new replies, copy .env.example to .env, set your Gemini API key and run python run.py --live. Generated wording can differ; the required-versus-optional failure policy is still code.

Test failures and concurrency

Check both failure policies offlineTerminal · bash
python -W ignore -m unittest -v test_release_prep.py test_runner.py

Replay tests exercise the saved outcomes. Controlled models wait, fail or count in-flight requests so the suite can test cancellation, retries and concurrency without a provider request.

Too many branches at once. Workflow(max_concurrency=1) schedules one node at a time. The offline suite checks that cap against the uncapped graph. Choose this limit for the dependencies your application can support; parallelism does not remove rate limits.

Gather typed results from parallel agents

The join defines a data boundary. Before it, branches work independently; after it, assemble can use their combined outputs. Branches should return data rather than mutate a shared result object.

A plain join graph requires its predecessors to finish successfully. If a predecessor raises, the workflow stops scheduling and cancels work still in flight as it exits. Cancellation does not undo effects a branch already performed.

The dynamic parent makes a different policy explicit. A failed ctx.run_node produces a DynamicNodeFailError, with the underlying exception in error and the child path in error_node_path. With return_exceptions=True, the parent can inspect that value after the other branches finish. It must still distinguish optional failures from required ones.

Choose what a failed branch means

Before running the comparison, predict which case can return a page when its sitemap is missing. Check your answer against the dynamic parent: writing may finish, but the page must say the link check did not run.

Use the graph when every branch is required. Use the dynamic parent when partial output is useful and the caller can see exactly what is missing. Keep external actions after the join, or give them their own retry-safe policy, because a sibling failure cannot roll them back.

Continue with Hierarchical task decomposition when a model must discover the subtasks rather than receive a fixed branch list.

An optional link check fails under build_partial. What must the caller see?

Choose one answer, then check your reasoning.

Read the explanation

Keeping partial output is useful only when missing checks remain visible.

References

Written by Arjun Prabhulal.

Module 3

Workflow patterns