diff --git a/Directory.Packages.props b/Directory.Packages.props
index 34509fe6..b90b4723 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
+
@@ -34,6 +35,11 @@
+
+
+
+
+
diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln
index 26c2e80d..383f2e15 100644
--- a/Microsoft.DurableTask.sln
+++ b/Microsoft.DurableTask.sln
@@ -93,6 +93,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduleWebApp", "samples\S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduledTasks.Tests", "test\ScheduledTasks.Tests\ScheduledTasks.Tests.csproj", "{D2779F32-A548-44F8-B60A-6AC018966C79}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LargePayloadConsoleApp", "samples\LargePayloadConsoleApp\LargePayloadConsoleApp.csproj", "{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureBlobPayloads", "src\Extensions\AzureBlobPayloads\AzureBlobPayloads.csproj", "{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -247,6 +251,14 @@ Global
{D2779F32-A548-44F8-B60A-6AC018966C79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -293,6 +305,8 @@ Global
{A89B766C-987F-4C9F-8937-D0AB9FE640C8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{100348B5-4D97-4A3F-B777-AB14F276F8FE} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{D2779F32-A548-44F8-B60A-6AC018966C79} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
+ {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
+ {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
diff --git a/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj b/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj
new file mode 100644
index 00000000..b0f2914c
--- /dev/null
+++ b/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net8.0
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/LargePayloadConsoleApp/Program.cs b/samples/LargePayloadConsoleApp/Program.cs
new file mode 100644
index 00000000..9e956180
--- /dev/null
+++ b/samples/LargePayloadConsoleApp/Program.cs
@@ -0,0 +1,198 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.DurableTask.Worker;
+using Microsoft.DurableTask.Worker.AzureManaged;
+using Microsoft.DurableTask;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+// Demonstrates Large Payload Externalization using Azure Blob Storage.
+// This sample uses Azurite/emulator by default via UseDevelopmentStorage=true.
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+// Connection string for Durable Task Scheduler
+string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
+ ?? throw new InvalidOperationException("Missing required configuration 'DURABLE_TASK_SCHEDULER_CONNECTION_STRING'");
+
+// 1) Register shared payload store ONCE
+builder.Services.AddExternalizedPayloadStore(opts =>
+{
+ // Keep threshold small to force externalization for demo purposes
+ opts.ExternalizeThresholdBytes = 1024; // 1KB
+ opts.ConnectionString = builder.Configuration.GetValue("DURABLETASK_STORAGE") ?? "UseDevelopmentStorage=true";
+ opts.ContainerName = builder.Configuration.GetValue("DURABLETASK_PAYLOAD_CONTAINER");
+});
+
+// 2) Configure Durable Task client
+builder.Services.AddDurableTaskClient(b =>
+{
+ b.UseDurableTaskScheduler(schedulerConnectionString);
+ b.Configure(o => o.EnableEntitySupport = true);
+
+ // Use shared store (no duplication of options)
+ b.UseExternalizedPayloads();
+});
+
+// 3) Configure Durable Task worker
+builder.Services.AddDurableTaskWorker(b =>
+{
+ b.UseDurableTaskScheduler(schedulerConnectionString);
+
+ b.AddTasks(tasks =>
+ {
+ // Orchestrator: call activity first, return its output (should equal original input)
+ tasks.AddOrchestratorFunc("LargeInputEcho", async (ctx, input) =>
+ {
+ string echoed = await ctx.CallActivityAsync("Echo", input);
+ return echoed;
+ });
+
+ // Activity: validate it receives raw input (not token) and return it
+ tasks.AddActivityFunc("Echo", (ctx, value) =>
+ {
+ if (value is null)
+ {
+ return string.Empty;
+ }
+
+ if (value.StartsWith("blob:v1:", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Activity received a payload token instead of raw input.");
+ }
+
+ return value;
+ });
+
+ // Entity samples
+ tasks.AddOrchestratorFunc
-
+
diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto
index df5143bc..52ca10d3 100644
--- a/src/Grpc/orchestrator_service.proto
+++ b/src/Grpc/orchestrator_service.proto
@@ -469,6 +469,7 @@ message PurgeInstancesRequest {
oneof request {
string instanceId = 1;
PurgeInstanceFilter purgeInstanceFilter = 2;
+ InstanceBatch instanceBatch = 4;
}
bool recursive = 3;
}
@@ -681,8 +682,7 @@ message AbandonEntityTaskResponse {
}
message SkipGracefulOrchestrationTerminationsRequest {
- // A maximum of 500 instance IDs can be provided in this list.
- repeated string instanceIds = 1;
+ InstanceBatch instanceBatch = 1;
google.protobuf.StringValue reason = 2;
}
@@ -818,4 +818,9 @@ message StreamInstanceHistoryRequest {
message HistoryChunk {
repeated HistoryEvent events = 1;
+}
+
+message InstanceBatch {
+ // A maximum of 500 instance IDs can be provided in this list.
+ repeated string instanceIds = 1;
}
\ No newline at end of file
diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt
index 3e4d1b21..51497576 100644
--- a/src/Grpc/versions.txt
+++ b/src/Grpc/versions.txt
@@ -1,2 +1,3 @@
-# The following files were downloaded from branch main at 2025-09-17 01:45:58 UTC
-https://raw.githubusercontent.com/microsoft/durabletask-protobuf/f5745e0d83f608d77871c1894d9260ceaae08967/protos/orchestrator_service.proto
+# The following files were downloaded from branch main at 2025-09-29 04:31:40 UTC
+https://raw.githubusercontent.com/microsoft/durabletask-protobuf/a4e448066e3d85e676839a8bd23036a36b3c5f88/protos/orchestrator_service.proto
+https://raw.githubusercontent.com/microsoft/durabletask-protobuf/a4e448066e3d85e676839a8bd23036a36b3c5f88/protos/backend_service.proto
diff --git a/test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj b/test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj
index e6b0aee7..ba2c8b68 100644
--- a/test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj
+++ b/test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj
@@ -7,6 +7,7 @@
+
diff --git a/test/Grpc.IntegrationTests/GrpcSidecar/Grpc/TaskHubGrpcServer.cs b/test/Grpc.IntegrationTests/GrpcSidecar/Grpc/TaskHubGrpcServer.cs
index e3a320f7..87f0e631 100644
--- a/test/Grpc.IntegrationTests/GrpcSidecar/Grpc/TaskHubGrpcServer.cs
+++ b/test/Grpc.IntegrationTests/GrpcSidecar/Grpc/TaskHubGrpcServer.cs
@@ -31,6 +31,9 @@ public class TaskHubGrpcServer : P.TaskHubSidecarService.TaskHubSidecarServiceBa
readonly TaskHubDispatcherHost dispatcherHost;
readonly IsConnectedSignal isConnectedSignal = new();
readonly SemaphoreSlim sendWorkItemLock = new(initialCount: 1);
+ readonly ConcurrentDictionary> streamingPastEvents = new(StringComparer.OrdinalIgnoreCase);
+
+ volatile bool supportsHistoryStreaming;
// Initialized when a client connects to this service to receive work-item commands.
IServerStreamWriter? workerToClientStream;
@@ -479,6 +482,8 @@ static P.GetInstanceResponse CreateGetInstanceResponse(OrchestrationState state,
public override async Task GetWorkItems(P.GetWorkItemsRequest request, IServerStreamWriter responseStream, ServerCallContext context)
{
+ // Record whether the client supports history streaming
+ this.supportsHistoryStreaming = request.Capabilities.Contains(P.WorkerCapability.HistoryStreaming);
// Use a lock to mitigate the race condition where we signal the dispatch host to start but haven't
// yet saved a reference to the client response stream.
lock (this.isConnectedSignal)
@@ -521,6 +526,35 @@ public override async Task GetWorkItems(P.GetWorkItemsRequest request, IServerSt
}
}
+ public override async Task StreamInstanceHistory(P.StreamInstanceHistoryRequest request, IServerStreamWriter responseStream, ServerCallContext context)
+ {
+ if (this.streamingPastEvents.TryGetValue(request.InstanceId, out List? pastEvents))
+ {
+ const int MaxChunkBytes = 256 * 1024; // 256KB per chunk to simulate chunked streaming
+ int currentSize = 0;
+ P.HistoryChunk chunk = new();
+
+ foreach (P.HistoryEvent e in pastEvents)
+ {
+ int eventSize = e.CalculateSize();
+ if (currentSize > 0 && currentSize + eventSize > MaxChunkBytes)
+ {
+ await responseStream.WriteAsync(chunk);
+ chunk = new P.HistoryChunk();
+ currentSize = 0;
+ }
+
+ chunk.Events.Add(e);
+ currentSize += eventSize;
+ }
+
+ if (chunk.Events.Count > 0)
+ {
+ await responseStream.WriteAsync(chunk);
+ }
+ }
+ }
+
///
/// Invoked by the when a work item is available, proxies the call to execute an orchestrator over a gRPC channel.
///
@@ -547,16 +581,37 @@ async Task ITaskExecutor.ExecuteOrchestrator(
try
{
+ var orkRequest = new P.OrchestratorRequest
+ {
+ InstanceId = instance.InstanceId,
+ ExecutionId = instance.ExecutionId,
+ NewEvents = { newEvents.Select(ProtobufUtils.ToHistoryEventProto) },
+ OrchestrationTraceContext = orchestrationTraceContext,
+ };
+
+ // Decide whether to stream based on total size of past events (> 1MiB)
+ List protoPastEvents = pastEvents.Select(ProtobufUtils.ToHistoryEventProto).ToList();
+ int totalBytes = 0;
+ foreach (P.HistoryEvent ev in protoPastEvents)
+ {
+ totalBytes += ev.CalculateSize();
+ }
+
+ if (this.supportsHistoryStreaming && totalBytes > (1024))
+ {
+ orkRequest.RequiresHistoryStreaming = true;
+ // Store past events to serve via StreamInstanceHistory
+ this.streamingPastEvents[instance.InstanceId] = protoPastEvents;
+ }
+ else
+ {
+ // Embed full history in the work item
+ orkRequest.PastEvents.AddRange(protoPastEvents);
+ }
+
await this.SendWorkItemToClientAsync(new P.WorkItem
{
- OrchestratorRequest = new P.OrchestratorRequest
- {
- InstanceId = instance.InstanceId,
- ExecutionId = instance.ExecutionId,
- NewEvents = { newEvents.Select(ProtobufUtils.ToHistoryEventProto) },
- OrchestrationTraceContext = orchestrationTraceContext,
- PastEvents = { pastEvents.Select(ProtobufUtils.ToHistoryEventProto) },
- }
+ OrchestratorRequest = orkRequest,
});
}
catch
diff --git a/test/Grpc.IntegrationTests/IntegrationTestBase.cs b/test/Grpc.IntegrationTests/IntegrationTestBase.cs
index 642107ef..13d92a7a 100644
--- a/test/Grpc.IntegrationTests/IntegrationTestBase.cs
+++ b/test/Grpc.IntegrationTests/IntegrationTestBase.cs
@@ -45,9 +45,12 @@ void IDisposable.Dispose()
GC.SuppressFinalize(this);
}
- protected async Task StartWorkerAsync(Action workerConfigure, Action? clientConfigure = null)
+ protected async Task StartWorkerAsync(
+ Action workerConfigure,
+ Action? clientConfigure = null,
+ Action? servicesConfigure = null)
{
- IHost host = this.CreateHostBuilder(workerConfigure, clientConfigure).Build();
+ IHost host = this.CreateHostBuilder(workerConfigure, clientConfigure, servicesConfigure).Build();
await host.StartAsync(this.TimeoutToken);
return new HostTestLifetime(host, this.TimeoutToken);
}
@@ -57,7 +60,10 @@ protected async Task StartWorkerAsync(Action
/// Configures the durable task worker builder.
/// Configures the durable task client builder.
- protected IHostBuilder CreateHostBuilder(Action workerConfigure, Action? clientConfigure)
+ protected IHostBuilder CreateHostBuilder(
+ Action workerConfigure,
+ Action? clientConfigure,
+ Action? servicesConfigure)
{
var host = Host.CreateDefaultBuilder()
.ConfigureLogging(b =>
@@ -69,6 +75,7 @@ protected IHostBuilder CreateHostBuilder(Action worke
})
.ConfigureServices((context, services) =>
{
+ servicesConfigure?.Invoke(services);
services.AddDurableTaskWorker(b =>
{
b.UseGrpc(this.sidecarFixture.Channel);
diff --git a/test/Grpc.IntegrationTests/LargePayloadTests.cs b/test/Grpc.IntegrationTests/LargePayloadTests.cs
new file mode 100644
index 00000000..6e73f877
--- /dev/null
+++ b/test/Grpc.IntegrationTests/LargePayloadTests.cs
@@ -0,0 +1,647 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Converters;
+using Microsoft.DurableTask.Worker;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Microsoft.DurableTask.Grpc.Tests;
+
+public class LargePayloadTests(ITestOutputHelper output, GrpcSidecarFixture sidecarFixture) : IntegrationTestBase(output, sidecarFixture)
+{
+ // Validates client externalizes a large orchestration input and worker resolves it.
+ [Fact]
+ public async Task LargeOrchestrationInputAndOutputAndCustomStatus()
+ {
+ string largeInput = new string('A', 1024 * 1024); // 1MB
+ TaskName orchestratorName = nameof(LargeOrchestrationInputAndOutputAndCustomStatus);
+
+ InMemoryPayloadStore fakeStore = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orchestratorName,
+ (ctx, input) =>
+ {
+ ctx.SetCustomStatus(largeInput);
+ return Task.FromResult(input + input);
+ }));
+
+ worker.UseExternalizedPayloads();
+
+ worker.Services.AddSingleton(fakeStore);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+
+ // Override store with in-memory test double
+ client.Services.AddSingleton(fakeStore);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input: largeInput);
+
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+
+ // Validate that the input made a roundtrip and was resolved on the worker
+ // validate input
+ string? input = completed.ReadInputAs();
+ Assert.NotNull(input);
+ Assert.Equal(largeInput.Length, input!.Length);
+ Assert.Equal(largeInput, input);
+
+ string? echoed = completed.ReadOutputAs();
+ Assert.NotNull(echoed);
+ Assert.Equal(largeInput.Length * 2, echoed!.Length);
+ Assert.Equal(largeInput + largeInput, echoed);
+
+ string? customStatus = completed.ReadCustomStatusAs();
+ Assert.NotNull(customStatus);
+ Assert.Equal(largeInput.Length, customStatus!.Length);
+ Assert.Equal(largeInput, customStatus);
+
+ // Ensure client externalized the input
+ Assert.True(fakeStore.UploadCount >= 1);
+ Assert.True(fakeStore.DownloadCount >= 1);
+ Assert.Contains(JsonSerializer.Serialize(largeInput), fakeStore.uploadedPayloads);
+ Assert.Contains(JsonSerializer.Serialize(largeInput + largeInput), fakeStore.uploadedPayloads);
+ }
+
+ // Validates history streaming path resolves externalized inputs/outputs in HistoryChunk.
+ [Fact]
+ public async Task HistoryStreaming_ResolvesPayloads()
+ {
+ // Make payloads large enough so that past events history exceeds 1 MiB to trigger streaming
+ string largeInput = new string('H', 2 * 1024 * 1024); // 2 MiB
+ string largeOutput = new string('O', 2 * 1024 * 1024); // 2 MiB
+ TaskName orch = nameof(HistoryStreaming_ResolvesPayloads);
+
+ InMemoryPayloadStore store = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orch,
+ async (ctx, input) =>
+ {
+ // Emit several events so that the serialized history size grows
+ for (int i = 0; i < 50; i++)
+ {
+ await ctx.CreateTimer(TimeSpan.FromMilliseconds(10), CancellationToken.None);
+ }
+ return largeOutput;
+ }));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(store);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(store);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ // Start orchestration with large input to exercise history input resolution
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orch, largeInput);
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+ Assert.Equal(largeInput, completed.ReadInputAs());
+ Assert.Equal(largeOutput, completed.ReadOutputAs());
+ Assert.True(store.UploadCount >= 2);
+ Assert.True(store.DownloadCount >= 2);
+ }
+
+ // Validates client externalizes large suspend and resume reasons.
+ [Fact]
+ public async Task SuspendAndResume_Reason_IsExternalizedByClient()
+ {
+ string largeReason1 = new string('Z', 700 * 1024); // 700KB
+ string largeReason2 = new string('Y', 650 * 1024); // 650KB
+ TaskName orchestratorName = nameof(SuspendAndResume_Reason_IsExternalizedByClient);
+
+ InMemoryPayloadStore clientStore = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ // Long-running orchestrator to give time for suspend/resume
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orchestratorName,
+ async (ctx, _) =>
+ {
+ await ctx.CreateTimer(TimeSpan.FromMinutes(5), CancellationToken.None);
+ return "done";
+ }));
+ },
+ client =>
+ {
+ // Enable externalization on the client and use the in-memory store to track uploads
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(clientStore);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
+ await server.Client.WaitForInstanceStartAsync(instanceId, this.TimeoutToken);
+
+ // Suspend with large reason (should be externalized by client)
+ await server.Client.SuspendInstanceAsync(instanceId, largeReason1, this.TimeoutToken);
+ await server.Client.WaitForInstanceStartAsync(instanceId, this.TimeoutToken);
+
+ // poll up to 5 seconds to verify it is suspended
+ var deadline1 = DateTime.UtcNow.AddSeconds(5);
+ while (true)
+ {
+ OrchestrationMetadata? status1 = await server.Client.GetInstanceAsync(instanceId, getInputsAndOutputs: false, this.TimeoutToken);
+ if (status1 is not null && status1.RuntimeStatus == OrchestrationRuntimeStatus.Suspended)
+ {
+ break;
+ }
+
+ if (DateTime.UtcNow >= deadline1)
+ {
+ Assert.NotNull(status1);
+ Assert.Equal(OrchestrationRuntimeStatus.Suspended, status1!.RuntimeStatus);
+ }
+ }
+ // Resume with large reason (should be externalized by client)
+ await server.Client.ResumeInstanceAsync(instanceId, largeReason2, this.TimeoutToken);
+
+ // verify it is resumed (poll up to 5 seconds)
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (true)
+ {
+ OrchestrationMetadata? status = await server.Client.GetInstanceAsync(instanceId, getInputsAndOutputs: false, this.TimeoutToken);
+ if (status is not null && status.RuntimeStatus == OrchestrationRuntimeStatus.Running)
+ {
+ break;
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ Assert.NotNull(status);
+ Assert.Equal(OrchestrationRuntimeStatus.Running, status!.RuntimeStatus);
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1), this.TimeoutToken);
+ }
+
+
+
+ Assert.True(clientStore.UploadCount >= 2);
+ Assert.Contains(largeReason1, clientStore.uploadedPayloads);
+ Assert.Contains(largeReason2, clientStore.uploadedPayloads);
+ }
+
+ // Validates terminating an instance with a large output payload is externalized by the client.
+ [Fact]
+ public async Task LargeTerminateWithPayload()
+ {
+ string largeInput = new string('I', 900 * 1024);
+ string largeOutput = new string('T', 900 * 1024);
+ TaskName orch = nameof(LargeTerminateWithPayload);
+
+ InMemoryPayloadStore store = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orch,
+ async (ctx, _) =>
+ {
+ await ctx.CreateTimer(TimeSpan.FromSeconds(30), CancellationToken.None);
+ return null;
+ }));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(store);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(store);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string id = await server.Client.ScheduleNewOrchestrationInstanceAsync(orch, largeInput);
+ await server.Client.WaitForInstanceStartAsync(id, this.TimeoutToken);
+
+ await server.Client.TerminateInstanceAsync(id, new TerminateInstanceOptions { Output = largeOutput }, this.TimeoutToken);
+
+ await server.Client.WaitForInstanceCompletionAsync(id, this.TimeoutToken);
+ OrchestrationMetadata? status = await server.Client.GetInstanceAsync(id, getInputsAndOutputs: false);
+ Assert.NotNull(status);
+ Assert.Equal(OrchestrationRuntimeStatus.Terminated, status!.RuntimeStatus);
+ Assert.True(store.UploadCount >= 1);
+ Assert.True(store.DownloadCount >= 1);
+ Assert.Contains(JsonSerializer.Serialize(largeOutput), store.uploadedPayloads);
+ }
+ // Validates large custom status and ContinueAsNew input are externalized and resolved across iterations.
+ [Fact]
+ public async Task LargeContinueAsNewAndCustomStatus()
+ {
+ string largeStatus = new string('S', 700 * 1024);
+ string largeNextInput = new string('N', 800 * 1024);
+ string largeFinalOutput = new string('F', 750 * 1024);
+ TaskName orch = nameof(LargeContinueAsNewAndCustomStatus);
+
+ var shared = new Dictionary();
+ InMemoryPayloadStore workerStore = new InMemoryPayloadStore(shared);
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orch,
+ async (ctx, input) =>
+ {
+ if (input == null)
+ {
+ ctx.SetCustomStatus(largeStatus);
+ ctx.ContinueAsNew(largeNextInput);
+ // unreachable
+ return "";
+ }
+ else
+ {
+ // second iteration returns final
+ return largeFinalOutput;
+ }
+ }));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(workerStore);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(workerStore);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orch);
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+ Assert.Equal(largeFinalOutput, completed.ReadOutputAs());
+ Assert.Contains(JsonSerializer.Serialize(largeStatus), workerStore.uploadedPayloads);
+ Assert.Contains(JsonSerializer.Serialize(largeNextInput), workerStore.uploadedPayloads);
+ Assert.Contains(JsonSerializer.Serialize(largeFinalOutput), workerStore.uploadedPayloads);
+ }
+
+ // Validates large sub-orchestration input and an activity large output in one flow.
+ [Fact]
+ public async Task LargeSubOrchestrationAndActivityOutput()
+ {
+ string largeChildInput = new string('C', 650 * 1024);
+ string largeActivityOutput = new string('A', 820 * 1024);
+ TaskName parent = nameof(LargeSubOrchestrationAndActivityOutput) + "_Parent";
+ TaskName child = nameof(LargeSubOrchestrationAndActivityOutput) + "_Child";
+ TaskName activity = "ProduceBig";
+
+ var shared = new Dictionary();
+ InMemoryPayloadStore workerStore = new InMemoryPayloadStore(shared);
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks
+ .AddOrchestratorFunc(
+ parent,
+ async (ctx, _) =>
+ {
+ string echoed = await ctx.CallSubOrchestratorAsync(child, largeChildInput);
+ string act = await ctx.CallActivityAsync(activity);
+ return echoed.Length + act.Length;
+ })
+ .AddOrchestratorFunc(child, (ctx, input) => Task.FromResult(input))
+ .AddActivityFunc(activity, (ctx) => Task.FromResult(largeActivityOutput)));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(workerStore);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(workerStore);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string id = await server.Client.ScheduleNewOrchestrationInstanceAsync(parent);
+ OrchestrationMetadata done = await server.Client.WaitForInstanceCompletionAsync(
+ id, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, done.RuntimeStatus);
+ Assert.Equal(largeChildInput.Length + largeActivityOutput.Length, done.ReadOutputAs());
+ Assert.True(workerStore.UploadCount >= 1);
+ Assert.True(workerStore.DownloadCount >= 1);
+ Assert.Contains(JsonSerializer.Serialize(largeChildInput), workerStore.uploadedPayloads);
+ Assert.Contains(JsonSerializer.Serialize(largeActivityOutput), workerStore.uploadedPayloads);
+ }
+
+ // Validates query with fetch I/O resolves large outputs for completed instances.
+ [Fact]
+ public async Task LargeQueryFetchInputsAndOutputs()
+ {
+ string largeIn = new string('I', 750 * 1024);
+ string largeOut = new string('Q', 880 * 1024);
+ TaskName orch = nameof(LargeQueryFetchInputsAndOutputs);
+
+ var shared = new Dictionary();
+ InMemoryPayloadStore workerStore = new InMemoryPayloadStore(shared);
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orch,
+ (ctx, input) => Task.FromResult(largeOut)));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(workerStore);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(workerStore);
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string id = await server.Client.ScheduleNewOrchestrationInstanceAsync(orch, largeIn);
+ await server.Client.WaitForInstanceCompletionAsync(id, getInputsAndOutputs: false, this.TimeoutToken);
+
+ var page = server.Client.GetAllInstancesAsync(new OrchestrationQuery { FetchInputsAndOutputs = true, InstanceIdPrefix = id });
+ OrchestrationMetadata? found = null;
+ await foreach (var item in page)
+ {
+ if (item.Name == orch.Name)
+ {
+ found = item;
+ break;
+ }
+ }
+
+ Assert.NotNull(found);
+ Assert.Equal(largeOut, found!.ReadOutputAs());
+ Assert.True(workerStore.DownloadCount >= 1);
+ Assert.True(workerStore.UploadCount >= 1);
+ Assert.Contains(JsonSerializer.Serialize(largeIn), workerStore.uploadedPayloads);
+ Assert.Contains(JsonSerializer.Serialize(largeOut), workerStore.uploadedPayloads);
+ }
+ // Validates worker externalizes large activity input and delivers resolved payload to activity.
+ [Fact]
+ public async Task LargeActivityInputAndOutput()
+ {
+ string largeParam = new string('P', 700 * 1024); // 700KB
+ TaskName orchestratorName = nameof(LargeActivityInputAndOutput);
+ TaskName activityName = "EchoLength";
+
+ InMemoryPayloadStore workerStore = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks
+ .AddOrchestratorFunc(
+ orchestratorName,
+ (ctx, _) => ctx.CallActivityAsync(activityName, largeParam))
+ .AddActivityFunc(activityName, (ctx, input) => input + input));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(workerStore);
+ },
+ client => { /* client not needed for externalization path here */ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+
+ // validate upload and download count
+ Assert.True(workerStore.UploadCount >= 1);
+ Assert.True(workerStore.DownloadCount >= 1);
+
+ // validate uploaded payloads include the activity input and output forms
+ string expectedActivityInputJson = JsonSerializer.Serialize(new[] { largeParam });
+ string expectedActivityOutputJson = JsonSerializer.Serialize(largeParam + largeParam);
+ Assert.Contains(expectedActivityInputJson, workerStore.uploadedPayloads);
+ Assert.Contains(expectedActivityOutputJson, workerStore.uploadedPayloads);
+ }
+
+
+ // Ensures payloads below the threshold are not externalized by client or worker.
+ [Fact]
+ public async Task NoLargePayloads()
+ {
+ string smallPayload = new string('X', 64 * 1024); // 64KB
+ TaskName orchestratorName = nameof(NoLargePayloads);
+
+ InMemoryPayloadStore workerStore = new InMemoryPayloadStore();
+ InMemoryPayloadStore clientStore = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orchestratorName,
+ (ctx, input) => Task.FromResult(input)));
+
+ worker.UseExternalizedPayloads();
+ worker.Services.AddSingleton(workerStore);
+ },
+ client =>
+ {
+ client.UseExternalizedPayloads();
+ client.Services.AddSingleton(clientStore);
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input: smallPayload);
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+ Assert.Equal(smallPayload, completed.ReadOutputAs());
+
+ Assert.Equal(0, workerStore.UploadCount);
+ Assert.Equal(0, workerStore.DownloadCount);
+ Assert.Equal(0, clientStore.UploadCount);
+ Assert.Equal(0, clientStore.DownloadCount);
+ }
+
+ // Validates client externalizes a large external event payload and worker resolves it.
+ [Fact]
+ public async Task LargeExternalEvent()
+ {
+ string largeEvent = new string('E', 512 * 1024); // 512KB
+ TaskName orchestratorName = nameof(LargeExternalEvent);
+ const string EventName = "LargeEvent";
+
+ InMemoryPayloadStore fakeStore = new InMemoryPayloadStore();
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(
+ worker =>
+ {
+ worker.AddTasks(tasks => tasks.AddOrchestratorFunc(
+ orchestratorName,
+ async ctx => await ctx.WaitForExternalEvent(EventName)));
+
+ worker.Services.AddSingleton(fakeStore);
+ worker.UseExternalizedPayloads();
+ },
+ client =>
+ {
+ client.Services.AddSingleton(fakeStore);
+ client.UseExternalizedPayloads();
+ },
+ services =>
+ {
+ services.AddExternalizedPayloadStore(opts =>
+ {
+ opts.ExternalizeThresholdBytes = 1024;
+ opts.ContainerName = "test";
+ opts.ConnectionString = "UseDevelopmentStorage=true";
+ });
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
+ await server.Client.WaitForInstanceStartAsync(instanceId, this.TimeoutToken);
+
+ await server.Client.RaiseEventAsync(instanceId, EventName, largeEvent, this.TimeoutToken);
+
+ OrchestrationMetadata completed = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, completed.RuntimeStatus);
+ string? output = completed.ReadOutputAs();
+ Assert.Equal(largeEvent, output);
+ Assert.True(fakeStore.UploadCount >= 1);
+ Assert.True(fakeStore.DownloadCount >= 1);
+ Assert.Contains(JsonSerializer.Serialize(largeEvent), fakeStore.uploadedPayloads);
+ }
+
+
+ class InMemoryPayloadStore : PayloadStore
+ {
+ const string TokenPrefix = "blob:v1:";
+ readonly Dictionary tokenToPayload;
+ public readonly HashSet uploadedPayloads = new();
+
+ public InMemoryPayloadStore()
+ : this(new Dictionary())
+ {
+ }
+
+ public InMemoryPayloadStore(Dictionary shared)
+ {
+ this.tokenToPayload = shared;
+ }
+
+ int uploadCount;
+ public int UploadCount => this.uploadCount;
+ int downloadCount;
+ public int DownloadCount => this.downloadCount;
+
+ public override Task UploadAsync(string payLoad, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref this.uploadCount);
+ string token = $"blob:v1:test:{Guid.NewGuid():N}";
+ this.tokenToPayload[token] = payLoad;
+ this.uploadedPayloads.Add(payLoad);
+ return Task.FromResult(token);
+ }
+
+ public override Task DownloadAsync(string token, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref this.downloadCount);
+ return Task.FromResult(this.tokenToPayload[token]);
+ }
+
+ public override bool IsKnownPayloadToken(string value)
+ {
+ return value.StartsWith(TokenPrefix, StringComparison.Ordinal);
+ }
+
+ }
+}