Skip to content

Commit 101bb90

Browse files
committed
feat(#2790): Add ChildWorkflowOptions support to WorkflowImplementationOptions
Fixes #2790 Allow predefining ChildWorkflowOptions on WorkflowImplementationOptions, mirroring the existing ActivityOptions, LocalActivityOptions and NexusServiceOptions support: - Add setChildWorkflowOptions(Map) and setDefaultChildWorkflowOptions() builder methods, matching getters, and equals/hashCode/toString/ toBuilder support on WorkflowImplementationOptions - Expose the options through SyncWorkflowContext, served directly from the immutable WorkflowImplementationOptions - Add ChildWorkflowOptions.Builder#mergeChildWorkflowOptions(): non-null override fields win, except contextPropagators lists are concatenated (matching ActivityOptions.Builder#mergeActivityOptions), the mutually exclusive searchAttributes/typedSearchAttributes are merged as one logical field so the merged options never carry both flavors, and VERSIONING_INTENT_UNSPECIFIED is treated as unset - Resolve the predefined options for both typed and untyped child stubs (WorkflowInternal.newChildWorkflowStub and newUntypedChildWorkflowStub, so DynamicWorkflow parents are covered too) with the following precedence, highest to lowest, merged field by field: options passed to the stub creation method > per-type options (setChildWorkflowOptions) > default options (setDefaultChildWorkflowOptions) - Keep child stub creation cost unchanged: the interface metadata is computed once and passed into ChildWorkflowInvocationHandler instead of being reflectively re-derived - Keep the previous public WorkflowImplementationOptions constructor as a backward-compatible delegating overload - Document the new behavior on the Workflow child stub factory methods and warn against predefining workflowId, which would be applied to every child of the matching scope Fix child workflow options precedence: the predefined options were merged in the wrong order, so when no options were passed to newChildWorkflowStub the default options overrode the per-type options. The default options have the lowest precedence and must never override per-type options. The merge order is now correct, and the javadocs on setChildWorkflowOptions and setDefaultChildWorkflowOptions describe the actual precedence. Tests: - Verify the applied options through the child's memo in DefaultChildWorkflowOptionsSetOnWorkflowTest (covering default, per-type, explicit and field-level merge precedence for both typed and untyped stubs), so a test only passes if the expected options actually took effect - Add an exhaustive unit test for ChildWorkflowOptions#mergeChildWorkflowOptions that exercises every field, plus dedicated tests for the search attribute flavors, context propagator concatenation and unspecified versioning intent Signed-off-by: Oleksandr Porunov <alexandr.porunov@gmail.com>
1 parent f86c766 commit 101bb90

8 files changed

Lines changed: 795 additions & 11 deletions

File tree

temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ class ChildWorkflowInvocationHandler implements InvocationHandler {
2222
private final POJOWorkflowInterfaceMetadata workflowMetadata;
2323

2424
ChildWorkflowInvocationHandler(
25-
Class<?> workflowInterface,
25+
POJOWorkflowInterfaceMetadata workflowMetadata,
2626
ChildWorkflowOptions options,
2727
WorkflowOutboundCallsInterceptor outboundCallsInterceptor,
2828
Functions.Proc1<String> assertReadOnly) {
29-
workflowMetadata = POJOWorkflowInterfaceMetadata.newInstance(workflowInterface);
29+
this.workflowMetadata = workflowMetadata;
3030
Optional<POJOWorkflowMethodMetadata> workflowMethodMetadata =
3131
workflowMetadata.getWorkflowMethod();
3232
if (!workflowMethodMetadata.isPresent()) {
3333
throw new IllegalArgumentException(
34-
"Missing method annotated with @WorkflowMethod: " + workflowInterface.getName());
34+
"Missing method annotated with @WorkflowMethod: "
35+
+ workflowMetadata.getInterfaceClass().getName());
3536
}
3637
Method workflowMethod = workflowMethodMetadata.get().getWorkflowMethod();
3738
MethodRetry retryAnnotation = workflowMethod.getAnnotation(MethodRetry.class);

temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,18 @@ public NexusServiceOptions getDefaultNexusServiceOptions() {
215215
: Collections.emptyMap();
216216
}
217217

218+
/**
219+
* Unlike the activity options above, the child workflow options have no runtime mutators, so they
220+
* are served directly from the immutable {@link WorkflowImplementationOptions}.
221+
*/
222+
public ChildWorkflowOptions getDefaultChildWorkflowOptions() {
223+
return workflowImplementationOptions.getDefaultChildWorkflowOptions();
224+
}
225+
226+
public @Nonnull Map<String, ChildWorkflowOptions> getChildWorkflowOptions() {
227+
return workflowImplementationOptions.getChildWorkflowOptions();
228+
}
229+
218230
public void setDefaultActivityOptions(ActivityOptions defaultActivityOptions) {
219231
this.defaultActivityOptions =
220232
(this.defaultActivityOptions == null)

temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,17 +391,48 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti
391391
@SuppressWarnings("unchecked")
392392
public static <T> T newChildWorkflowStub(
393393
Class<T> workflowInterface, ChildWorkflowOptions options) {
394+
SyncWorkflowContext context = getRootWorkflowContext();
395+
POJOWorkflowInterfaceMetadata workflowMetadata =
396+
POJOWorkflowInterfaceMetadata.newInstance(workflowInterface);
397+
// The workflow type is absent for interfaces that contain only signal and query methods;
398+
// ChildWorkflowInvocationHandler rejects such interfaces.
399+
String workflowType = workflowMetadata.getWorkflowType().orElse(null);
400+
options = mergePredefinedChildWorkflowOptions(context, workflowType, options);
394401
return (T)
395402
Proxy.newProxyInstance(
396403
workflowInterface.getClassLoader(),
397404
new Class<?>[] {workflowInterface, StubMarker.class, AsyncMarker.class},
398405
new ChildWorkflowInvocationHandler(
399-
workflowInterface,
406+
workflowMetadata,
400407
options,
401-
getWorkflowOutboundInterceptor(),
408+
context.getWorkflowOutboundInterceptor(),
402409
WorkflowInternal::assertNotReadOnly));
403410
}
404411

412+
/**
413+
* Merges the child workflow options predefined through {@link
414+
* io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and
415+
* {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions(
416+
* ChildWorkflowOptions)} into the options passed to the stub creation method. Precedence from
417+
* lowest to highest: default options < per-type options < the passed options. Each layer
418+
* overrides only the non-null fields of the layers below it.
419+
*/
420+
private static ChildWorkflowOptions mergePredefinedChildWorkflowOptions(
421+
SyncWorkflowContext context,
422+
@Nullable String workflowType,
423+
@Nullable ChildWorkflowOptions options) {
424+
ChildWorkflowOptions defaultOptions = context.getDefaultChildWorkflowOptions();
425+
ChildWorkflowOptions perTypeOptions =
426+
workflowType != null ? context.getChildWorkflowOptions().get(workflowType) : null;
427+
if (defaultOptions == null && perTypeOptions == null) {
428+
return options;
429+
}
430+
return ChildWorkflowOptions.newBuilder(defaultOptions)
431+
.mergeChildWorkflowOptions(perTypeOptions)
432+
.mergeChildWorkflowOptions(options)
433+
.build();
434+
}
435+
405436
@SuppressWarnings("unchecked")
406437
public static <T> T newExternalWorkflowStub(
407438
Class<T> workflowInterface, WorkflowExecution execution) {
@@ -434,10 +465,12 @@ public static Promise<WorkflowExecution> getWorkflowExecution(Object workflowStu
434465

435466
public static ChildWorkflowStub newUntypedChildWorkflowStub(
436467
String workflowType, ChildWorkflowOptions options) {
468+
SyncWorkflowContext context = getRootWorkflowContext();
469+
options = mergePredefinedChildWorkflowOptions(context, workflowType, options);
437470
return new ChildWorkflowStubImpl(
438471
workflowType,
439472
options,
440-
getWorkflowOutboundInterceptor(),
473+
context.getWorkflowOutboundInterceptor(),
441474
WorkflowInternal::assertNotReadOnly);
442475
}
443476

temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.temporal.activity.ActivityOptions;
44
import io.temporal.activity.LocalActivityOptions;
55
import io.temporal.common.Experimental;
6+
import io.temporal.workflow.ChildWorkflowOptions;
67
import io.temporal.workflow.NexusServiceOptions;
78
import io.temporal.workflow.Workflow;
89
import java.util.*;
@@ -42,6 +43,8 @@ public static final class Builder {
4243
private LocalActivityOptions defaultLocalActivityOptions;
4344
private Map<String, NexusServiceOptions> nexusServiceOptions;
4445
private NexusServiceOptions defaultNexusServiceOptions;
46+
private Map<String, ChildWorkflowOptions> childWorkflowOptions;
47+
private ChildWorkflowOptions defaultChildWorkflowOptions;
4548
private boolean enableUpsertVersionSearchAttributes;
4649

4750
private Builder() {}
@@ -57,6 +60,8 @@ private Builder(WorkflowImplementationOptions options) {
5760
this.defaultLocalActivityOptions = options.getDefaultLocalActivityOptions();
5861
this.nexusServiceOptions = options.getNexusServiceOptions();
5962
this.defaultNexusServiceOptions = options.getDefaultNexusServiceOptions();
63+
this.childWorkflowOptions = options.getChildWorkflowOptions();
64+
this.defaultChildWorkflowOptions = options.getDefaultChildWorkflowOptions();
6065
this.enableUpsertVersionSearchAttributes = options.isEnableUpsertVersionSearchAttributes();
6166
}
6267

@@ -158,6 +163,49 @@ public Builder setDefaultNexusServiceOptions(NexusServiceOptions defaultNexusSer
158163
return this;
159164
}
160165

166+
/**
167+
* Set individual child workflow options per workflow type. They apply to child workflow stubs
168+
* created through both {@link io.temporal.workflow.Workflow#newChildWorkflowStub(Class,
169+
* ChildWorkflowOptions)} and {@link
170+
* io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}.
171+
* These options take precedence over the default options set through {@link
172+
* #setDefaultChildWorkflowOptions(ChildWorkflowOptions)}, but each field is still overridden by
173+
* the corresponding non-null field of the options passed to the stub creation method, which
174+
* have the highest precedence.
175+
*
176+
* <p>Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id
177+
* would be applied to every child workflow of the type, so starting more than one such child
178+
* fails with a duplicate workflow id error.
179+
*
180+
* @param childWorkflowOptions map from workflow type to ChildWorkflowOptions
181+
*/
182+
public Builder setChildWorkflowOptions(Map<String, ChildWorkflowOptions> childWorkflowOptions) {
183+
this.childWorkflowOptions = new HashMap<>(Objects.requireNonNull(childWorkflowOptions));
184+
return this;
185+
}
186+
187+
/**
188+
* These child workflow options have the lowest precedence across all child workflow options.
189+
* They apply to child workflow stubs created through both {@link
190+
* io.temporal.workflow.Workflow#newChildWorkflowStub(Class, ChildWorkflowOptions)} and {@link
191+
* io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}.
192+
* Each field is overridden by the corresponding non-null field of the per-type options set
193+
* through {@link #setChildWorkflowOptions(Map)}, and then by the options passed to the stub
194+
* creation method, which have the highest precedence.
195+
*
196+
* <p>Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id
197+
* would be applied to every child workflow started with these options, so starting more than
198+
* one such child fails with a duplicate workflow id error.
199+
*
200+
* @param defaultChildWorkflowOptions ChildWorkflowOptions for all child workflows in the
201+
* workflow.
202+
*/
203+
public Builder setDefaultChildWorkflowOptions(
204+
ChildWorkflowOptions defaultChildWorkflowOptions) {
205+
this.defaultChildWorkflowOptions = Objects.requireNonNull(defaultChildWorkflowOptions);
206+
return this;
207+
}
208+
161209
/**
162210
* Enable upserting version search attributes on {@link Workflow#getVersion}. This will cause
163211
* the SDK to automatically add the <b>TemporalChangeVersion</b> search attributes to the
@@ -187,6 +235,8 @@ public WorkflowImplementationOptions build() {
187235
defaultLocalActivityOptions,
188236
nexusServiceOptions == null ? null : nexusServiceOptions,
189237
defaultNexusServiceOptions,
238+
childWorkflowOptions,
239+
defaultChildWorkflowOptions,
190240
enableUpsertVersionSearchAttributes);
191241
}
192242
}
@@ -198,8 +248,14 @@ public WorkflowImplementationOptions build() {
198248
private final LocalActivityOptions defaultLocalActivityOptions;
199249
private final @Nullable Map<String, NexusServiceOptions> nexusServiceOptions;
200250
private final NexusServiceOptions defaultNexusServiceOptions;
251+
private final @Nullable Map<String, ChildWorkflowOptions> childWorkflowOptions;
252+
private final ChildWorkflowOptions defaultChildWorkflowOptions;
201253
private final boolean enableUpsertVersionSearchAttributes;
202254

255+
/**
256+
* Retained for backward compatibility with code compiled against SDK versions without child
257+
* workflow options support. Prefer {@link #newBuilder()}.
258+
*/
203259
public WorkflowImplementationOptions(
204260
Class<? extends Throwable>[] failWorkflowExceptionTypes,
205261
@Nullable Map<String, ActivityOptions> activityOptions,
@@ -209,13 +265,39 @@ public WorkflowImplementationOptions(
209265
@Nullable Map<String, NexusServiceOptions> nexusServiceOptions,
210266
NexusServiceOptions defaultNexusServiceOptions,
211267
boolean enableUpsertVersionSearchAttributes) {
268+
this(
269+
failWorkflowExceptionTypes,
270+
activityOptions,
271+
defaultActivityOptions,
272+
localActivityOptions,
273+
defaultLocalActivityOptions,
274+
nexusServiceOptions,
275+
defaultNexusServiceOptions,
276+
null,
277+
null,
278+
enableUpsertVersionSearchAttributes);
279+
}
280+
281+
public WorkflowImplementationOptions(
282+
Class<? extends Throwable>[] failWorkflowExceptionTypes,
283+
@Nullable Map<String, ActivityOptions> activityOptions,
284+
ActivityOptions defaultActivityOptions,
285+
@Nullable Map<String, LocalActivityOptions> localActivityOptions,
286+
LocalActivityOptions defaultLocalActivityOptions,
287+
@Nullable Map<String, NexusServiceOptions> nexusServiceOptions,
288+
NexusServiceOptions defaultNexusServiceOptions,
289+
@Nullable Map<String, ChildWorkflowOptions> childWorkflowOptions,
290+
ChildWorkflowOptions defaultChildWorkflowOptions,
291+
boolean enableUpsertVersionSearchAttributes) {
212292
this.failWorkflowExceptionTypes = failWorkflowExceptionTypes;
213293
this.activityOptions = activityOptions;
214294
this.defaultActivityOptions = defaultActivityOptions;
215295
this.localActivityOptions = localActivityOptions;
216296
this.defaultLocalActivityOptions = defaultLocalActivityOptions;
217297
this.nexusServiceOptions = nexusServiceOptions;
218298
this.defaultNexusServiceOptions = defaultNexusServiceOptions;
299+
this.childWorkflowOptions = childWorkflowOptions;
300+
this.defaultChildWorkflowOptions = defaultChildWorkflowOptions;
219301
this.enableUpsertVersionSearchAttributes = enableUpsertVersionSearchAttributes;
220302
}
221303

@@ -253,6 +335,16 @@ public NexusServiceOptions getDefaultNexusServiceOptions() {
253335
return defaultNexusServiceOptions;
254336
}
255337

338+
public @Nonnull Map<String, ChildWorkflowOptions> getChildWorkflowOptions() {
339+
return childWorkflowOptions != null
340+
? Collections.unmodifiableMap(childWorkflowOptions)
341+
: Collections.emptyMap();
342+
}
343+
344+
public ChildWorkflowOptions getDefaultChildWorkflowOptions() {
345+
return defaultChildWorkflowOptions;
346+
}
347+
256348
@Experimental
257349
public boolean isEnableUpsertVersionSearchAttributes() {
258350
return enableUpsertVersionSearchAttributes;
@@ -275,6 +367,10 @@ public String toString() {
275367
+ nexusServiceOptions
276368
+ ", defaultNexusServiceOptions="
277369
+ defaultNexusServiceOptions
370+
+ ", childWorkflowOptions="
371+
+ childWorkflowOptions
372+
+ ", defaultChildWorkflowOptions="
373+
+ defaultChildWorkflowOptions
278374
+ ", enableUpsertVersionSearchAttributes="
279375
+ enableUpsertVersionSearchAttributes
280376
+ '}';
@@ -292,6 +388,8 @@ public boolean equals(Object o) {
292388
&& Objects.equals(defaultLocalActivityOptions, that.defaultLocalActivityOptions)
293389
&& Objects.equals(nexusServiceOptions, that.nexusServiceOptions)
294390
&& Objects.equals(defaultNexusServiceOptions, that.defaultNexusServiceOptions)
391+
&& Objects.equals(childWorkflowOptions, that.childWorkflowOptions)
392+
&& Objects.equals(defaultChildWorkflowOptions, that.defaultChildWorkflowOptions)
295393
&& Objects.equals(
296394
enableUpsertVersionSearchAttributes, that.enableUpsertVersionSearchAttributes);
297395
}
@@ -306,6 +404,8 @@ public int hashCode() {
306404
defaultLocalActivityOptions,
307405
nexusServiceOptions,
308406
defaultNexusServiceOptions,
407+
childWorkflowOptions,
408+
defaultChildWorkflowOptions,
309409
enableUpsertVersionSearchAttributes);
310410
result = 31 * result + Arrays.hashCode(failWorkflowExceptionTypes);
311411
return result;

temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.temporal.failure.TimeoutFailure;
1313
import io.temporal.internal.common.OptionsUtils;
1414
import java.time.Duration;
15+
import java.util.ArrayList;
1516
import java.util.List;
1617
import java.util.Map;
1718

@@ -338,6 +339,89 @@ public Builder setPriority(Priority priority) {
338339
return this;
339340
}
340341

342+
/**
343+
* Merges the provided override options into this builder. Any non-null fields in the override
344+
* will take precedence over the fields in this builder, with the following exceptions:
345+
*
346+
* <ul>
347+
* <li>{@code contextPropagators} lists are concatenated instead of replaced, matching {@link
348+
* io.temporal.activity.ActivityOptions.Builder#mergeActivityOptions(
349+
* io.temporal.activity.ActivityOptions)}.
350+
* <li>The mutually exclusive {@code searchAttributes} and {@code typedSearchAttributes} are
351+
* merged as a single logical field: an override that specifies either flavor replaces
352+
* both, so the merged options never carry both flavors at once.
353+
* <li>A {@code versioningIntent} of {@link VersioningIntent#VERSIONING_INTENT_UNSPECIFIED} is
354+
* treated as unset.
355+
* </ul>
356+
*
357+
* @param override ChildWorkflowOptions that overrides the current builder values.
358+
* @return this builder.
359+
*/
360+
@SuppressWarnings("deprecation")
361+
public Builder mergeChildWorkflowOptions(ChildWorkflowOptions override) {
362+
if (override == null) {
363+
return this;
364+
}
365+
this.namespace = (override.getNamespace() == null) ? this.namespace : override.getNamespace();
366+
this.workflowId =
367+
(override.getWorkflowId() == null) ? this.workflowId : override.getWorkflowId();
368+
this.workflowIdReusePolicy =
369+
(override.getWorkflowIdReusePolicy() == null)
370+
? this.workflowIdReusePolicy
371+
: override.getWorkflowIdReusePolicy();
372+
this.workflowRunTimeout =
373+
(override.getWorkflowRunTimeout() == null)
374+
? this.workflowRunTimeout
375+
: override.getWorkflowRunTimeout();
376+
this.workflowExecutionTimeout =
377+
(override.getWorkflowExecutionTimeout() == null)
378+
? this.workflowExecutionTimeout
379+
: override.getWorkflowExecutionTimeout();
380+
this.workflowTaskTimeout =
381+
(override.getWorkflowTaskTimeout() == null)
382+
? this.workflowTaskTimeout
383+
: override.getWorkflowTaskTimeout();
384+
this.taskQueue = (override.getTaskQueue() == null) ? this.taskQueue : override.getTaskQueue();
385+
this.retryOptions =
386+
(override.getRetryOptions() == null) ? this.retryOptions : override.getRetryOptions();
387+
this.cronSchedule =
388+
(override.getCronSchedule() == null) ? this.cronSchedule : override.getCronSchedule();
389+
this.parentClosePolicy =
390+
(override.getParentClosePolicy() == null)
391+
? this.parentClosePolicy
392+
: override.getParentClosePolicy();
393+
this.memo = (override.getMemo() == null) ? this.memo : override.getMemo();
394+
// searchAttributes and typedSearchAttributes are mutually exclusive (the setters reject
395+
// mixing them), so they are merged as one logical field: an override specifying either
396+
// flavor replaces both, otherwise the merged options could carry both flavors and fail at
397+
// child-scheduling time.
398+
if (override.getSearchAttributes() != null || override.getTypedSearchAttributes() != null) {
399+
this.searchAttributes = override.getSearchAttributes();
400+
this.typedSearchAttributes = override.getTypedSearchAttributes();
401+
}
402+
if (this.contextPropagators == null) {
403+
this.contextPropagators = override.getContextPropagators();
404+
} else if (override.getContextPropagators() != null) {
405+
List<ContextPropagator> mergedPropagators = new ArrayList<>(this.contextPropagators);
406+
mergedPropagators.addAll(override.getContextPropagators());
407+
this.contextPropagators = mergedPropagators;
408+
}
409+
this.cancellationType =
410+
(override.getCancellationType() == null)
411+
? this.cancellationType
412+
: override.getCancellationType();
413+
if (override.getVersioningIntent() != null
414+
&& override.getVersioningIntent() != VersioningIntent.VERSIONING_INTENT_UNSPECIFIED) {
415+
this.versioningIntent = override.getVersioningIntent();
416+
}
417+
this.staticSummary =
418+
(override.getStaticSummary() == null) ? this.staticSummary : override.getStaticSummary();
419+
this.staticDetails =
420+
(override.getStaticDetails() == null) ? this.staticDetails : override.getStaticDetails();
421+
this.priority = (override.getPriority() == null) ? this.priority : override.getPriority();
422+
return this;
423+
}
424+
341425
public ChildWorkflowOptions build() {
342426
return new ChildWorkflowOptions(
343427
namespace,

0 commit comments

Comments
 (0)