Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions docs/features/sub-orchestrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,22 +207,46 @@ public class PhaseOrchestration : TaskOrchestration<PhaseResult, PhaseInput>
### Auto-Generated IDs

```csharp
// ID is an automatically generated GUID
// The framework generates a distinct instance ID for this call
var result = await context.CreateSubOrchestrationInstance<Result>(
typeof(ChildOrchestration),
input);
```

### Custom IDs for Idempotency
### Custom IDs

```csharp
// Using custom ID ensures idempotency
// Choose an ID that is unique among concurrently running children
var result = await context.CreateSubOrchestrationInstance<Result>(
typeof(ChildOrchestration),
instanceId: $"{context.OrchestrationInstance.InstanceId}:child:{input.ItemId}",
input: input);
```

### Detecting Duplicate Awaited Child IDs

Concurrent sub-orchestration calls must use distinct instance IDs. Different names, versions, or inputs do not disambiguate the same ID, and an explicit ID does not merge calls or make their results idempotent.

Core hosts can opt in to detection before starting the worker:

```csharp
var worker = new TaskHubWorker(service)
{
FailOnDuplicateSubOrchestrationInstanceIds = true
};
await worker.StartAsync();
```

The option defaults to `false` for compatibility. When enabled, a new awaited child start that conflicts with another pending awaited child in the same parent execution fails the **parent orchestration** with failure type `DuplicateSubOrchestrationInstanceId` and non-retriable failure details. The entire current decision batch is discarded before any of its activities, timers, events, or children are scheduled. Work already scheduled in previous batches is not cancelled.

This is a terminal orchestration-level failure, not an exception that the offending orchestration can catch around an individual call. Use distinct IDs, omit explicit IDs, or await a child's completion or failure before reusing its ID. Comparison is ordinal and case-sensitive.

Detection does not check fire-and-forget starts or uniqueness across parents. Existing duplicate history without a new conflicting start is not rejected, and already stranded orchestrations are not automatically repaired. This is a Core worker option; downstream hosts such as Azure Functions must separately expose and enable it in a release that includes this capability.

The guard lazily indexes accepted child history once per runtime-state load, then maintains the pending index as events are accepted. Cold initialization reduces completed history before allocating index entries for the remaining pending children. Reused runtime states, including extended sessions, validate new batches without rescanning old history or copying all pending children. Cold loads and ordinary uncached orchestration replay still process history; this option does not eliminate those costs. Proposed actions are tracked separately until they are accepted into history, and a drained fan-out releases its index capacity.

Custom middleware or providers that rewrite history should construct a new `OrchestrationRuntimeState`. If they instead directly edit `Events` or mutate accepted event IDs, child instance IDs, completion/failure task schedule IDs, or fire-and-forget tags, they must call `InvalidateSubOrchestrationInstanceIdIndex()` before the next guarded validation. Normal `AddEvent` calls maintain the index automatically. Arbitrary external mutations are not detected automatically, and invalidating this derived index does not repair other runtime-state metadata.

### Naming Conventions

