Skip to content

Commit b475909

Browse files
committed
fix: avoid StackOverflowError in PersistBarrier.awaitPersisted for large steps
awaitPersisted folded a step's per-event barriers into a left-nested Completable.andThen(...) chain. A single step can carry many events (an agent transfer folds the sub-agent's events into the parent step), and the nested chain recurses once per element on both subscription and completion, throwing StackOverflowError for long sessions. Await the barriers with Completable.concat instead, which drains its sources iteratively and stays stack-safe while preserving the same await semantics (order is irrelevant, as each subject retains its terminal state). Adds a regression test that awaits 50k events in a single step.
1 parent 6a1ce6e commit b475909

2 files changed

Lines changed: 34 additions & 3 deletions

File tree

core/src/main/java/com/google/adk/flows/llmflows/PersistBarrier.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.common.annotations.VisibleForTesting;
2222
import io.reactivex.rxjava3.core.Completable;
2323
import io.reactivex.rxjava3.subjects.CompletableSubject;
24+
import java.util.ArrayList;
2425
import java.util.List;
2526
import java.util.Map;
2627
import java.util.concurrent.ConcurrentHashMap;
@@ -72,14 +73,21 @@ public static Completable awaitPersisted(InvocationContext context, List<Event>
7273
if (enabled == null || !enabled) {
7374
return Completable.complete();
7475
}
75-
Completable result = Completable.complete();
76+
// Await the per-event barriers via Completable.concat instead of folding them into a
77+
// left-nested chain of Completable.andThen(...). A single step's event list can be large (an
78+
// agent transfer folds the sub-agent's events into the parent step), and a nested andThen chain
79+
// recurses once per element on both subscription and completion, overflowing the stack for
80+
// long sessions. Completable.concat drains its sources iteratively, so it stays stack-safe
81+
// while keeping the same await semantics (order is irrelevant, as each subject retains its
82+
// terminal state).
83+
List<Completable> barriers = new ArrayList<>(events.size());
7684
for (Event event : events) {
7785
String eventId = event.id();
7886
if (eventId != null) {
79-
result = result.andThen(barrier(context, eventId));
87+
barriers.add(barrier(context, eventId));
8088
}
8189
}
82-
return result;
90+
return Completable.concat(barriers);
8391
}
8492

8593
/** Signals that the {@code Runner} persisted the event with the given id. */

core/src/test/java/com/google/adk/flows/llmflows/PersistBarrierTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ public void multiEventStep_completesOnlyAfterAllMarked() {
112112
assertThat(PersistBarrier.pendingCount(context)).isEqualTo(0);
113113
}
114114

115+
@Test
116+
public void largeStep_awaitsAllWithoutStackOverflow() {
117+
// A single step's event list can be large (e.g. an agent transfer folds the sub-agent's events
118+
// into the parent step). Awaiting them must not build a deeply nested chain that overflows the
119+
// stack when the returned Completable is subscribed or completed.
120+
PersistBarrier.enable(context);
121+
int eventCount = 50_000;
122+
List<Event> events = new ArrayList<>(eventCount);
123+
for (int i = 0; i < eventCount; i++) {
124+
events.add(event("e" + i));
125+
}
126+
127+
TestObserver<Void> observer = PersistBarrier.awaitPersisted(context, events).test();
128+
observer.assertNotComplete();
129+
130+
for (Event event : events) {
131+
PersistBarrier.markPersisted(context, event.id());
132+
}
133+
134+
observer.assertComplete();
135+
assertThat(PersistBarrier.pendingCount(context)).isEqualTo(0);
136+
}
137+
115138
@Test
116139
public void markFailedBeforeAwait_awaitFails() {
117140
PersistBarrier.enable(context);

0 commit comments

Comments
 (0)