4040import io .reactivex .rxjava3 .processors .FlowableProcessor ;
4141import io .reactivex .rxjava3 .processors .UnicastProcessor ;
4242import io .reactivex .rxjava3 .subjects .CompletableSubject ;
43+ import io .reactivex .rxjava3 .subjects .SingleSubject ;
4344import java .util .Map ;
4445import java .util .Optional ;
4546import java .util .Queue ;
4647import java .util .concurrent .ConcurrentLinkedQueue ;
48+ import java .util .concurrent .ConcurrentMap ;
4749import java .util .function .Function ;
4850import 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.
0 commit comments