```csharp
Expand Down
28 changes: 28 additions & 0 deletions src/DurableTask.Core/OrchestrationRuntimeState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@ namespace DurableTask.Core
public class OrchestrationRuntimeState
{
private OrchestrationStatus orchestrationStatus;
SubOrchestrationInstanceIdIndex? subOrchestrationInstanceIdIndex;

/// <summary>
/// List of all history events for this runtime state.
/// Note that this list is frequently a combination of <see cref="PastEvents"/> and <see cref="NewEvents"/>, but not always.
/// </summary>
/// <remarks>
/// Use <see cref="AddEvent(HistoryEvent)"/> to append accepted events. Custom history rewriters should
/// construct a new runtime state, or call <see cref="InvalidateSubOrchestrationInstanceIdIndex"/>
/// after directly modifying this list or indexed fields of its events.
/// </remarks>
public IList<HistoryEvent> Events { get; }

/// <summary>
Expand Down Expand Up @@ -210,6 +216,27 @@ public void AddEvent(HistoryEvent historyEvent)
AddEvent(historyEvent, true);
}

/// <summary>
/// Invalidates derived tracking used by the opt-in duplicate sub-orchestration instance ID guard.
/// </summary>
/// <remarks>
/// Call this after directly editing <see cref="Events"/>, or changing an accepted child's event ID,
/// instance ID, fire-and-forget tags, or a completion/failure's task schedule ID in place.
/// Normal <see cref="AddEvent(HistoryEvent)"/> calls update the tracking automatically.
/// The next guarded child-start validation rebuilds it from <see cref="Events"/>.
/// This does not reconcile other runtime state; constructing a new runtime state is preferred
/// when rewriting history.
/// </remarks>
public void InvalidateSubOrchestrationInstanceIdIndex()
{
this.subOrchestrationInstanceIdIndex = null;
}

internal SubOrchestrationInstanceIdIndex GetSubOrchestrationInstanceIdIndex()
{
return this.subOrchestrationInstanceIdIndex ??= SubOrchestrationInstanceIdIndex.FromHistory(this.Events);
}

ExecutionStartedEvent GetExecutionStartedEventOrThrow()
{
ExecutionStartedEvent? executionStartedEvent = this.ExecutionStartedEvent;
Expand Down Expand Up @@ -245,6 +272,7 @@ void AddEvent(HistoryEvent historyEvent, bool isNewEvent)
}

SetMarkerEvents(historyEvent);
this.subOrchestrationInstanceIdIndex?.AddEvent(historyEvent);
}

bool IsDuplicateEvent(HistoryEvent historyEvent)
Expand Down
162 changes: 162 additions & 0 deletions src/DurableTask.Core/SubOrchestrationInstanceIdIndex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// ----------------------------------------------------------------------------------
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------

#nullable enable
namespace DurableTask.Core
{
using System;
using System.Collections.Generic;
using DurableTask.Core.History;

internal sealed class SubOrchestrationInstanceIdIndex
{
Dictionary<int, PendingChild>? pendingTasks;
Dictionary<string, PendingChild>? pendingInstances;
bool hadConcurrentChildren;

internal static SubOrchestrationInstanceIdIndex FromHistory(IEnumerable<HistoryEvent> history)
{
// Reduce completed history before allocating nodes: cold sequential replay needs
// only a small temporary map, not one linked node for every historical child.
Dictionary<int, string>? pending = null;
foreach (HistoryEvent historyEvent in history)
{
switch (historyEvent)
{
case SubOrchestrationInstanceCreatedEvent created
when created.InstanceId != null && !OrchestrationTags.IsTaggedAsFireAndForget(created.Tags):
pending ??= new Dictionary<int, string>();
pending[created.EventId] = created.InstanceId;
break;
case SubOrchestrationInstanceCompletedEvent completed:
pending?.Remove(completed.TaskScheduledId);
break;
case SubOrchestrationInstanceFailedEvent failed:
pending?.Remove(failed.TaskScheduledId);
break;
}
}

var index = new SubOrchestrationInstanceIdIndex();
if (pending != null)
{
foreach (KeyValuePair<int, string> child in pending)
{
index.AddPending(child.Key, child.Value);
}
}

return index;
}

internal bool TryGetPendingTaskId(string instanceId, out int taskId)
{
if (this.pendingInstances != null && this.pendingInstances.TryGetValue(instanceId, out PendingChild child))
{
taskId = child.TaskId;
return true;
}

taskId = default;
return false;
}

internal void AddEvent(HistoryEvent historyEvent)
{
switch (historyEvent)
{
case SubOrchestrationInstanceCreatedEvent created
when created.InstanceId != null && !OrchestrationTags.IsTaggedAsFireAndForget(created.Tags):
this.AddPending(created.EventId, created.InstanceId);
break;
case SubOrchestrationInstanceCompletedEvent completed:
this.Remove(completed.TaskScheduledId);
break;
case SubOrchestrationInstanceFailedEvent failed:
this.Remove(failed.TaskScheduledId);
break;
}
}

void AddPending(int taskId, string instanceId)
{
this.Remove(taskId);
this.pendingTasks ??= new Dictionary<int, PendingChild>();
this.pendingInstances ??= new Dictionary<string, PendingChild>(StringComparer.Ordinal);
this.pendingInstances.TryGetValue(instanceId, out PendingChild previous);
var child = new PendingChild(taskId, instanceId) { Next = previous };
if (previous != null)
{
previous.Previous = child;
}

this.pendingTasks.Add(child.TaskId, child);
this.pendingInstances[child.InstanceId] = child;
this.hadConcurrentChildren |= this.pendingTasks.Count > 1;
}

void Remove(int taskId)
{
if (this.pendingTasks == null || !this.pendingTasks.TryGetValue(taskId, out PendingChild child))
{
return;
}

if (child.Previous != null)
{
child.Previous.Next = child.Next;
}
else if (child.Next != null)
{
this.pendingInstances![child.InstanceId] = child.Next;
}
else
{
this.pendingInstances!.Remove(child.InstanceId);
}

if (child.Next != null)
{
child.Next.Previous = child.Previous;
}

this.pendingTasks.Remove(taskId);
if (this.pendingTasks.Count == 0 && this.hadConcurrentChildren)
{
// Release drained fan-out capacity, but reuse the small buffers for sequential children.
this.pendingTasks = null;
this.pendingInstances = null;
this.hadConcurrentChildren = false;
}
}

// Legacy histories can contain several pending task IDs for one instance ID.
// Links allow removal of any matching task in O(1), without a HashSet per child.
sealed class PendingChild
{
internal PendingChild(int taskId, string instanceId)
{
this.TaskId = taskId;
this.InstanceId = instanceId;
}

internal int TaskId { get; }

internal string InstanceId { get; }

internal PendingChild? Previous { get; set; }

internal PendingChild? Next { get; set; }
}
}
}
65 changes: 65 additions & 0 deletions src/DurableTask.Core/SubOrchestrationInstanceIdValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// ----------------------------------------------------------------------------------
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------

#nullable enable
namespace DurableTask.Core
{
using System;
using System.Collections.Generic;
using DurableTask.Core.Command;

internal static class SubOrchestrationInstanceIdValidator
{
internal static OrchestrationCompleteOrchestratorAction? GetFailure(
string parentInstanceId,
OrchestrationRuntimeState runtimeState,
IEnumerable<OrchestratorAction> decisions)
{
SubOrchestrationInstanceIdIndex? pendingInstances = null;
Dictionary<string, int>? batchInstances = null;
foreach (OrchestratorAction decision in decisions)
{
if (decision is not CreateSubOrchestrationAction action
|| action.InstanceId == null
|| OrchestrationTags.IsTaggedAsFireAndForget(action.Tags))
{
continue;
}

pendingInstances ??= runtimeState.GetSubOrchestrationInstanceIdIndex();
if (pendingInstances.TryGetPendingTaskId(action.InstanceId, out int priorTaskId)
|| (batchInstances != null && batchInstances.TryGetValue(action.InstanceId, out priorTaskId)))
{
string message = $"Orchestration '{parentInstanceId}' attempted to start sub-orchestration "
+ $"'{action.InstanceId}' with task ID {action.Id}, but task ID {priorTaskId} is still pending "
+ "with the same instance ID. Use distinct instance IDs for concurrent sub-orchestrations, "
+ "omit the instance ID to generate one automatically, or await completion before reusing an ID.";

return new OrchestrationCompleteOrchestratorAction
{
Id = action.Id,
OrchestrationStatus = OrchestrationStatus.Failed,
Result = message,
FailureDetails = new FailureDetails("DuplicateSubOrchestrationInstanceId", message, null, null, true),
};
}

// Proposed actions are not accepted history: a rejected or split batch may never send them.
batchInstances ??= new Dictionary<string, int>(StringComparer.Ordinal);
batchInstances.Add(action.InstanceId, action.Id);
}

return null;
}
}
}
19 changes: 18 additions & 1 deletion src/DurableTask.Core/TaskHubWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,20 @@ public TaskHubWorker(
/// </remarks>
public ErrorPropagationMode ErrorPropagationMode { get; set; }

/// <summary>
/// Gets or sets whether to fail an orchestration that starts an awaited sub-orchestration
/// with the same instance ID as another pending awaited sub-orchestration in that execution.
/// </summary>
/// <remarks>
/// Defaults to <c>false</c> for compatibility. Set this property before <see cref="StartAsync"/>.
/// When enabled, a conflicting start fails the orchestration and discards its entire current
/// decision batch; it does not throw a catchable exception at the individual call site.
/// Instance IDs are compared ordinally and case-sensitively. Reuse after completion or failure
/// is allowed. Fire-and-forget starts and conflicts across different parents are not checked.
/// Existing duplicate history alone does not cause failure or repair stranded orchestrations.
/// </remarks>
public bool FailOnDuplicateSubOrchestrationInstanceIds { get; set; }

/// <summary>
/// Gets or sets the exception properties provider that extracts custom properties from exceptions
/// when creating FailureDetails objects.
Expand Down Expand Up @@ -303,7 +317,10 @@ public async Task<TaskHubWorker> StartAsync()
this.logHelper,
this.ErrorPropagationMode,
this.versioningSettings,
this.ExceptionPropertiesProvider);
this.ExceptionPropertiesProvider)
{
FailOnDuplicateSubOrchestrationInstanceIds = this.FailOnDuplicateSubOrchestrationInstanceIds,
};
this.activityDispatcher = new TaskActivityDispatcher(
this.orchestrationService,
this.activityManager,
Expand Down
17 changes: 17 additions & 0 deletions src/DurableTask.Core/TaskOrchestrationDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ public async Task StopAsync(bool forced)
/// </summary>
public bool EntitiesEnabled { get; set; }

internal bool FailOnDuplicateSubOrchestrationInstanceIds { get; set; }

/// <summary>
/// Method to get the next work item to process within supplied timeout
/// </summary>
Expand Down Expand Up @@ -467,6 +469,21 @@ protected async Task<bool> OnProcessWorkItemAsync(TaskOrchestrationWorkItem work
}
}

if (this.FailOnDuplicateSubOrchestrationInstanceIds)
{
OrchestrationCompleteOrchestratorAction? failure =
SubOrchestrationInstanceIdValidator.GetFailure(
runtimeState.OrchestrationInstance!.InstanceId,
runtimeState,
decisions);
if (failure != null)
{
// Validate the whole batch before creating any history or outbound messages,
// including when the provider would otherwise split the batch across episodes.
decisions = new[] { failure };
}
}

this.logHelper.OrchestrationExecuted(
runtimeState.OrchestrationInstance!,
runtimeState.Name,
Expand Down
Loading
Loading