diff --git a/CHANGELOG.md b/CHANGELOG.md index 160e790f..cee91be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Unreleased +* Add `getParentInstance()` API to `TaskOrchestrationContext` for discovering parent orchestration info ([#284](https://github.com/microsoft/durabletask-java/pull/284)) ## v1.9.0 * Fix entity locking deserialization and add Jackson support for EntityInstanceId/EntityMetadata ([#281](https://github.com/microsoft/durabletask-java/pull/281)) diff --git a/client/src/main/java/com/microsoft/durabletask/ParentOrchestrationInstance.java b/client/src/main/java/com/microsoft/durabletask/ParentOrchestrationInstance.java new file mode 100644 index 00000000..693d7665 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ParentOrchestrationInstance.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * Represents the parent orchestration of a sub-orchestration. + * This is available via {@link TaskOrchestrationContext#getParentInstance()} when the + * current orchestration was started as a sub-orchestration. + */ +public final class ParentOrchestrationInstance { + private final String name; + private final String instanceId; + + /** + * Creates a new ParentOrchestrationInstance. + * + * @param name the name of the parent orchestration + * @param instanceId the instance ID of the parent orchestration + */ + public ParentOrchestrationInstance(@Nonnull String name, @Nonnull String instanceId) { + this.name = Objects.requireNonNull(name, "name"); + this.instanceId = Objects.requireNonNull(instanceId, "instanceId"); + } + + /** + * Gets the name of the parent orchestration. + * + * @return the parent orchestration name + */ + @Nonnull + public String getName() { + return this.name; + } + + /** + * Gets the instance ID of the parent orchestration. + * + * @return the parent orchestration instance ID + */ + @Nonnull + public String getInstanceId() { + return this.instanceId; + } + + @Override + public String toString() { + return String.format("ParentOrchestrationInstance{name='%s', instanceId='%s'}", name, instanceId); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ParentOrchestrationInstance)) return false; + ParentOrchestrationInstance that = (ParentOrchestrationInstance) o; + return Objects.equals(name, that.name) && Objects.equals(instanceId, that.instanceId); + } + + @Override + public int hashCode() { + return Objects.hash(name, instanceId); + } +} diff --git a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java index e94acde4..1ae60612 100644 --- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java +++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java @@ -794,6 +794,19 @@ default Task lockEntities(@Nonnull EntityInstanceId... entityIds) */ void clearCustomStatus(); + /** + * Gets the parent orchestration instance, or {@code null} if this orchestration + * was not started as a sub-orchestration. + *

+ * Implementers that wrap or decorate another {@link TaskOrchestrationContext} + * must delegate to the wrapped instance. Returning {@code null} unconditionally + * will incorrectly report sub-orchestrations as standalone. + * + * @return the parent orchestration instance, or {@code null} + */ + @Nullable + ParentOrchestrationInstance getParentInstance(); + /** * Makes a durable HTTP request using the specified {@link DurableHttpRequest} and returns a {@link Task} * that completes when the HTTP call completes. diff --git a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java index 8e60080e..5bdbad2e 100644 --- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java +++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java @@ -146,6 +146,7 @@ private class ContextImplTask implements TaskOrchestrationContext { private boolean preserveUnprocessedEvents; private Object customStatus; private TraceContext parentTraceContext; + private ParentOrchestrationInstance parentInstance; // Entity integration state (Phase 4) private String executionId; @@ -207,6 +208,12 @@ public String getInstanceId() { return this.instanceId; } + @Override + @Nullable + public ParentOrchestrationInstance getParentInstance() { + return this.parentInstance; + } + private void setInstanceId(String instanceId) { // TODO: Throw if instance ID is not null this.instanceId = instanceId; @@ -1705,6 +1712,16 @@ private void processEvent(HistoryEvent e) { } else { this.parentTraceContext = null; } + if (startedEvent.hasParentInstance()) { + ParentInstanceInfo parentInfo = startedEvent.getParentInstance(); + String parentName = parentInfo.hasName() ? parentInfo.getName().getValue() : ""; + String parentInstanceId = parentInfo.hasOrchestrationInstance() + ? parentInfo.getOrchestrationInstance().getInstanceId() + : ""; + this.parentInstance = new ParentOrchestrationInstance(parentName, parentInstanceId); + } else { + this.parentInstance = null; + } TaskOrchestrationFactory factory = TaskOrchestrationExecutor.this.orchestrationFactories.get(name); if (factory == null) { // Try getting the default orchestrator diff --git a/client/src/test/java/com/microsoft/durabletask/EntityProxyTest.java b/client/src/test/java/com/microsoft/durabletask/EntityProxyTest.java index 0bfb2ca8..3aae5204 100644 --- a/client/src/test/java/com/microsoft/durabletask/EntityProxyTest.java +++ b/client/src/test/java/com/microsoft/durabletask/EntityProxyTest.java @@ -128,6 +128,9 @@ static class RecordingContext implements TaskOrchestrationContext { @Override public String getInstanceId() { return "test-instance"; } + @Override + public ParentOrchestrationInstance getParentInstance() { return null; } + @Override public Instant getCurrentInstant() { return Instant.now(); } diff --git a/client/src/test/java/com/microsoft/durabletask/TaskOrchestrationExecutorTest.java b/client/src/test/java/com/microsoft/durabletask/TaskOrchestrationExecutorTest.java index 52c2e887..ce21dd9d 100644 --- a/client/src/test/java/com/microsoft/durabletask/TaskOrchestrationExecutorTest.java +++ b/client/src/test/java/com/microsoft/durabletask/TaskOrchestrationExecutorTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.Test; import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; import java.util.*; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -285,4 +287,404 @@ public TaskOrchestration create() { assertFalse(scheduleAction.getScheduleTask().hasParentTraceContext(), "ScheduleTaskAction should not have parentTraceContext when none was provided"); } + + // region Parent Instance Tests + + @Test + void execute_withParentInstance_getParentInstanceReturnsParent() { + // Arrange: orchestration that captures getParentInstance() + String orchName = "ChildOrch"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("child-instance") + .build()) + .setParentInstance(ParentInstanceInfo.newBuilder() + .setName(StringValue.of("ParentOrch")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("parent-123") + .build()) + .build()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act + executor.execute(Collections.emptyList(), newEvents, null); + + // Assert + assertNotNull(captured[0], "getParentInstance() should not be null for a sub-orchestration"); + assertEquals("ParentOrch", captured[0].getName()); + assertEquals("parent-123", captured[0].getInstanceId()); + } + + @Test + void execute_withoutParentInstance_getParentInstanceReturnsNull() { + // Arrange: orchestration without parent instance + String orchName = "StandaloneOrch"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + boolean[] wasCalled = {false}; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + wasCalled[0] = true; + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("standalone-instance") + .build()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act + executor.execute(Collections.emptyList(), newEvents, null); + + // Assert + assertTrue(wasCalled[0], "Orchestrator should have been called"); + assertNull(captured[0], "getParentInstance() should be null for a standalone orchestration"); + } + + @Test + void execute_withParentInstance_preservesExactValues() { + // Arrange: use mixed casing and special characters + String orchName = "ChildOrch"; + String parentName = "Parent.Orch-V2"; + String parentInstanceId = "abc-DEF-123_special!@#"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("child-instance") + .build()) + .setParentInstance(ParentInstanceInfo.newBuilder() + .setName(StringValue.of(parentName)) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId(parentInstanceId) + .build()) + .build()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act + executor.execute(Collections.emptyList(), newEvents, null); + + // Assert: values must match exactly, no normalization + assertNotNull(captured[0]); + assertEquals(parentName, captured[0].getName()); + assertEquals(parentInstanceId, captured[0].getInstanceId()); + } + + @Test + void execute_withParentInstance_emptyFields_acceptsValues() { + // Arrange: parent instance with empty/default StringValue fields + String orchName = "ChildOrch"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("child-instance") + .build()) + .setParentInstance(ParentInstanceInfo.newBuilder() + .setName(StringValue.of("")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("") + .build()) + .build()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act + executor.execute(Collections.emptyList(), newEvents, null); + + // Assert: permissive — empty values accepted as-is, matching .NET behavior + assertNotNull(captured[0], "getParentInstance() should not be null when parentInstance is present"); + assertEquals("", captured[0].getName()); + assertEquals("", captured[0].getInstanceId()); + } + + @Test + void execute_withParentInstance_unsetProtoFields_defaultsToEmpty() { + // Arrange: ParentInstanceInfo is present, but its inner fields + // (name StringValue and OrchestrationInstance) are unset. + // The executor must not NPE — it should default to empty strings. + String orchName = "ChildOrch"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("child-instance") + .build()) + // ParentInstanceInfo present but inner fields unset (no setName, no setOrchestrationInstance) + .setParentInstance(ParentInstanceInfo.getDefaultInstance()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act — must not throw NPE + executor.execute(Collections.emptyList(), newEvents, null); + + // Assert: parent is non-null, fields default to empty + assertNotNull(captured[0], "getParentInstance() should not be null when ParentInstanceInfo is present"); + assertEquals("", captured[0].getName()); + assertEquals("", captured[0].getInstanceId()); + } + + @Test + void executorReplay_parentValueStableAcrossReplays() { + // Arrange: simulate replay — ExecutionStartedEvent is in pastEvents, + // new dispatch has OrchestratorStarted + OrchestratorCompleted. + String orchName = "ChildOrch"; + ParentOrchestrationInstance[] captured = new ParentOrchestrationInstance[1]; + + HashMap factories = new HashMap<>(); + factories.put(orchName, new TaskOrchestrationFactory() { + @Override + public String getName() { return orchName; } + @Override + public TaskOrchestration create() { + return ctx -> { + captured[0] = ctx.getParentInstance(); + ctx.complete("done"); + }; + } + }); + + TaskOrchestrationExecutor executor = new TaskOrchestrationExecutor( + factories, new JacksonDataConverter(), Duration.ofDays(3), logger, null); + + // Past events: the initial execution (already processed) + List pastEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setExecutionStarted(ExecutionStartedEvent.newBuilder() + .setName(orchName) + .setVersion(StringValue.of("")) + .setInput(StringValue.of("\"test\"")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("child-instance") + .build()) + .setParentInstance(ParentInstanceInfo.newBuilder() + .setName(StringValue.of("ParentOrch")) + .setOrchestrationInstance(OrchestrationInstance.newBuilder() + .setInstanceId("parent-456") + .build()) + .build()) + .build()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // New events: a new dispatch re-enters the orchestrator + List newEvents = Arrays.asList( + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorStarted(OrchestratorStartedEvent.getDefaultInstance()) + .build(), + HistoryEvent.newBuilder() + .setEventId(-1) + .setTimestamp(Timestamp.getDefaultInstance()) + .setOrchestratorCompleted(OrchestratorCompletedEvent.getDefaultInstance()) + .build() + ); + + // Act: execute with replay history + executor.execute(pastEvents, newEvents, null); + + // Assert: parent is still available and unchanged during replay + assertNotNull(captured[0], "getParentInstance() should not be null during replay"); + assertEquals("ParentOrch", captured[0].getName()); + assertEquals("parent-456", captured[0].getInstanceId()); + } + + @Test + void parentOrchestrationInstance_equalsAndHashCode() { + ParentOrchestrationInstance a = new ParentOrchestrationInstance("Orch", "id-1"); + ParentOrchestrationInstance b = new ParentOrchestrationInstance("Orch", "id-1"); + ParentOrchestrationInstance c = new ParentOrchestrationInstance("Other", "id-1"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + assertNotEquals(a, null); + assertEquals("ParentOrchestrationInstance{name='Orch', instanceId='id-1'}", a.toString()); + } + + // endregion } diff --git a/samples/build.gradle b/samples/build.gradle index 877afa29..e87347cb 100644 --- a/samples/build.gradle +++ b/samples/build.gradle @@ -88,6 +88,12 @@ task runTypedEntityProxySample(type: JavaExec) { environment dtsEnv } +task runParentInstanceSample(type: JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.durabletask.samples.ParentInstanceSample' + environment dtsEnv +} + task printClasspath { doLast { println sourceSets.main.runtimeClasspath.asPath diff --git a/samples/src/main/java/io/durabletask/samples/ParentInstanceSample.java b/samples/src/main/java/io/durabletask/samples/ParentInstanceSample.java new file mode 100644 index 00000000..ed92a203 --- /dev/null +++ b/samples/src/main/java/io/durabletask/samples/ParentInstanceSample.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package io.durabletask.samples; + +import com.microsoft.durabletask.*; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeoutException; + +/** + * Demonstrates {@link TaskOrchestrationContext#getParentInstance()}: the child orchestration + * reports its parent when started as a sub-orchestration, and reports {@code null} when started + * standalone. + * + *

Run with: {@code ./gradlew runParentInstanceSample} + */ +final class ParentInstanceSample { + public static void main(String[] args) throws IOException, InterruptedException, TimeoutException { + DurableTaskGrpcWorkerBuilder workerBuilder = SampleUtils.newWorkerBuilder(); + + // Parent orchestration: starts the child as a sub-orchestration. + workerBuilder.addOrchestration(new TaskOrchestrationFactory() { + @Override + public String getName() { return "ParentOrchestrator"; } + + @Override + public TaskOrchestration create() { + return ctx -> { + log(ctx, "Starting child sub-orchestration..."); + String childResult = ctx.callSubOrchestrator( + "ChildOrchestrator", null, String.class).await(); + log(ctx, "Child returned: " + childResult); + ctx.complete(childResult); + }; + } + }); + + // Child orchestration: reads getParentInstance() directly on the context. + workerBuilder.addOrchestration(new TaskOrchestrationFactory() { + @Override + public String getName() { return "ChildOrchestrator"; } + + @Override + public TaskOrchestration create() { + return ctx -> { + String result = describeParent(ctx.getParentInstance()); + log(ctx, result); + ctx.complete(result); + }; + } + }); + + DurableTaskGrpcWorker worker = workerBuilder.build(); + worker.start(); + + DurableTaskClient client = SampleUtils.newClientBuilder().build(); + + // Step 1: Start the parent. The child observes its parent via getParentInstance(). + System.out.println("=== Step 1: Sub-orchestration (child has parent) ==="); + String parentId = client.scheduleNewOrchestrationInstance("ParentOrchestrator"); + System.out.println(" Scheduled ParentOrchestrator: " + parentId); + OrchestrationMetadata result1 = client.waitForInstanceCompletion( + parentId, Duration.ofSeconds(30), true); + System.out.println(" Status: " + result1.getRuntimeStatus()); + if (result1.getRuntimeStatus() == OrchestrationRuntimeStatus.COMPLETED) { + System.out.println(" Result: " + result1.readOutputAs(String.class)); + } else { + System.out.println(" Failure: " + result1.getFailureDetails()); + } + + // Step 2: Start the child directly. getParentInstance() returns null. + System.out.println("\n=== Step 2: Standalone orchestration (no parent) ==="); + String standaloneId = client.scheduleNewOrchestrationInstance("ChildOrchestrator"); + System.out.println(" Scheduled ChildOrchestrator standalone: " + standaloneId); + OrchestrationMetadata result2 = client.waitForInstanceCompletion( + standaloneId, Duration.ofSeconds(30), true); + System.out.println(" Result: " + result2.readOutputAs(String.class)); + + System.out.println("\n=== Sample completed ==="); + worker.stop(); + } + + /** Returns a string describing the given parent instance, or that there is none. */ + private static String describeParent(@Nullable ParentOrchestrationInstance parent) { + if (parent != null) { + return String.format("I was called by '%s' (instance: %s)", + parent.getName(), parent.getInstanceId()); + } + return "No parent — I was started standalone"; + } + + /** Replay-safe console log. */ + private static void log(TaskOrchestrationContext ctx, String message) { + if (!ctx.getIsReplaying()) { + System.out.println(" [" + ctx.getName() + "] " + message); + } + } +}