From 948ecc8346d44cf7a9ddc802eb15c1470930a09e Mon Sep 17 00:00:00 2001 From: Oleksandr Porunov Date: Mon, 25 May 2026 22:02:56 +0100 Subject: [PATCH] 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 - Store and expose the options on SyncWorkflowContext - Add ChildWorkflowOptions.Builder#mergeChildWorkflowOptions() - Resolve the options in WorkflowInternal.newChildWorkflowStub() with the following precedence, highest to lowest, merged field by field: options passed to newChildWorkflowStub > per-type options (setChildWorkflowOptions) > default options (setDefaultChildWorkflowOptions) 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 contradictory javadocs on setChildWorkflowOptions and setDefaultChildWorkflowOptions now describe the actual precedence. Tests: - Rewrite DefaultChildWorkflowOptionsSetOnWorkflowTest to verify the applied options through the child's memo (covering default, per-type, explicit and field-level merge precedence), so a test only passes if the expected options actually took effect - Add an exhaustive unit test for ChildWorkflowOptions#mergeChildWorkflowOptions that exercises every field Signed-off-by: Oleksandr Porunov --- .../internal/sync/SyncWorkflowContext.java | 16 ++ .../internal/sync/WorkflowInternal.java | 25 ++ .../worker/WorkflowImplementationOptions.java | 62 +++++ .../workflow/ChildWorkflowOptions.java | 69 +++++ ...nsInWorkflowImplementationOptionsTest.java | 239 ++++++++++++++++++ ...ChildWorkflowOptionsSetOnWorkflowTest.java | 207 +++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java 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 1517c9257e..fd506deac4 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 @@ -100,6 +100,8 @@ final class SyncWorkflowContext implements WorkflowContext, WorkflowOutboundCall private Map localActivityOptionsMap; private NexusServiceOptions defaultNexusServiceOptions = null; private Map nexusServiceOptionsMap; + private ChildWorkflowOptions defaultChildWorkflowOptions = null; + private Map childWorkflowOptionsMap; private boolean readOnly = false; private final WorkflowThreadLocal currentUpdateInfo = new WorkflowThreadLocal<>(); @Nullable private String currentDetails; @@ -136,6 +138,10 @@ public SyncWorkflowContext( workflowImplementationOptions.getDefaultNexusServiceOptions(); this.nexusServiceOptionsMap = new HashMap<>(workflowImplementationOptions.getNexusServiceOptions()); + this.defaultChildWorkflowOptions = + workflowImplementationOptions.getDefaultChildWorkflowOptions(); + this.childWorkflowOptionsMap = + new HashMap<>(workflowImplementationOptions.getChildWorkflowOptions()); } this.workflowImplementationOptions = workflowImplementationOptions == null @@ -215,6 +221,16 @@ public NexusServiceOptions getDefaultNexusServiceOptions() { : Collections.emptyMap(); } + public ChildWorkflowOptions getDefaultChildWorkflowOptions() { + return defaultChildWorkflowOptions; + } + + public @Nonnull Map getChildWorkflowOptions() { + return childWorkflowOptionsMap != null + ? Collections.unmodifiableMap(childWorkflowOptionsMap) + : Collections.emptyMap(); + } + 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 79495694b4..7c1802d001 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,6 +391,31 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti @SuppressWarnings("unchecked") public static T newChildWorkflowStub( Class workflowInterface, ChildWorkflowOptions options) { + SyncWorkflowContext context = getRootWorkflowContext(); + + // Look up the per-type options predefined for this workflow type through + // WorkflowImplementationOptions.setChildWorkflowOptions(Map). + ChildWorkflowOptions perTypeOptions = null; + POJOWorkflowInterfaceMetadata workflowMetadata = + POJOWorkflowInterfaceMetadata.newInstance(workflowInterface); + Optional workflowMethodMetadata = + workflowMetadata.getWorkflowMethod(); + if (workflowMethodMetadata.isPresent()) { + String workflowType = workflowMethodMetadata.get().getName(); + perTypeOptions = context.getChildWorkflowOptions().get(workflowType); + } + + ChildWorkflowOptions defaultOptions = context.getDefaultChildWorkflowOptions(); + if (defaultOptions != null || perTypeOptions != null) { + // Precedence from lowest to highest: default options < per-type options < the options passed + // to this method. Each layer overrides only the non-null fields of the layers below it. + options = + ChildWorkflowOptions.newBuilder(defaultOptions) + .mergeChildWorkflowOptions(perTypeOptions) + .mergeChildWorkflowOptions(options) + .build(); + } + return (T) Proxy.newProxyInstance( workflowInterface.getClassLoader(), 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 c2d0aa033c..b8120d46f2 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,37 @@ public Builder setDefaultNexusServiceOptions(NexusServiceOptions defaultNexusSer return this; } + /** + * Set individual child workflow options per workflow type. 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 {@link + * io.temporal.workflow.Workflow#newChildWorkflowStub(Class, ChildWorkflowOptions)}, which have + * the highest precedence. + * + * @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. + * 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 {@link + * io.temporal.workflow.Workflow#newChildWorkflowStub(Class, ChildWorkflowOptions)}, which have + * the highest precedence. + * + * @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 +223,8 @@ public WorkflowImplementationOptions build() { defaultLocalActivityOptions, nexusServiceOptions == null ? null : nexusServiceOptions, defaultNexusServiceOptions, + childWorkflowOptions == null ? null : childWorkflowOptions, + defaultChildWorkflowOptions, enableUpsertVersionSearchAttributes); } } @@ -198,6 +236,8 @@ 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; public WorkflowImplementationOptions( @@ -208,6 +248,8 @@ public WorkflowImplementationOptions( LocalActivityOptions defaultLocalActivityOptions, @Nullable Map nexusServiceOptions, NexusServiceOptions defaultNexusServiceOptions, + @Nullable Map childWorkflowOptions, + ChildWorkflowOptions defaultChildWorkflowOptions, boolean enableUpsertVersionSearchAttributes) { this.failWorkflowExceptionTypes = failWorkflowExceptionTypes; this.activityOptions = activityOptions; @@ -216,6 +258,8 @@ public WorkflowImplementationOptions( this.defaultLocalActivityOptions = defaultLocalActivityOptions; this.nexusServiceOptions = nexusServiceOptions; this.defaultNexusServiceOptions = defaultNexusServiceOptions; + this.childWorkflowOptions = childWorkflowOptions; + this.defaultChildWorkflowOptions = defaultChildWorkflowOptions; this.enableUpsertVersionSearchAttributes = enableUpsertVersionSearchAttributes; } @@ -253,6 +297,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 +329,10 @@ public String toString() { + nexusServiceOptions + ", defaultNexusServiceOptions=" + defaultNexusServiceOptions + + ", childWorkflowOptions=" + + childWorkflowOptions + + ", defaultChildWorkflowOptions=" + + defaultChildWorkflowOptions + ", enableUpsertVersionSearchAttributes=" + enableUpsertVersionSearchAttributes + '}'; @@ -292,6 +350,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 +366,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 261cd97247..0bdf087f63 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java @@ -338,6 +338,75 @@ 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. + * + * @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(); + this.searchAttributes = + (override.getSearchAttributes() == null) + ? this.searchAttributes + : override.getSearchAttributes(); + this.typedSearchAttributes = + (override.getTypedSearchAttributes() == null) + ? this.typedSearchAttributes + : override.getTypedSearchAttributes(); + this.contextPropagators = + (override.getContextPropagators() == null) + ? this.contextPropagators + : override.getContextPropagators(); + this.cancellationType = + (override.getCancellationType() == null) + ? this.cancellationType + : override.getCancellationType(); + this.versioningIntent = + (override.getVersioningIntent() == null) + ? 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/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java new file mode 100644 index 0000000000..a70015169c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java @@ -0,0 +1,239 @@ +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.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(); + + // Override takes precedence for workflowExecutionTimeout + assertEquals(Duration.ofSeconds(200), merged.getWorkflowExecutionTimeout()); + // Base values are preserved for fields not set in 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 {@code merge(base, override)} can only equal {@code override} if each field is + * merged from the correct getter, and {@code merge(base, empty)} can only equal {@code base} if + * no field is dropped. + */ + @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. + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(optionsA).mergeChildWorkflowOptions(optionsB).build(); + assertEquals(optionsB, 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 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 0000000000..32c268d9f9 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java @@ -0,0 +1,207 @@ +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, and that the precedence + * between the per-type options ({@link + * WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)}) and the default options + * ({@link WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions}) 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 {@link Workflow#newChildWorkflowStub}. */ + 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(); + + /** + * The {@link PerTypeChild} type has per-type options configured, so they must win over the + * default options. With the buggy implementation the default options override the per-type + * options, so the memo would be {@code "default"} instead of {@code "perType"}. + */ + @Test + public void testPerTypeChildWorkflowOptionsOverrideDefault() { + Map report = runParent(); + assertEquals("perType", report.get("perType.memo")); + } + + /** + * The {@link DefaultChild} type has no per-type entry, so the default options must be applied to + * it. + */ + @Test + public void testDefaultChildWorkflowOptionsAppliedWhenNoPerTypeMatch() { + Map report = runParent(); + assertEquals("default", report.get("default.memo")); + } + + /** + * Per-type options only set the memo; every other field must fall back to the default options. + * This proves the per-type options are merged on top of the default options field-by-field rather + * than replacing them wholesale. + */ + @Test + public void testPerTypeOptionsMergedWithDefaultForUnsetFields() { + Map report = runParent(); + assertEquals("perType", report.get("perType.memo")); + assertEquals(DEFAULT_RUN_TIMEOUT.toString(), report.get("perType.runTimeout")); + } + + /** Explicit options passed to the stub must win over the default options. */ + @Test + public void testExplicitOptionsOverrideDefault() { + Map report = runParent(); + assertEquals("explicit", report.get("explicitOverDefault.memo")); + } + + /** Explicit options passed to the stub must win even over the per-type options. */ + @Test + public void testExplicitOptionsOverridePerType() { + Map report = runParent(); + assertEquals("explicit", report.get("explicitOverPerType.memo")); + } + + /** + * Explicit options only set the memo; every other field must still fall back through the per-type + * options to the default options. + */ + @Test + public void testExplicitOptionsMergedWithLowerPrecedenceForUnsetFields() { + Map report = runParent(); + assertEquals("explicit", report.get("explicitOverPerType.memo")); + 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 + 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()); + + 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; + } +}