Skip to content

Commit 64a7448

Browse files
DeanChensjcopybara-github
authored andcommitted
perf: Optimize workflow rehydration performance with indexing
Introduce an event index to cache events by parent path on startup, avoiding repeated linear scans of the event log during rehydration. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 947968985
1 parent 4fdc94c commit 64a7448

4 files changed

Lines changed: 265 additions & 40 deletions

File tree

src/google/adk/workflow/_dynamic_node_scheduler.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ class DynamicNodeState:
9696
completes.
9797
"""
9898

99+
replay_manager: ReplayManager = field(default_factory=ReplayManager)
100+
"""The replay manager for this loop state, containing event indexes."""
101+
99102
def get_dynamic_tasks(self) -> list[asyncio.Task[Context]]:
100103
"""Get all active dynamic node tasks."""
101104
return [
@@ -120,7 +123,7 @@ class DynamicNodeScheduler(ScheduleDynamicNode):
120123

121124
def __init__(self, *, state: DynamicNodeState) -> None:
122125
self._state = state
123-
self._replay_manager = ReplayManager()
126+
self._replay_manager = state.replay_manager
124127

125128
async def __call__(
126129
self,
@@ -327,8 +330,11 @@ def _rehydrate_from_events(self, ctx: Context, node_path: str) -> None:
327330
logger.debug('node %s rehydrate start.', node_path)
328331
ic = ctx._invocation_context # pylint: disable=protected-access
329332

333+
filtered_events = self._replay_manager.get_events_for_rehydration(
334+
ctx, node_path
335+
)
330336
results = _reconstruct_node_states(
331-
events=ic.session.events,
337+
events=filtered_events,
332338
base_path=node_path,
333339
group_by_direct_child=False,
334340
invocation_id=ic.invocation_id,

src/google/adk/workflow/_workflow.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
from .utils._rehydration_utils import _ChildScanState
4444
from .utils._replay_interceptor import check_interception
4545
from .utils._replay_interceptor import create_mock_context
46-
from .utils._replay_manager import ReplayManager
4746
from .utils._replay_sequence_barrier import ReplaySequenceBarrier
4847

4948
if TYPE_CHECKING:
@@ -222,7 +221,7 @@ async def _run_impl(
222221
# --- SETUP: resume from events or start fresh ---
223222
# TODO: resume from checkpoint event.
224223
loop_state = _LoopState()
225-
replay_mgr = ReplayManager()
224+
replay_mgr = loop_state.replay_manager
226225
loop_state.recovered_executions, _ = replay_mgr.scan_workflow_events(ctx)
227226
loop_state.sequence_barrier = replay_mgr.sequence_barrier
228227

src/google/adk/workflow/utils/_replay_manager.py

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ...agents.context import Context
2222
from ...events._node_path_builder import _NodePathBuilder
23+
from ...events.event import Event
2324
from ._rehydration_utils import _ChildScanState
2425
from ._rehydration_utils import _reconstruct_node_states
2526
from ._rehydration_utils import is_terminal_event
@@ -35,6 +36,8 @@ def __init__(self) -> None:
3536
self._recovered_executions: dict[str, _ChildScanState] = {}
3637
self._sequence_barrier: ReplaySequenceBarrier | None = None
3738
self._parent_sequence_barriers: dict[str, ReplaySequenceBarrier] = {}
39+
self._events_by_parent: dict[str, list[Event]] = {}
40+
self._transitive_events_by_parent: dict[str, list[Event]] = {}
3841

3942
@property
4043
def recovered_executions(self) -> dict[str, _ChildScanState]:
@@ -46,18 +49,135 @@ def sequence_barrier(self) -> ReplaySequenceBarrier | None:
4649
"""Sequence barrier for deterministic replay ordering."""
4750
return self._sequence_barrier
4851

52+
def _ensure_index(self, ctx: Context) -> None:
53+
"""Ensures event indexes are initialized and up-to-date with current session.
54+
55+
In multi-turn sessions, new events are added to session history on each turn.
56+
Rebuilding the index whenever event count changes ensures rehydration
57+
always operates on the complete event stream across turns.
58+
"""
59+
ic = ctx._invocation_context
60+
events = ic.session.events
61+
if getattr(self, "_indexed_event_count", -1) != len(events):
62+
self._build_event_index(events, ic.invocation_id)
63+
self._indexed_event_count = len(events)
64+
65+
def _build_event_index(self, events: list[Event], invocation_id: str) -> None:
66+
"""Builds index of events grouped by parent path (both direct and transitive)."""
67+
self._events_by_parent = {}
68+
self._transitive_events_by_parent = {}
69+
fc_to_parent: dict[str, str] = {}
70+
71+
from ._workflow_hitl_utils import get_request_input_interrupt_ids
72+
73+
for event in events:
74+
if event.author == "user":
75+
self._index_user_event(event, fc_to_parent)
76+
continue
77+
78+
path = event.node_info.path or ""
79+
if not path:
80+
continue
81+
82+
path_builder = _NodePathBuilder.from_string(path)
83+
parent_path = str(path_builder.parent) if path_builder.parent else ""
84+
85+
self._add_event_to_index(parent_path, event)
86+
87+
# Track interrupts to route future user responses
88+
interrupt_ids = set(event.long_running_tool_ids or [])
89+
interrupt_ids.update(get_request_input_interrupt_ids(event))
90+
for fid in interrupt_ids:
91+
fc_to_parent[fid] = parent_path
92+
93+
def _index_user_event(
94+
self, event: Event, fc_to_parent: dict[str, str]
95+
) -> None:
96+
"""Routes user response events to parent path based on function call IDs."""
97+
if not event.content or not event.content.parts:
98+
return
99+
matched = False
100+
added_parents: set[str] = set()
101+
for part in event.content.parts:
102+
fr = part.function_response
103+
if fr and fr.id and fr.id in fc_to_parent:
104+
parent = fc_to_parent[fr.id]
105+
if parent not in added_parents:
106+
self._add_event_to_index(parent, event)
107+
added_parents.add(parent)
108+
matched = True
109+
110+
if not matched:
111+
# General user prompt event: add to root ("")
112+
self._events_by_parent.setdefault("", []).append(event)
113+
self._transitive_events_by_parent.setdefault("", []).append(event)
114+
115+
def get_events_for_rehydration(
116+
self, ctx: Context, node_path: str
117+
) -> list[Event]:
118+
"""Retrieves pre-filtered session events relevant to rehydrating a node path.
119+
120+
Instead of performing an O(N) linear scan over all session events, this
121+
queries pre-indexed transitive events under the node's parent path. Top-level
122+
user prompts are merged in to ensure multi-turn conversation context is visible
123+
while preserving strict session chronological ordering.
124+
"""
125+
if not node_path:
126+
return []
127+
128+
self._ensure_index(ctx)
129+
path_builder = _NodePathBuilder.from_string(node_path)
130+
parent_builder = path_builder.parent
131+
if not parent_builder or not str(parent_builder):
132+
return ctx._invocation_context.session.events
133+
parent_path = str(parent_builder)
134+
135+
node_events = self._transitive_events_by_parent.get(parent_path, [])
136+
if not node_events:
137+
return ctx._invocation_context.session.events
138+
139+
# Top-level user text prompts live under root key ("").
140+
# Merge them so multi-turn turn inputs remain visible during state reconstruction.
141+
root_events = self._events_by_parent.get("", [])
142+
user_prompts = [
143+
e for e in root_events if e.author == "user" and e not in node_events
144+
]
145+
146+
if not user_prompts:
147+
return node_events
148+
149+
# Retain exact chronological ordering of session events.
150+
session_events = ctx._invocation_context.session.events
151+
event_ids = {id(e) for e in node_events}.union(id(e) for e in user_prompts)
152+
return [e for e in session_events if id(e) in event_ids]
153+
154+
def _add_event_to_index(self, parent_path: str, event: Event) -> None:
155+
"""Indexes an event under its direct parent and all ancestor paths up to root."""
156+
self._events_by_parent.setdefault(parent_path, []).append(event)
157+
158+
# Propagate event up through all ancestor paths so parent and grandparent nodes
159+
# can query all sub-tree events in O(1) via _transitive_events_by_parent.
160+
curr: _NodePathBuilder | None = (
161+
_NodePathBuilder.from_string(parent_path) if parent_path else None
162+
)
163+
while curr is not None and str(curr):
164+
self._transitive_events_by_parent.setdefault(str(curr), []).append(event)
165+
curr = curr.parent
166+
167+
self._transitive_events_by_parent.setdefault("", []).append(event)
168+
49169
def _scan_sequence(
50-
self, ctx: Context, base_path: str, strict_direct_child: bool = False
170+
self,
171+
events: list[Event],
172+
ctx: Context,
173+
base_path: str,
174+
strict_direct_child: bool = False,
51175
) -> list[str]:
52176
"""Extract chronological child completion sequence under base_path."""
53-
ic = ctx._invocation_context
54177
base_path_builder = _NodePathBuilder.from_string(base_path)
55178
sequence: list[str] = []
56179

57-
for event in ic.session.events:
58-
if event.invocation_id != ic.invocation_id:
59-
continue
60-
180+
for event in events:
61181
event_node_path = event.node_info.path or ""
62182
event_path_builder = _NodePathBuilder.from_string(event_node_path)
63183

@@ -82,15 +202,23 @@ def scan_workflow_events(
82202
) -> tuple[dict[str, _ChildScanState], list[str]]:
83203
"""Scan session events for direct child workflow nodes and initialize sequence barrier."""
84204
ic = ctx._invocation_context
205+
206+
# Build the index
207+
self._build_event_index(ic.session.events, ic.invocation_id)
208+
209+
# Use transitive parent events for static child nodes so deeper descendant events (e.g. delegated outputs/interrupts) are recovered
210+
filtered_events = self._transitive_events_by_parent.get(ctx.node_path, [])
85211
raw_results = _reconstruct_node_states(
86-
events=ic.session.events,
212+
events=filtered_events,
87213
base_path=ctx.node_path,
88214
group_by_direct_child=True,
89215
invocation_id=ic.invocation_id,
90216
)
91217

218+
# Use transitive events for sequence (strict_direct_child = False)
219+
transitive_events = self._transitive_events_by_parent.get(ctx.node_path, [])
92220
sequence = self._scan_sequence(
93-
ctx, ctx.node_path, strict_direct_child=False
221+
transitive_events, ctx, ctx.node_path, strict_direct_child=False
94222
)
95223

96224
self._recovered_executions = raw_results
@@ -102,7 +230,14 @@ def prepare_parent_sequence_barrier(
102230
) -> ReplaySequenceBarrier:
103231
"""Ensure a sequence barrier is set up for dynamic nodes under parent_path."""
104232
if parent_path not in self._parent_sequence_barriers:
105-
seq = self._scan_sequence(ctx, parent_path, strict_direct_child=True)
233+
self._ensure_index(ctx)
234+
235+
# Dynamic parent path uses strict_direct_child=True.
236+
# So we use the direct parent index.
237+
events = self._events_by_parent.get(parent_path, [])
238+
seq = self._scan_sequence(
239+
events, ctx, parent_path, strict_direct_child=True
240+
)
106241
self._parent_sequence_barriers[parent_path] = ReplaySequenceBarrier(seq)
107242
return self._parent_sequence_barriers[parent_path]
108243

0 commit comments

Comments
 (0)