Skip to content

Commit a313ce4

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: share one run per Context.runNode run ID
PiperOrigin-RevId: 1005539735
1 parent 2e88a6d commit a313ce4

5 files changed

Lines changed: 416 additions & 9 deletions

File tree

‎core/src/main/java/com/google/adk/workflow/NodeExecutionState.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.common.collect.ImmutableMap;
2727
import com.google.common.collect.ImmutableSet;
2828
import com.google.errorprone.annotations.CanIgnoreReturnValue;
29+
import io.reactivex.rxjava3.subjects.SingleSubject;
2930
import java.util.Collection;
3031
import java.util.Collections;
3132
import java.util.LinkedHashMap;
@@ -61,6 +62,7 @@ public final class NodeExecutionState {
6162
private final ImmutableList<String> outputFor;
6263
private final Map<String, @Nullable Object> resumeInputs;
6364
private final ConcurrentMap<String, Integer> childRunCounts = new ConcurrentHashMap<>();
65+
private final ConcurrentMap<String, SingleSubject<Context>> childRuns = new ConcurrentHashMap<>();
6466
// The paths of the node runs started but not finished in this turn, shared under one root.
6567
private final Set<String> unfinishedNodePaths;
6668
private final Set<String> interruptIds = new CopyOnWriteArraySet<>();
@@ -224,6 +226,14 @@ String nextChildRunId(String name) {
224226
return String.valueOf(childRunCounts.merge(name, 1, Integer::sum));
225227
}
226228

229+
/**
230+
* Returns the runs of the children this node runs itself, keyed by {@code name@runId}, which
231+
* calls with the same run ID share.
232+
*/
233+
ConcurrentMap<String, SingleSubject<Context>> childRuns() {
234+
return childRuns;
235+
}
236+
227237
/** Records that this run started; it counts as canceled mid-run until it finishes. */
228238
void markStarted() {
229239
unfinishedNodePaths.add(nodePath);
@@ -255,6 +265,11 @@ synchronized ImmutableMap<String, ResumeScan.RecoveredNode> recoveredChildren(
255265
return recoveredChildren;
256266
}
257267

268+
/** Returns the parent's state if this run's output is also the parent's, or null. */
269+
@Nullable NodeExecutionState outputParent() {
270+
return outputParent;
271+
}
272+
258273
/** Returns whether an event has carried the node's own or delegated output. */
259274
boolean outputEmitted() {
260275
OutputStatus status = outputState.get().status();

‎core/src/main/java/com/google/adk/workflow/NodeRunner.java‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@
4040
import io.reactivex.rxjava3.processors.FlowableProcessor;
4141
import io.reactivex.rxjava3.processors.UnicastProcessor;
4242
import io.reactivex.rxjava3.subjects.CompletableSubject;
43+
import io.reactivex.rxjava3.subjects.SingleSubject;
4344
import java.util.Map;
4445
import java.util.Optional;
4546
import java.util.Queue;
4647
import java.util.concurrent.ConcurrentLinkedQueue;
48+
import java.util.concurrent.ConcurrentMap;
4749
import java.util.function.Function;
4850
import org.jspecify.annotations.Nullable;
4951

@@ -218,8 +220,7 @@ private static Maybe<Object> startChild(
218220
parentState.claimOutputDelegation();
219221
}
220222
String runId = config.runId().orElseGet(() -> parentState.nextChildRunId(node.name()));
221-
// Deferred so that a throw from scan or intercept releases the delegation, as a failure does.
222-
return Single.defer(() -> runOrReplayChild(parent, node, nodeInput, runId, config))
223+
return sharedChildRun(parent, node, nodeInput, runId, config)
223224
.doOnError(
224225
error -> {
225226
if (delegatesOutput) {
@@ -229,6 +230,50 @@ private static Maybe<Object> startChild(
229230
.flatMapMaybe(child -> childResult(parent, child, delegatesOutput, config.raiseOnWait()));
230231
}
231232

233+
/**
234+
* Returns the run of {@code node} under {@code runId} shared by the caller's calls with that run
235+
* ID, starting it if needed. A run that fails or waits for an output it did not produce is not
236+
* shared, so a later call runs the child again. When the call that started a run is canceled, a
237+
* call still waiting on it runs the child itself.
238+
*/
239+
private static Single<Context> sharedChildRun(
240+
Context parent, Node node, @Nullable Object nodeInput, String runId, RunNodeConfig config) {
241+
return Single.defer(
242+
() -> {
243+
ConcurrentMap<String, SingleSubject<Context>> childRuns =
244+
parent.requireNodeState().childRuns();
245+
String childKey = segment(node.name(), runId);
246+
SingleSubject<Context> run = SingleSubject.create();
247+
SingleSubject<Context> shared = childRuns.putIfAbsent(childKey, run);
248+
if (shared != null) {
249+
return shared.onErrorResumeNext(
250+
error ->
251+
error instanceof ChildRunCanceledException
252+
? sharedChildRun(parent, node, nodeInput, runId, config)
253+
: Single.error(error));
254+
}
255+
// Defer so a throw from scan or intercept releases the claimed run instead of leaking it.
256+
return Single.defer(() -> runOrReplayChild(parent, node, nodeInput, runId, config))
257+
.doOnSuccess(
258+
child -> {
259+
if (!keepsItsRun(child)) {
260+
childRuns.remove(childKey, run);
261+
}
262+
run.onSuccess(child);
263+
})
264+
.doOnError(
265+
error -> {
266+
childRuns.remove(childKey, run);
267+
run.onError(error);
268+
})
269+
.doOnDispose(
270+
() -> {
271+
childRuns.remove(childKey, run);
272+
run.onError(new ChildRunCanceledException());
273+
});
274+
});
275+
}
276+
232277
/**
233278
* Runs {@code node} as a child of {@code parent}, or replays the result it recorded in an earlier
234279
* turn under the same run ID.
@@ -258,6 +303,22 @@ private static Single<Context> runOrReplayChild(
258303
.run(nodeInput);
259304
}
260305

306+
/**
307+
* Returns whether a later call with the same run ID shares this finished run of a child: a failed
308+
* child runs again, as does one that waits for an output and left no outcome.
309+
*/
310+
private static boolean keepsItsRun(Context child) {
311+
NodeExecutionState state = child.requireNodeState();
312+
if (state.failure().isPresent()) {
313+
return false;
314+
}
315+
return !state.interruptIds().isEmpty()
316+
|| state.output().isPresent()
317+
|| state.selectedRoutes().isPresent()
318+
|| child.eventActions().transferToAgent().isPresent()
319+
|| !state.node().waitForOutput();
320+
}
321+
261322
/**
262323
* Returns the child's output, or fails with the child's failure or with an interrupt, which the
263324
* caller takes on as its own.
@@ -292,9 +353,11 @@ private static Maybe<Object> childResult(
292353
&& (childNode instanceof Workflow || childNode.waitForOutput())) {
293354
return Maybe.error(new NodeInterruptedException());
294355
}
295-
// No event of this run carries a delegate's replayed output, so the caller emits it.
356+
// No event of this run carries a delegate's replayed or shared output, so the caller emits it.
296357
Optional<Object> output = childState.output();
297-
if (delegatesOutput && output.isPresent() && !childState.outputEmitted()) {
358+
boolean emittedForCaller =
359+
childState.outputEmitted() && childState.outputParent() == parentState;
360+
if (delegatesOutput && output.isPresent() && !emittedForCaller) {
298361
parentState.adoptReplayedOutput(output.get());
299362
}
300363
return Maybe.fromOptional(output);
@@ -532,6 +595,13 @@ private Event.Builder newEvent() {
532595
}
533596
}
534597

598+
/** Ends the waiting calls of a shared child run whose first caller was canceled. */
599+
private static final class ChildRunCanceledException extends RuntimeException {
600+
ChildRunCanceledException() {
601+
super("The call that ran this child was canceled.");
602+
}
603+
}
604+
535605
/**
536606
* An event queued for the runner, paired with the signal that completes once the caller receives
537607
* the event and, for an error event, with the failure it reports.

‎core/src/main/java/com/google/adk/workflow/RunNodeConfig.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ public abstract class RunNodeConfig {
3131

3232
/**
3333
* Returns the ID of the child's run, which names the run in node paths and branches. A child that
34-
* recorded an output under the same ID in an earlier turn returns it without running again. When
35-
* this ID is empty, the child receives the next number among the caller's runs of nodes with the
36-
* same name, in call order, so concurrent children need explicit IDs.
34+
* already recorded an output under the same ID, in this run of the caller or in an earlier turn,
35+
* returns it without running again; one that finished without an output may run again. When this
36+
* ID is empty, the child receives the next number among the caller's runs of nodes with the same
37+
* name, in call order, so concurrent children need explicit IDs.
3738
*/
3839
public abstract Optional<String> runId();
3940

0 commit comments

Comments
 (0)