diff --git a/src/DurableTask.Core/Entities/OperationFormat/StartNewOrchestrationOperationAction.cs b/src/DurableTask.Core/Entities/OperationFormat/StartNewOrchestrationOperationAction.cs
index 29aca841b..08d7868d9 100644
--- a/src/DurableTask.Core/Entities/OperationFormat/StartNewOrchestrationOperationAction.cs
+++ b/src/DurableTask.Core/Entities/OperationFormat/StartNewOrchestrationOperationAction.cs
@@ -48,6 +48,15 @@ public class StartNewOrchestrationOperationAction : OperationAction
///
public string? Input { get; set; }
+ ///
+ /// Gets or sets the tags of the sub-orchestration.
+ ///
+ ///
+ /// These tags override tags inherited from the entity. The runtime always sets the
+ /// tag.
+ ///
+ public IDictionary? Tags { get; set; }
+
///
/// Gets or sets when to start the orchestration, or null if the orchestration should be started immediately.
///
diff --git a/src/DurableTask.Core/TaskEntityDispatcher.cs b/src/DurableTask.Core/TaskEntityDispatcher.cs
index a3d163e28..91f6e005b 100644
--- a/src/DurableTask.Core/TaskEntityDispatcher.cs
+++ b/src/DurableTask.Core/TaskEntityDispatcher.cs
@@ -912,7 +912,9 @@ internal void ProcessSendStartMessage(WorkItemEffects effects, OrchestrationRunt
{
Tags = OrchestrationTags.MergeTags(
newTags: new Dictionary() { { OrchestrationTags.FireAndForget, "" } },
- existingTags: runtimeState.Tags),
+ existingTags: OrchestrationTags.MergeTags(
+ newTags: action.Tags,
+ existingTags: runtimeState.Tags)),
OrchestrationInstance = destination,
ScheduledStartTime = action.ScheduledStartTime,
ParentInstance = new ParentInstance
diff --git a/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs b/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs
index 6128c0465..87de65202 100644
--- a/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs
+++ b/test/DurableTask.Core.Tests/TestTaskEntityDispatcher.cs
@@ -3,11 +3,19 @@
using DurableTask.Core.History;
using DurableTask.Core.Logging;
using DurableTask.Core.Middleware;
+using DurableTask.Core.Settings;
+using DurableTask.Core.Tracing;
using DurableTask.Emulator;
using DurableTask.Test.Orchestrations;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
using static DurableTask.Core.TaskEntityDispatcher;
namespace DurableTask.Core.Tests
@@ -19,11 +27,10 @@ public class TestTaskEntityDispatcher
/// Utiliy function to create a TaskEntityDispatcher instance. To be expanded upon as per testing needs.
///
///
- private TaskEntityDispatcher GetTaskEntityDispatcher()
+ private TaskEntityDispatcher GetTaskEntityDispatcher(IOrchestrationService service)
{
// TODO: these should probably be injectable parameters to this method,
// initialized with sensible defaults if not provided
- var service = new LocalOrchestrationService();
ILoggerFactory loggerFactory = null;
var entityManager = new NameVersionObjectManager();
var entityMiddleware = new DispatchMiddlewarePipeline();
@@ -46,7 +53,8 @@ private TaskEntityDispatcher GetTaskEntityDispatcher()
[TestMethod]
public void TestEntityDoesNotSetFireAndForgetTags()
{
- TaskEntityDispatcher dispatcher = GetTaskEntityDispatcher();
+ using var service = new LocalOrchestrationService();
+ TaskEntityDispatcher dispatcher = GetTaskEntityDispatcher(service);
// Prepare effects
var effects = new WorkItemEffects();
@@ -84,5 +92,402 @@ public void TestEntityDoesNotSetFireAndForgetTags()
bool hasFireAndForgetTag = executionStartedEvent.Tags.ContainsKey(OrchestrationTags.FireAndForget);
Assert.IsTrue(hasFireAndForgetTag);
}
+
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public void StartNewOrchestrationOperationAction_Tags_SurviveSerialization(bool useSystemTextJson)
+ {
+ string json = JsonConvert.SerializeObject(new
+ {
+ OperationActionType = OperationActionType.StartNewOrchestration,
+ Tags = new Dictionary { { "custom", "value" } },
+ });
+
+ StartNewOrchestrationOperationAction action = useSystemTextJson
+ ? System.Text.Json.JsonSerializer.Deserialize(json)
+ : JsonConvert.DeserializeObject(json);
+ string roundTripped = useSystemTextJson
+ ? System.Text.Json.JsonSerializer.Serialize(action)
+ : JsonConvert.SerializeObject(action);
+
+ Assert.AreEqual("value", JObject.Parse(roundTripped)["Tags"]?["custom"]?.Value());
+ }
+
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public void StartNewOrchestrationOperationAction_MissingTags_DefaultToNull(bool useSystemTextJson)
+ {
+ string json = JsonConvert.SerializeObject(new
+ {
+ OperationActionType = OperationActionType.StartNewOrchestration,
+ });
+
+ StartNewOrchestrationOperationAction action = useSystemTextJson
+ ? System.Text.Json.JsonSerializer.Deserialize(json)
+ : JsonConvert.DeserializeObject(json);
+
+ Assert.IsNull(new StartNewOrchestrationOperationAction().Tags);
+ Assert.IsNull(action.Tags);
+ }
+
+ [TestMethod]
+ public void ProcessSendStartMessage_SuppliedTags_AreIncluded()
+ {
+ var action = new StartNewOrchestrationOperationAction
+ {
+ Tags = new Dictionary { { "custom", "value" } },
+ };
+
+ var (startEvent, _) = SendStartMessage(CreateEntityRuntimeState(), action);
+
+ CollectionAssert.AreEquivalent(
+ new Dictionary
+ {
+ { "custom", "value" },
+ { OrchestrationTags.FireAndForget, "" },
+ }.ToArray(),
+ startEvent.Tags.ToArray());
+ }
+
+ [TestMethod]
+ public void ProcessSendStartMessage_SuppliedTags_OverrideInheritedTags()
+ {
+ var runtimeState = CreateEntityRuntimeState(new Dictionary
+ {
+ { "shared", "entity-value" },
+ { "inherited", "entity-only" },
+ { OrchestrationTags.TraceState, "inherited-trace-state" },
+ });
+ var action = new StartNewOrchestrationOperationAction
+ {
+ Tags = new Dictionary
+ {
+ { "shared", "child-value" },
+ { "custom", "caller-value" },
+ },
+ };
+
+ var (startEvent, _) = SendStartMessage(runtimeState, action);
+
+ CollectionAssert.AreEquivalent(
+ new Dictionary
+ {
+ { "shared", "child-value" },
+ { "inherited", "entity-only" },
+ { "custom", "caller-value" },
+ { OrchestrationTags.TraceState, "inherited-trace-state" },
+ { OrchestrationTags.FireAndForget, "" },
+ }.ToArray(),
+ startEvent.Tags.ToArray());
+ }
+
+ [DataTestMethod]
+ [DataRow(false, false)]
+ [DataRow(false, true)]
+ [DataRow(true, false)]
+ [DataRow(true, true)]
+ public void ProcessSendStartMessage_NullOrEmptyTags_PreserveDefaults(bool hasInheritedTags, bool useEmptyTags)
+ {
+ IDictionary inheritedTags = hasInheritedTags
+ ? new Dictionary
+ {
+ { "inherited", "entity-value" },
+ { OrchestrationTags.FireAndForget, "parent-value" },
+ }
+ : null;
+ var runtimeState = CreateEntityRuntimeState(inheritedTags);
+ var action = new StartNewOrchestrationOperationAction
+ {
+ Tags = useEmptyTags ? new Dictionary() : null,
+ };
+ var expectedTags = new Dictionary { { OrchestrationTags.FireAndForget, "" } };
+ if (hasInheritedTags)
+ {
+ expectedTags.Add("inherited", "entity-value");
+ }
+
+ var (startEvent, _) = SendStartMessage(runtimeState, action);
+
+ CollectionAssert.AreEquivalent(expectedTags.ToArray(), startEvent.Tags.ToArray());
+ Assert.AreSame(inheritedTags, runtimeState.Tags);
+ if (hasInheritedTags)
+ {
+ Assert.AreEqual("parent-value", inheritedTags[OrchestrationTags.FireAndForget]);
+ Assert.AreEqual(2, inheritedTags.Count);
+ }
+ if (useEmptyTags)
+ {
+ Assert.AreEqual(0, action.Tags.Count);
+ }
+ }
+
+ [TestMethod]
+ public void ProcessSendStartMessage_FireAndForgetTag_CannotBeOverridden()
+ {
+ var runtimeState = CreateEntityRuntimeState(new Dictionary
+ {
+ { OrchestrationTags.FireAndForget, "parent-value" },
+ { "inherited", "entity-value" },
+ });
+ var action = new StartNewOrchestrationOperationAction
+ {
+ Tags = new Dictionary
+ {
+ { OrchestrationTags.FireAndForget, "false" },
+ { "custom", "caller-value" },
+ },
+ };
+
+ var (startEvent, _) = SendStartMessage(runtimeState, action);
+
+ Assert.AreEqual("", startEvent.Tags[OrchestrationTags.FireAndForget]);
+ Assert.IsTrue(startEvent.Tags.ContainsKey("custom"));
+ Assert.AreEqual("caller-value", startEvent.Tags["custom"]);
+ Assert.AreEqual("entity-value", startEvent.Tags["inherited"]);
+ Assert.AreEqual("false", action.Tags[OrchestrationTags.FireAndForget]);
+ Assert.AreEqual("parent-value", runtimeState.Tags[OrchestrationTags.FireAndForget]);
+ }
+
+ [TestMethod]
+ public void ProcessSendStartMessage_DoesNotMutateSourceTags()
+ {
+ var inheritedTags = new Dictionary
+ {
+ { "inherited", "entity-value" },
+ { "shared", "entity-value" },
+ };
+ var suppliedTags = new Dictionary
+ {
+ { "custom", "caller-value" },
+ { "shared", "caller-value" },
+ };
+ var inheritedSnapshot = inheritedTags.ToArray();
+ var suppliedSnapshot = suppliedTags.ToArray();
+ var runtimeState = CreateEntityRuntimeState(inheritedTags);
+ var action = new StartNewOrchestrationOperationAction { Tags = suppliedTags };
+
+ var (startEvent, _) = SendStartMessage(runtimeState, action);
+
+ Assert.AreSame(inheritedTags, runtimeState.Tags);
+ Assert.AreSame(suppliedTags, action.Tags);
+ Assert.AreNotSame(inheritedTags, startEvent.Tags);
+ Assert.AreNotSame(suppliedTags, startEvent.Tags);
+ CollectionAssert.AreEquivalent(inheritedSnapshot, inheritedTags.ToArray());
+ CollectionAssert.AreEquivalent(suppliedSnapshot, suppliedTags.ToArray());
+
+ startEvent.Tags["shared"] = "changed-child-value";
+ startEvent.Tags["child-only"] = "value";
+ CollectionAssert.AreEquivalent(inheritedSnapshot, inheritedTags.ToArray());
+ CollectionAssert.AreEquivalent(suppliedSnapshot, suppliedTags.ToArray());
+ }
+
+ [DataTestMethod]
+ [DataRow(false, false)]
+ [DataRow(false, true)]
+ [DataRow(true, false)]
+ [DataRow(true, true)]
+ public void ProcessSendStartMessage_SchedulingAndTracing_ArePreserved(bool useTags, bool scheduled)
+ {
+ var requestTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var parentTraceContext = new DistributedTraceContext(
+ "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "vendor=value");
+ var action = new StartNewOrchestrationOperationAction
+ {
+ InstanceId = "child-instance",
+ Name = "child-orchestration",
+ Version = "2.0",
+ Input = "{\"value\":42}",
+ Tags = useTags ? new Dictionary { { "custom", "value" } } : null,
+ ScheduledStartTime = scheduled ? requestTime.UtcDateTime.AddMinutes(5) : (DateTime?)null,
+ RequestTime = requestTime,
+ ParentTraceContext = parentTraceContext,
+ };
+ var runtimeState = CreateEntityRuntimeState();
+ var originalEvents = runtimeState.Events.ToArray();
+ Activity schedulingActivity = null;
+ using var listener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == "DurableTask.Core",
+ Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded,
+ ActivityStarted = activity => schedulingActivity = activity,
+ };
+ ActivitySource.AddActivityListener(listener);
+
+ var (startEvent, effects) = SendStartMessage(runtimeState, action, taskIdCounter: 42);
+
+ Assert.AreEqual(action.InstanceId, startEvent.OrchestrationInstance.InstanceId);
+ Assert.IsTrue(Guid.TryParseExact(startEvent.OrchestrationInstance.ExecutionId, "N", out _));
+ Assert.AreSame(startEvent.OrchestrationInstance, effects.InstanceMessages[0].OrchestrationInstance);
+ Assert.AreEqual(action.Name, startEvent.Name);
+ Assert.AreEqual(action.Version, startEvent.Version);
+ Assert.AreEqual(action.Input, startEvent.Input);
+ Assert.AreEqual(action.ScheduledStartTime, startEvent.ScheduledStartTime);
+ Assert.AreSame(runtimeState.OrchestrationInstance, startEvent.ParentInstance.OrchestrationInstance);
+ Assert.AreEqual(runtimeState.Name, startEvent.ParentInstance.Name);
+ Assert.AreEqual(runtimeState.Version, startEvent.ParentInstance.Version);
+ Assert.AreEqual(42, startEvent.ParentInstance.TaskScheduleId);
+ Assert.AreEqual(43, effects.taskIdCounter);
+ CollectionAssert.AreEqual(originalEvents, runtimeState.Events.ToArray());
+ Assert.AreEqual(0, effects.ActivityMessages.Count);
+ Assert.AreEqual(0, effects.TimerMessages.Count);
+
+ Assert.IsNotNull(schedulingActivity);
+ Assert.IsTrue(ActivityContext.TryParse(
+ parentTraceContext.TraceParent, parentTraceContext.TraceState, out ActivityContext parentContext));
+ Assert.AreEqual(parentContext.TraceId, schedulingActivity.TraceId);
+ Assert.AreEqual(parentContext.SpanId, schedulingActivity.ParentSpanId);
+ Assert.AreEqual(requestTime.UtcDateTime, schedulingActivity.StartTimeUtc);
+ Assert.AreEqual(schedulingActivity.Id, startEvent.ParentTraceContext.TraceParent);
+ Assert.AreEqual(parentTraceContext.TraceState, startEvent.ParentTraceContext.TraceState);
+ Assert.AreEqual(action.ScheduledStartTime?.ToString(), schedulingActivity.GetTagItem(Schema.Task.ScheduledTime));
+ Assert.AreSame(parentTraceContext, action.ParentTraceContext);
+ Assert.AreEqual(requestTime, action.RequestTime);
+ }
+
+ [DataTestMethod]
+ [DataRow(false, false)]
+ [DataRow(false, true)]
+ [DataRow(true, false)]
+ [DataRow(true, true)]
+ public async Task ProcessSendStartMessage_ChildCompletion_DoesNotNotifyEntity(bool useTags, bool fail)
+ {
+ var runtimeState = CreateEntityRuntimeState(new Dictionary { { "inherited", "entity-value" } });
+ var action = new StartNewOrchestrationOperationAction
+ {
+ InstanceId = "child-instance",
+ Name = nameof(CompletingOrchestration),
+ Version = "",
+ Tags = useTags
+ ? new Dictionary
+ {
+ { "custom", "caller-value" },
+ { OrchestrationTags.FireAndForget, "false" },
+ }
+ : null,
+ };
+ var (startEvent, effects) = SendStartMessage(runtimeState, action);
+ using var service = new CapturingOrchestrationService();
+ var dispatcher = new CompletingOrchestrationDispatcher(service, new CompletingOrchestration(fail));
+ var workItem = new TaskOrchestrationWorkItem
+ {
+ InstanceId = startEvent.OrchestrationInstance.InstanceId,
+ OrchestrationRuntimeState = new OrchestrationRuntimeState(),
+ LockedUntilUtc = DateTime.MaxValue,
+ NewMessages = effects.InstanceMessages,
+ };
+
+ bool completed = await dispatcher.ProcessAsync(workItem);
+
+ Assert.IsTrue(completed);
+ Assert.AreEqual(fail ? OrchestrationStatus.Failed : OrchestrationStatus.Completed, service.State.OrchestrationStatus);
+ Assert.AreEqual(0, service.Messages.Count, "A fire-and-forget child must not send completion or failure to the entity.");
+ Assert.IsNull(service.ContinuedAsNewMessage);
+ Assert.AreEqual("", service.State.Tags[OrchestrationTags.FireAndForget]);
+ Assert.AreEqual("entity-value", service.State.Tags["inherited"]);
+ Assert.IsFalse(runtimeState.Tags.ContainsKey(OrchestrationTags.FireAndForget));
+ Assert.AreEqual(1, runtimeState.Tags.Count);
+ if (useTags)
+ {
+ Assert.IsTrue(service.State.Tags.ContainsKey("custom"));
+ Assert.AreEqual("caller-value", service.State.Tags["custom"]);
+ }
+ }
+
+ static OrchestrationRuntimeState CreateEntityRuntimeState(IDictionary tags = null)
+ {
+ return new OrchestrationRuntimeState(new HistoryEvent[]
+ {
+ new ExecutionStartedEvent(-1, "entity-state")
+ {
+ OrchestrationInstance = new OrchestrationInstance
+ {
+ InstanceId = "@testentity@test-key",
+ ExecutionId = "entity-execution",
+ },
+ Name = "testentity",
+ Version = "1.0",
+ Tags = tags,
+ },
+ });
+ }
+
+ (ExecutionStartedEvent StartEvent, WorkItemEffects Effects) SendStartMessage(
+ OrchestrationRuntimeState runtimeState, StartNewOrchestrationOperationAction action, int taskIdCounter = 0)
+ {
+ var effects = new WorkItemEffects
+ {
+ InstanceId = runtimeState.OrchestrationInstance.InstanceId,
+ RuntimeState = runtimeState,
+ taskIdCounter = taskIdCounter,
+ InstanceMessages = new List(),
+ ActivityMessages = new List(),
+ TimerMessages = new List(),
+ };
+
+ using var service = new LocalOrchestrationService();
+ GetTaskEntityDispatcher(service).ProcessSendStartMessage(effects, runtimeState, action);
+
+ Assert.AreEqual(1, effects.InstanceMessages.Count);
+ Assert.IsInstanceOfType(effects.InstanceMessages[0].Event, typeof(ExecutionStartedEvent));
+ return ((ExecutionStartedEvent)effects.InstanceMessages[0].Event, effects);
+ }
+
+ sealed class CompletingOrchestration : TaskOrchestration
+ {
+ readonly bool fail;
+
+ public CompletingOrchestration(bool fail) => this.fail = fail;
+
+ public override Task RunTask(OrchestrationContext context, string input)
+ {
+ if (this.fail)
+ {
+ throw new InvalidOperationException("Child orchestration failed.");
+ }
+ return Task.FromResult("completed");
+ }
+ }
+
+ sealed class CompletingOrchestrationDispatcher : TaskOrchestrationDispatcher
+ {
+ public CompletingOrchestrationDispatcher(IOrchestrationService service, TaskOrchestration orchestration)
+ : base(service, CreateObjectManager(orchestration), new DispatchMiddlewarePipeline(),
+ new LogHelper(null), ErrorPropagationMode.UseFailureDetails, new VersioningSettings(), null)
+ {
+ }
+
+ public Task ProcessAsync(TaskOrchestrationWorkItem workItem) => this.OnProcessWorkItemAsync(workItem);
+
+ static NameVersionObjectManager CreateObjectManager(TaskOrchestration orchestration)
+ {
+ var manager = new NameVersionObjectManager();
+ manager.Add(new TestObjectCreator(nameof(CompletingOrchestration), "", () => orchestration));
+ return manager;
+ }
+ }
+
+ sealed class CapturingOrchestrationService : LocalOrchestrationService, IOrchestrationService
+ {
+ public OrchestrationState State { get; private set; }
+ public IList Messages { get; private set; }
+ public TaskMessage ContinuedAsNewMessage { get; private set; }
+
+ Task IOrchestrationService.CompleteTaskOrchestrationWorkItemAsync(
+ TaskOrchestrationWorkItem workItem,
+ OrchestrationRuntimeState newOrchestrationRuntimeState,
+ IList outboundMessages,
+ IList orchestratorMessages,
+ IList timerMessages,
+ TaskMessage continuedAsNewMessage,
+ OrchestrationState state)
+ {
+ this.State = state;
+ this.Messages = outboundMessages.Concat(orchestratorMessages).Concat(timerMessages).ToList();
+ this.ContinuedAsNewMessage = continuedAsNewMessage;
+ return Task.CompletedTask;
+ }
+ }
}
}