diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java index 365ff4b87..726885925 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java @@ -22,16 +22,17 @@ class ChildWorkflowInvocationHandler implements InvocationHandler { private final POJOWorkflowInterfaceMetadata workflowMetadata; ChildWorkflowInvocationHandler( - Class workflowInterface, + POJOWorkflowInterfaceMetadata workflowMetadata, ChildWorkflowOptions options, WorkflowOutboundCallsInterceptor outboundCallsInterceptor, Functions.Proc1 assertReadOnly) { - workflowMetadata = POJOWorkflowInterfaceMetadata.newInstance(workflowInterface); + this.workflowMetadata = workflowMetadata; Optional workflowMethodMetadata = workflowMetadata.getWorkflowMethod(); if (!workflowMethodMetadata.isPresent()) { throw new IllegalArgumentException( - "Missing method annotated with @WorkflowMethod: " + workflowInterface.getName()); + "Missing method annotated with @WorkflowMethod: " + + workflowMetadata.getInterfaceClass().getName()); } Method workflowMethod = workflowMethodMetadata.get().getWorkflowMethod(); MethodRetry retryAnnotation = workflowMethod.getAnnotation(MethodRetry.class); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index 1517c9257..bdc1528d7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -215,6 +215,18 @@ public NexusServiceOptions getDefaultNexusServiceOptions() { : Collections.emptyMap(); } + /** + * Unlike the activity options above, the child workflow options have no runtime mutators, so they + * are served directly from the immutable {@link WorkflowImplementationOptions}. + */ + public ChildWorkflowOptions getDefaultChildWorkflowOptions() { + return workflowImplementationOptions.getDefaultChildWorkflowOptions(); + } + + public @Nonnull Map getChildWorkflowOptions() { + return workflowImplementationOptions.getChildWorkflowOptions(); + } + public void setDefaultActivityOptions(ActivityOptions defaultActivityOptions) { this.defaultActivityOptions = (this.defaultActivityOptions == null) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java index 79495694b..2f34ee153 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java @@ -391,17 +391,48 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti @SuppressWarnings("unchecked") public static T newChildWorkflowStub( Class workflowInterface, ChildWorkflowOptions options) { + SyncWorkflowContext context = getRootWorkflowContext(); + POJOWorkflowInterfaceMetadata workflowMetadata = + POJOWorkflowInterfaceMetadata.newInstance(workflowInterface); + // The workflow type is absent for interfaces that contain only signal and query methods; + // ChildWorkflowInvocationHandler rejects such interfaces. + String workflowType = workflowMetadata.getWorkflowType().orElse(null); + options = mergePredefinedChildWorkflowOptions(context, workflowType, options); return (T) Proxy.newProxyInstance( workflowInterface.getClassLoader(), new Class[] {workflowInterface, StubMarker.class, AsyncMarker.class}, new ChildWorkflowInvocationHandler( - workflowInterface, + workflowMetadata, options, - getWorkflowOutboundInterceptor(), + context.getWorkflowOutboundInterceptor(), WorkflowInternal::assertNotReadOnly)); } + /** + * Merges the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)} into the options passed to the stub creation method. Precedence from + * lowest to highest: default options < per-type options < the passed options. Each layer + * overrides only the non-null fields of the layers below it. + */ + private static ChildWorkflowOptions mergePredefinedChildWorkflowOptions( + SyncWorkflowContext context, + @Nullable String workflowType, + @Nullable ChildWorkflowOptions options) { + ChildWorkflowOptions defaultOptions = context.getDefaultChildWorkflowOptions(); + ChildWorkflowOptions perTypeOptions = + workflowType != null ? context.getChildWorkflowOptions().get(workflowType) : null; + if (defaultOptions == null && perTypeOptions == null) { + return options; + } + return ChildWorkflowOptions.newBuilder(defaultOptions) + .mergeChildWorkflowOptions(perTypeOptions) + .mergeChildWorkflowOptions(options) + .build(); + } + @SuppressWarnings("unchecked") public static T newExternalWorkflowStub( Class workflowInterface, WorkflowExecution execution) { @@ -434,10 +465,12 @@ public static Promise getWorkflowExecution(Object workflowStu public static ChildWorkflowStub newUntypedChildWorkflowStub( String workflowType, ChildWorkflowOptions options) { + SyncWorkflowContext context = getRootWorkflowContext(); + options = mergePredefinedChildWorkflowOptions(context, workflowType, options); return new ChildWorkflowStubImpl( workflowType, options, - getWorkflowOutboundInterceptor(), + context.getWorkflowOutboundInterceptor(), WorkflowInternal::assertNotReadOnly); } diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java index c2d0aa033..f0c39f857 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java @@ -3,6 +3,7 @@ import io.temporal.activity.ActivityOptions; import io.temporal.activity.LocalActivityOptions; import io.temporal.common.Experimental; +import io.temporal.workflow.ChildWorkflowOptions; import io.temporal.workflow.NexusServiceOptions; import io.temporal.workflow.Workflow; import java.util.*; @@ -42,6 +43,8 @@ public static final class Builder { private LocalActivityOptions defaultLocalActivityOptions; private Map nexusServiceOptions; private NexusServiceOptions defaultNexusServiceOptions; + private Map childWorkflowOptions; + private ChildWorkflowOptions defaultChildWorkflowOptions; private boolean enableUpsertVersionSearchAttributes; private Builder() {} @@ -57,6 +60,8 @@ private Builder(WorkflowImplementationOptions options) { this.defaultLocalActivityOptions = options.getDefaultLocalActivityOptions(); this.nexusServiceOptions = options.getNexusServiceOptions(); this.defaultNexusServiceOptions = options.getDefaultNexusServiceOptions(); + this.childWorkflowOptions = options.getChildWorkflowOptions(); + this.defaultChildWorkflowOptions = options.getDefaultChildWorkflowOptions(); this.enableUpsertVersionSearchAttributes = options.isEnableUpsertVersionSearchAttributes(); } @@ -158,6 +163,49 @@ public Builder setDefaultNexusServiceOptions(NexusServiceOptions defaultNexusSer return this; } + /** + * Set individual child workflow options per workflow type. They apply to child workflow stubs + * created through both {@link io.temporal.workflow.Workflow#newChildWorkflowStub(Class, + * ChildWorkflowOptions)} and {@link + * io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}. + * These options take precedence over the default options set through {@link + * #setDefaultChildWorkflowOptions(ChildWorkflowOptions)}, but each field is still overridden by + * the corresponding non-null field of the options passed to the stub creation method, which + * have the highest precedence. + * + *

Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id + * would be applied to every child workflow of the type, so starting more than one such child + * fails with a duplicate workflow id error. + * + * @param childWorkflowOptions map from workflow type to ChildWorkflowOptions + */ + public Builder setChildWorkflowOptions(Map childWorkflowOptions) { + this.childWorkflowOptions = new HashMap<>(Objects.requireNonNull(childWorkflowOptions)); + return this; + } + + /** + * These child workflow options have the lowest precedence across all child workflow options. + * They apply to child workflow stubs created through both {@link + * io.temporal.workflow.Workflow#newChildWorkflowStub(Class, ChildWorkflowOptions)} and {@link + * io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}. + * Each field is overridden by the corresponding non-null field of the per-type options set + * through {@link #setChildWorkflowOptions(Map)}, and then by the options passed to the stub + * creation method, which have the highest precedence. + * + *

Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id + * would be applied to every child workflow started with these options, so starting more than + * one such child fails with a duplicate workflow id error. + * + * @param defaultChildWorkflowOptions ChildWorkflowOptions for all child workflows in the + * workflow. + */ + public Builder setDefaultChildWorkflowOptions( + ChildWorkflowOptions defaultChildWorkflowOptions) { + this.defaultChildWorkflowOptions = Objects.requireNonNull(defaultChildWorkflowOptions); + return this; + } + /** * Enable upserting version search attributes on {@link Workflow#getVersion}. This will cause * the SDK to automatically add the TemporalChangeVersion search attributes to the @@ -187,6 +235,8 @@ public WorkflowImplementationOptions build() { defaultLocalActivityOptions, nexusServiceOptions == null ? null : nexusServiceOptions, defaultNexusServiceOptions, + childWorkflowOptions, + defaultChildWorkflowOptions, enableUpsertVersionSearchAttributes); } } @@ -198,8 +248,14 @@ public WorkflowImplementationOptions build() { private final LocalActivityOptions defaultLocalActivityOptions; private final @Nullable Map nexusServiceOptions; private final NexusServiceOptions defaultNexusServiceOptions; + private final @Nullable Map childWorkflowOptions; + private final ChildWorkflowOptions defaultChildWorkflowOptions; private final boolean enableUpsertVersionSearchAttributes; + /** + * Retained for backward compatibility with code compiled against SDK versions without child + * workflow options support. Prefer {@link #newBuilder()}. + */ public WorkflowImplementationOptions( Class[] failWorkflowExceptionTypes, @Nullable Map activityOptions, @@ -209,6 +265,30 @@ public WorkflowImplementationOptions( @Nullable Map nexusServiceOptions, NexusServiceOptions defaultNexusServiceOptions, boolean enableUpsertVersionSearchAttributes) { + this( + failWorkflowExceptionTypes, + activityOptions, + defaultActivityOptions, + localActivityOptions, + defaultLocalActivityOptions, + nexusServiceOptions, + defaultNexusServiceOptions, + null, + null, + enableUpsertVersionSearchAttributes); + } + + public WorkflowImplementationOptions( + Class[] failWorkflowExceptionTypes, + @Nullable Map activityOptions, + ActivityOptions defaultActivityOptions, + @Nullable Map localActivityOptions, + LocalActivityOptions defaultLocalActivityOptions, + @Nullable Map nexusServiceOptions, + NexusServiceOptions defaultNexusServiceOptions, + @Nullable Map childWorkflowOptions, + ChildWorkflowOptions defaultChildWorkflowOptions, + boolean enableUpsertVersionSearchAttributes) { this.failWorkflowExceptionTypes = failWorkflowExceptionTypes; this.activityOptions = activityOptions; this.defaultActivityOptions = defaultActivityOptions; @@ -216,6 +296,8 @@ public WorkflowImplementationOptions( this.defaultLocalActivityOptions = defaultLocalActivityOptions; this.nexusServiceOptions = nexusServiceOptions; this.defaultNexusServiceOptions = defaultNexusServiceOptions; + this.childWorkflowOptions = childWorkflowOptions; + this.defaultChildWorkflowOptions = defaultChildWorkflowOptions; this.enableUpsertVersionSearchAttributes = enableUpsertVersionSearchAttributes; } @@ -253,6 +335,16 @@ public NexusServiceOptions getDefaultNexusServiceOptions() { return defaultNexusServiceOptions; } + public @Nonnull Map getChildWorkflowOptions() { + return childWorkflowOptions != null + ? Collections.unmodifiableMap(childWorkflowOptions) + : Collections.emptyMap(); + } + + public ChildWorkflowOptions getDefaultChildWorkflowOptions() { + return defaultChildWorkflowOptions; + } + @Experimental public boolean isEnableUpsertVersionSearchAttributes() { return enableUpsertVersionSearchAttributes; @@ -275,6 +367,10 @@ public String toString() { + nexusServiceOptions + ", defaultNexusServiceOptions=" + defaultNexusServiceOptions + + ", childWorkflowOptions=" + + childWorkflowOptions + + ", defaultChildWorkflowOptions=" + + defaultChildWorkflowOptions + ", enableUpsertVersionSearchAttributes=" + enableUpsertVersionSearchAttributes + '}'; @@ -292,6 +388,8 @@ public boolean equals(Object o) { && Objects.equals(defaultLocalActivityOptions, that.defaultLocalActivityOptions) && Objects.equals(nexusServiceOptions, that.nexusServiceOptions) && Objects.equals(defaultNexusServiceOptions, that.defaultNexusServiceOptions) + && Objects.equals(childWorkflowOptions, that.childWorkflowOptions) + && Objects.equals(defaultChildWorkflowOptions, that.defaultChildWorkflowOptions) && Objects.equals( enableUpsertVersionSearchAttributes, that.enableUpsertVersionSearchAttributes); } @@ -306,6 +404,8 @@ public int hashCode() { defaultLocalActivityOptions, nexusServiceOptions, defaultNexusServiceOptions, + childWorkflowOptions, + defaultChildWorkflowOptions, enableUpsertVersionSearchAttributes); result = 31 * result + Arrays.hashCode(failWorkflowExceptionTypes); return result; diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java index 261cd9724..a76e9c612 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java @@ -12,6 +12,7 @@ import io.temporal.failure.TimeoutFailure; import io.temporal.internal.common.OptionsUtils; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -338,6 +339,89 @@ public Builder setPriority(Priority priority) { return this; } + /** + * Merges the provided override options into this builder. Any non-null fields in the override + * will take precedence over the fields in this builder, with the following exceptions: + * + *

    + *
  • {@code contextPropagators} lists are concatenated instead of replaced, matching {@link + * io.temporal.activity.ActivityOptions.Builder#mergeActivityOptions( + * io.temporal.activity.ActivityOptions)}. + *
  • The mutually exclusive {@code searchAttributes} and {@code typedSearchAttributes} are + * merged as a single logical field: an override that specifies either flavor replaces + * both, so the merged options never carry both flavors at once. + *
  • A {@code versioningIntent} of {@link VersioningIntent#VERSIONING_INTENT_UNSPECIFIED} is + * treated as unset. + *
+ * + * @param override ChildWorkflowOptions that overrides the current builder values. + * @return this builder. + */ + @SuppressWarnings("deprecation") + public Builder mergeChildWorkflowOptions(ChildWorkflowOptions override) { + if (override == null) { + return this; + } + this.namespace = (override.getNamespace() == null) ? this.namespace : override.getNamespace(); + this.workflowId = + (override.getWorkflowId() == null) ? this.workflowId : override.getWorkflowId(); + this.workflowIdReusePolicy = + (override.getWorkflowIdReusePolicy() == null) + ? this.workflowIdReusePolicy + : override.getWorkflowIdReusePolicy(); + this.workflowRunTimeout = + (override.getWorkflowRunTimeout() == null) + ? this.workflowRunTimeout + : override.getWorkflowRunTimeout(); + this.workflowExecutionTimeout = + (override.getWorkflowExecutionTimeout() == null) + ? this.workflowExecutionTimeout + : override.getWorkflowExecutionTimeout(); + this.workflowTaskTimeout = + (override.getWorkflowTaskTimeout() == null) + ? this.workflowTaskTimeout + : override.getWorkflowTaskTimeout(); + this.taskQueue = (override.getTaskQueue() == null) ? this.taskQueue : override.getTaskQueue(); + this.retryOptions = + (override.getRetryOptions() == null) ? this.retryOptions : override.getRetryOptions(); + this.cronSchedule = + (override.getCronSchedule() == null) ? this.cronSchedule : override.getCronSchedule(); + this.parentClosePolicy = + (override.getParentClosePolicy() == null) + ? this.parentClosePolicy + : override.getParentClosePolicy(); + this.memo = (override.getMemo() == null) ? this.memo : override.getMemo(); + // searchAttributes and typedSearchAttributes are mutually exclusive (the setters reject + // mixing them), so they are merged as one logical field: an override specifying either + // flavor replaces both, otherwise the merged options could carry both flavors and fail at + // child-scheduling time. + if (override.getSearchAttributes() != null || override.getTypedSearchAttributes() != null) { + this.searchAttributes = override.getSearchAttributes(); + this.typedSearchAttributes = override.getTypedSearchAttributes(); + } + if (this.contextPropagators == null) { + this.contextPropagators = override.getContextPropagators(); + } else if (override.getContextPropagators() != null) { + List mergedPropagators = new ArrayList<>(this.contextPropagators); + mergedPropagators.addAll(override.getContextPropagators()); + this.contextPropagators = mergedPropagators; + } + this.cancellationType = + (override.getCancellationType() == null) + ? this.cancellationType + : override.getCancellationType(); + if (override.getVersioningIntent() != null + && override.getVersioningIntent() != VersioningIntent.VERSIONING_INTENT_UNSPECIFIED) { + this.versioningIntent = override.getVersioningIntent(); + } + this.staticSummary = + (override.getStaticSummary() == null) ? this.staticSummary : override.getStaticSummary(); + this.staticDetails = + (override.getStaticDetails() == null) ? this.staticDetails : override.getStaticDetails(); + this.priority = (override.getPriority() == null) ? this.priority : override.getPriority(); + return this; + } + public ChildWorkflowOptions build() { return new ChildWorkflowOptions( namespace, diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java index 0378b8b6c..be73fd47f 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java @@ -152,8 +152,11 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti /** * Creates client stub that can be used to start a child workflow that implements the given - * interface using parent options. Use {@link #newExternalWorkflowStub(Class, String)} to get a - * stub to signal a workflow without starting it. + * interface using parent options. Child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)} are applied. Use {@link #newExternalWorkflowStub(Class, String)} to get + * a stub to signal a workflow without starting it. * * @param workflowInterface interface type implemented by activities */ @@ -167,7 +170,12 @@ public static T newChildWorkflowStub(Class workflowInterface) { * starting it. * * @param workflowInterface interface type implemented by activities - * @param options options passed to the child workflow. + * @param options options passed to the child workflow. Each non-null field overrides the + * corresponding field of the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}. */ public static T newChildWorkflowStub( Class workflowInterface, ChildWorkflowOptions options) { @@ -211,7 +219,12 @@ public static Promise getWorkflowExecution(Object workflowStu * Creates untyped client stub that can be used to start and signal a child workflow. * * @param workflowType name of the workflow type to start. - * @param options options passed to the child workflow. + * @param options options passed to the child workflow. Each non-null field overrides the + * corresponding field of the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}. */ public static ChildWorkflowStub newUntypedChildWorkflowStub( String workflowType, ChildWorkflowOptions options) { @@ -220,7 +233,10 @@ public static ChildWorkflowStub newUntypedChildWorkflowStub( /** * Creates untyped client stub that can be used to start and signal a child workflow. All options - * are inherited from the parent. + * are inherited from the parent, except for the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}, which are applied. * * @param workflowType name of the workflow type to start. */ diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java new file mode 100644 index 000000000..09728b87f --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java @@ -0,0 +1,339 @@ +package io.temporal.workflow; + +import static org.junit.Assert.*; + +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.ParentClosePolicy; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import io.temporal.common.SearchAttributeKey; +import io.temporal.common.SearchAttributes; +import io.temporal.common.context.ContextPropagator; +import io.temporal.worker.WorkflowImplementationOptions; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import org.junit.Test; + +public class ChildWorkflowOptionsInWorkflowImplementationOptionsTest { + + @Test + public void testBuilderSetAndGet() { + ChildWorkflowOptions defaultOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("default-queue") + .build(); + + ChildWorkflowOptions perTypeOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(200)) + .setTaskQueue("per-type-queue") + .build(); + + Map optionsMap = + Collections.singletonMap("MyWorkflow", perTypeOpts); + + WorkflowImplementationOptions options = + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultOpts) + .setChildWorkflowOptions(optionsMap) + .build(); + + assertEquals(defaultOpts, options.getDefaultChildWorkflowOptions()); + assertEquals(1, options.getChildWorkflowOptions().size()); + assertEquals(perTypeOpts, options.getChildWorkflowOptions().get("MyWorkflow")); + } + + @Test + public void testDefaultInstanceHasEmptyChildWorkflowOptions() { + WorkflowImplementationOptions options = WorkflowImplementationOptions.getDefaultInstance(); + assertNull(options.getDefaultChildWorkflowOptions()); + assertNotNull(options.getChildWorkflowOptions()); + assertTrue(options.getChildWorkflowOptions().isEmpty()); + } + + @Test + public void testToBuilder() { + ChildWorkflowOptions defaultOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .build(); + + Map optionsMap = + Collections.singletonMap("MyWorkflow", defaultOpts); + + WorkflowImplementationOptions original = + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultOpts) + .setChildWorkflowOptions(optionsMap) + .build(); + + WorkflowImplementationOptions copy = original.toBuilder().build(); + assertEquals(original.getDefaultChildWorkflowOptions(), copy.getDefaultChildWorkflowOptions()); + assertEquals(original.getChildWorkflowOptions(), copy.getChildWorkflowOptions()); + } + + @Test + public void testMergeChildWorkflowOptionsOverridesNonNull() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("base-queue") + .setWorkflowRunTimeout(Duration.ofSeconds(50)) + .build(); + + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(200)) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + + // The override takes precedence for workflowExecutionTimeout. + assertEquals(Duration.ofSeconds(200), merged.getWorkflowExecutionTimeout()); + // Base values are preserved for fields not set in the override. + assertEquals("base-queue", merged.getTaskQueue()); + assertEquals(Duration.ofSeconds(50), merged.getWorkflowRunTimeout()); + } + + @Test + public void testMergeChildWorkflowOptionsWithNull() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("base-queue") + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(null).build(); + + assertEquals(Duration.ofSeconds(100), merged.getWorkflowExecutionTimeout()); + assertEquals("base-queue", merged.getTaskQueue()); + } + + /** + * Exhaustively verifies the merge for every field. Both options have every field set to distinct + * values, so the merge result can only match the expectation if each field is merged from the + * correct getter, and {@code merge(base, empty)} can only equal {@code base} if no field is + * dropped. The only field the override does not replace is {@code contextPropagators}, which is + * concatenated. + */ + @Test + public void testMergeChildWorkflowOptionsOverridesEveryField() { + ContextPropagator propagatorA = new TestContextPropagator("A"); + ContextPropagator propagatorB = new TestContextPropagator("B"); + ChildWorkflowOptions optionsA = allFieldsSet(1, propagatorA); + ChildWorkflowOptions optionsB = allFieldsSet(2, propagatorB); + + // A fully populated override replaces every field of the base, except the context propagator + // lists, which are concatenated. + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(optionsA).mergeChildWorkflowOptions(optionsB).build(); + ChildWorkflowOptions expected = + optionsB.toBuilder().setContextPropagators(Arrays.asList(propagatorA, propagatorB)).build(); + assertEquals(expected, merged); + + // An override that sets no fields leaves every field of the base untouched. + ChildWorkflowOptions mergedWithEmpty = + ChildWorkflowOptions.newBuilder(optionsA) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals(optionsA, mergedWithEmpty); + } + + /** + * The two search attribute flavors are mutually exclusive, so an override that specifies either + * flavor must replace both fields; otherwise the merge could produce options carrying both + * flavors, which fail at child-scheduling time. + */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsReplacesSearchAttributeFlavorsAsOneField() { + Map legacyAttributes = Collections.singletonMap("Field", "legacy"); + ChildWorkflowOptions deprecatedFlavor = + ChildWorkflowOptions.newBuilder().setSearchAttributes(legacyAttributes).build(); + SearchAttributes typedAttributes = + SearchAttributes.newBuilder() + .set(SearchAttributeKey.forText("CustomTextField"), "typed") + .build(); + ChildWorkflowOptions typedFlavor = + ChildWorkflowOptions.newBuilder().setTypedSearchAttributes(typedAttributes).build(); + + // A typed override replaces a deprecated base. + ChildWorkflowOptions typedWins = + ChildWorkflowOptions.newBuilder(deprecatedFlavor) + .mergeChildWorkflowOptions(typedFlavor) + .build(); + assertNull(typedWins.getSearchAttributes()); + assertEquals(typedAttributes, typedWins.getTypedSearchAttributes()); + + // A deprecated override replaces a typed base. + ChildWorkflowOptions deprecatedWins = + ChildWorkflowOptions.newBuilder(typedFlavor) + .mergeChildWorkflowOptions(deprecatedFlavor) + .build(); + assertEquals(legacyAttributes, deprecatedWins.getSearchAttributes()); + assertNull(deprecatedWins.getTypedSearchAttributes()); + + // An override that specifies neither flavor keeps the base flavor. + ChildWorkflowOptions baseKept = + ChildWorkflowOptions.newBuilder(typedFlavor) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertNull(baseKept.getSearchAttributes()); + assertEquals(typedAttributes, baseKept.getTypedSearchAttributes()); + } + + /** + * {@code VERSIONING_INTENT_UNSPECIFIED} means "not set" and must not override a specific + * versioning intent, matching {@code ActivityOptions.Builder#mergeActivityOptions}. + */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsIgnoresUnspecifiedVersioningIntent() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setVersioningIntent(io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setVersioningIntent(io.temporal.common.VersioningIntent.VERSIONING_INTENT_UNSPECIFIED) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals( + io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE, + merged.getVersioningIntent()); + } + + /** + * Context propagator lists are concatenated instead of replaced, matching {@code + * ActivityOptions.Builder#mergeActivityOptions}. + */ + @Test + public void testMergeChildWorkflowOptionsConcatenatesContextPropagators() { + ContextPropagator propagatorA = new TestContextPropagator("A"); + ContextPropagator propagatorB = new TestContextPropagator("B"); + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setContextPropagators(Collections.singletonList(propagatorA)) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setContextPropagators(Collections.singletonList(propagatorB)) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals(Arrays.asList(propagatorA, propagatorB), merged.getContextPropagators()); + + ChildWorkflowOptions mergedWithEmptyOverride = + ChildWorkflowOptions.newBuilder(base) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals( + Collections.singletonList(propagatorA), mergedWithEmptyOverride.getContextPropagators()); + } + + /** The deprecated {@code searchAttributes} field is mutually exclusive with the typed variant. */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsMergesDeprecatedSearchAttributes() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setSearchAttributes(Collections.singletonMap("Field", "base")) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setSearchAttributes(Collections.singletonMap("Field", "override")) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals(Collections.singletonMap("Field", "override"), merged.getSearchAttributes()); + + ChildWorkflowOptions mergedKeepsBase = + ChildWorkflowOptions.newBuilder(base) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals(Collections.singletonMap("Field", "base"), mergedKeepsBase.getSearchAttributes()); + } + + /** + * Builds a {@link ChildWorkflowOptions} with every field set to a value derived from {@code v}. + */ + @SuppressWarnings("deprecation") + private static ChildWorkflowOptions allFieldsSet(int v, ContextPropagator propagator) { + return ChildWorkflowOptions.newBuilder() + .setNamespace("namespace-" + v) + .setWorkflowId("workflow-id-" + v) + .setWorkflowIdReusePolicy( + v == 1 + ? WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE + : WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowRunTimeout(Duration.ofSeconds(10 + v)) + .setWorkflowExecutionTimeout(Duration.ofSeconds(20 + v)) + .setWorkflowTaskTimeout(Duration.ofSeconds(30 + v)) + .setTaskQueue("task-queue-" + v) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(v).build()) + .setCronSchedule(v + " 0 * * *") + .setParentClosePolicy( + v == 1 + ? ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON + : ParentClosePolicy.PARENT_CLOSE_POLICY_TERMINATE) + .setMemo(Collections.singletonMap("memoKey", "memo-" + v)) + .setTypedSearchAttributes( + SearchAttributes.newBuilder() + .set(SearchAttributeKey.forText("CustomTextField"), "search-attribute-" + v) + .build()) + .setContextPropagators(Collections.singletonList(propagator)) + .setCancellationType( + v == 1 + ? ChildWorkflowCancellationType.TRY_CANCEL + : ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED) + .setVersioningIntent( + v == 1 + ? io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE + : io.temporal.common.VersioningIntent.VERSIONING_INTENT_DEFAULT) + .setStaticSummary("summary-" + v) + .setStaticDetails("details-" + v) + .setPriority(Priority.newBuilder().setPriorityKey(v).build()) + .build(); + } + + private static class TestContextPropagator implements ContextPropagator { + private final String name; + + TestContextPropagator(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public Map serializeContext(Object context) { + return Collections.emptyMap(); + } + + @Override + public Object deserializeContext(Map context) { + return null; + } + + @Override + public Object getCurrentContext() { + return null; + } + + @Override + public void setCurrentContext(Object context) {} + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java new file mode 100644 index 000000000..a1a66a5ce --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java @@ -0,0 +1,199 @@ +package io.temporal.workflow; + +import static org.junit.Assert.assertEquals; + +import io.temporal.client.WorkflowOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkflowImplementationOptions; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that the {@link ChildWorkflowOptions} configured on {@link + * WorkflowImplementationOptions} are actually applied to child workflows created through both typed + * and untyped stubs, and that the precedence between the per-type options ({@link + * WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)}), the default options ({@link + * WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions}) and the options passed to + * the stub creation method is correct. + * + *

Each scenario is verified by reading the child's memo from inside the child workflow, so a + * test only passes if the expected options object was the one that actually took effect. + */ +public class DefaultChildWorkflowOptionsSetOnWorkflowTest { + + private static final String MEMO_KEY = "optionsSource"; + private static final Duration DEFAULT_RUN_TIMEOUT = Duration.ofSeconds(30); + + /** Lowest precedence options applied to every child workflow. */ + private static final ChildWorkflowOptions defaultChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setWorkflowRunTimeout(DEFAULT_RUN_TIMEOUT) + .setMemo(Collections.singletonMap(MEMO_KEY, "default")) + .build(); + + /** Per-type options applied only to the {@link PerTypeChild} workflow type. */ + private static final ChildWorkflowOptions perTypeChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setMemo(Collections.singletonMap(MEMO_KEY, "perType")) + .build(); + + /** Highest precedence options, passed explicitly to the stub creation methods. */ + private static final ChildWorkflowOptions explicitChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setMemo(Collections.singletonMap(MEMO_KEY, "explicit")) + .build(); + + private static final Map childWorkflowOptionsMap = + Collections.singletonMap("PerTypeChild", perTypeChildWorkflowOptions); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultChildWorkflowOptions) + .setChildWorkflowOptions(childWorkflowOptionsMap) + .build(), + ParentWorkflowImpl.class, + PerTypeChildImpl.class, + DefaultChildImpl.class) + .build(); + + /** + * Verifies the predefined options applied when no explicit options are passed to the stub + * creation method: the per-type options must win over the default options, the default options + * must be applied to types without a per-type entry, and fields the per-type options do not set + * must fall back to the default options. Untyped stubs must behave the same as typed stubs. + */ + @Test + public void testPredefinedOptionsApplied() { + Map report = runParent(); + + // Typed stubs. + assertEquals("perType", report.get("perType.memo")); + assertEquals("default", report.get("default.memo")); + // The per-type options only set the memo; other fields fall back to the default options. + assertEquals(DEFAULT_RUN_TIMEOUT.toString(), report.get("perType.runTimeout")); + + // Untyped stubs. + assertEquals("perType", report.get("untypedPerType.memo")); + assertEquals("default", report.get("untypedDefault.memo")); + } + + /** + * Verifies that the options passed to the stub creation method have the highest precedence over + * both the per-type options and the default options, while fields they do not set still fall back + * through the per-type options to the default options. + */ + @Test + public void testExplicitOptionsTakePrecedence() { + Map report = runParent(); + + assertEquals("explicit", report.get("explicitOverDefault.memo")); + assertEquals("explicit", report.get("explicitOverPerType.memo")); + assertEquals("explicit", report.get("untypedExplicit.memo")); + // The explicit options only set the memo; other fields fall back to the default options. + assertEquals(DEFAULT_RUN_TIMEOUT.toString(), report.get("explicitOverPerType.runTimeout")); + } + + private Map runParent() { + ParentWorkflow parent = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + ParentWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.getTaskQueue()).build()); + return parent.execute(); + } + + @WorkflowInterface + public interface ParentWorkflow { + @WorkflowMethod + Map execute(); + } + + @WorkflowInterface + public interface PerTypeChild { + @WorkflowMethod + Map execute(); + } + + @WorkflowInterface + public interface DefaultChild { + @WorkflowMethod + Map execute(); + } + + public static class ParentWorkflowImpl implements ParentWorkflow { + @Override + @SuppressWarnings("unchecked") + public Map execute() { + Map report = new HashMap<>(); + + // No explicit options: PerTypeChild has per-type options, which must win over the default. + PerTypeChild perTypeChild = Workflow.newChildWorkflowStub(PerTypeChild.class); + prefix(report, "perType", perTypeChild.execute()); + + // No explicit options and no per-type entry: the default options must be applied. + DefaultChild defaultChild = Workflow.newChildWorkflowStub(DefaultChild.class); + prefix(report, "default", defaultChild.execute()); + + // Explicit options on a type without per-type options: explicit must win over the default. + DefaultChild explicitOverDefault = + Workflow.newChildWorkflowStub(DefaultChild.class, explicitChildWorkflowOptions); + prefix(report, "explicitOverDefault", explicitOverDefault.execute()); + + // Explicit options on a type that also has per-type options: explicit must win over both. + PerTypeChild explicitOverPerType = + Workflow.newChildWorkflowStub(PerTypeChild.class, explicitChildWorkflowOptions); + prefix(report, "explicitOverPerType", explicitOverPerType.execute()); + + // Untyped stubs must apply the predefined options the same way as typed stubs. + ChildWorkflowStub untypedPerType = Workflow.newUntypedChildWorkflowStub("PerTypeChild"); + prefix(report, "untypedPerType", untypedPerType.execute(Map.class)); + + ChildWorkflowStub untypedDefault = Workflow.newUntypedChildWorkflowStub("DefaultChild"); + prefix(report, "untypedDefault", untypedDefault.execute(Map.class)); + + ChildWorkflowStub untypedExplicit = + Workflow.newUntypedChildWorkflowStub("PerTypeChild", explicitChildWorkflowOptions); + prefix(report, "untypedExplicit", untypedExplicit.execute(Map.class)); + + return report; + } + + private static void prefix( + Map target, String prefix, Map childReport) { + for (Map.Entry entry : childReport.entrySet()) { + target.put(prefix + "." + entry.getKey(), entry.getValue()); + } + } + } + + public static class PerTypeChildImpl implements PerTypeChild { + @Override + public Map execute() { + return reportAppliedOptions(); + } + } + + public static class DefaultChildImpl implements DefaultChild { + @Override + public Map execute() { + return reportAppliedOptions(); + } + } + + /** Reports the options that were actually applied to the running (child) workflow. */ + private static Map reportAppliedOptions() { + Map report = new HashMap<>(); + Object memo = Workflow.getMemo(MEMO_KEY, String.class); + report.put("memo", memo == null ? "none" : memo.toString()); + report.put("runTimeout", String.valueOf(Workflow.getInfo().getWorkflowRunTimeout())); + return report; + } +}