diff --git a/WebJobs.Extensions.DurableTask.sln b/WebJobs.Extensions.DurableTask.sln
index 8efe24eee..54242679f 100644
--- a/WebJobs.Extensions.DurableTask.sln
+++ b/WebJobs.Extensions.DurableTask.sln
@@ -94,6 +94,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PerfTests", "PerfTests", "{
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DFPerfScenariosV4", "test\DFPerfScenarios\DFPerfScenariosV4.csproj", "{FC8AD123-F949-4D21-B817-E5A4BBF7F69B}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IsolatedEntities", "test\IsolatedEntities\IsolatedEntities.csproj", "{8CBB856D-2D77-4052-9E50-2F635DE5C88F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -178,6 +180,10 @@ Global
{FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC8AD123-F949-4D21-B817-E5A4BBF7F69B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8CBB856D-2D77-4052-9E50-2F635DE5C88F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8CBB856D-2D77-4052-9E50-2F635DE5C88F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8CBB856D-2D77-4052-9E50-2F635DE5C88F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8CBB856D-2D77-4052-9E50-2F635DE5C88F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -211,6 +217,7 @@ Global
{65F904AA-0F6F-48CB-BE19-593B7D68152A} = {7387E723-E153-4B7A-B105-8C67BFBD48CF}
{7387E723-E153-4B7A-B105-8C67BFBD48CF} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5}
{FC8AD123-F949-4D21-B817-E5A4BBF7F69B} = {7387E723-E153-4B7A-B105-8C67BFBD48CF}
+ {8CBB856D-2D77-4052-9E50-2F635DE5C88F} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5E9AC327-DE18-41A5-A55D-E44CB4281943}
diff --git a/test/IsolatedEntities/Common/HttpTriggers.cs b/test/IsolatedEntities/Common/HttpTriggers.cs
new file mode 100644
index 000000000..bb450f5d2
--- /dev/null
+++ b/test/IsolatedEntities/Common/HttpTriggers.cs
@@ -0,0 +1,79 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Runtime.Serialization;
+using System.Text;
+using System.Text.RegularExpressions;
+using Azure.Core;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Extensions.DurableTask;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace IsolatedEntities;
+
+///
+/// Provides an http trigger to run functional tests for entities.
+///
+public static class HttpTriggers
+{
+ [Function(nameof(RunAllTests))]
+ public static async Task RunAllTests(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "tests/")] HttpRequestData request,
+ [DurableClient] DurableTaskClient client,
+ FunctionContext executionContext)
+ {
+ var context = new TestContext(client, executionContext);
+ string result = await TestRunner.RunAsync(context, filter: null);
+ HttpResponseData response = request.CreateResponse(System.Net.HttpStatusCode.OK);
+ response.WriteString(result);
+ return response;
+ }
+
+ [Function(nameof(RunFilteredTests))]
+ public static async Task RunFilteredTests(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "tests/{filter}")] HttpRequestData request,
+ [DurableClient] DurableTaskClient client,
+ FunctionContext executionContext,
+ string filter)
+ {
+ var context = new TestContext(client, executionContext);
+ string result = await TestRunner.RunAsync(context, filter);
+ HttpResponseData response = request.CreateResponse(System.Net.HttpStatusCode.OK);
+ response.WriteString(result);
+ return response;
+ }
+
+ [Function(nameof(ListAllTests))]
+ public static async Task ListAllTests(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "tests/")] HttpRequestData request,
+ [DurableClient] DurableTaskClient client,
+ FunctionContext executionContext)
+ {
+ var context = new TestContext(client, executionContext);
+ string result = await TestRunner.RunAsync(context, filter: null, listOnly: true);
+ HttpResponseData response = request.CreateResponse(System.Net.HttpStatusCode.OK);
+ response.WriteString(result);
+ return response;
+ }
+
+ [Function(nameof(ListFilteredTests))]
+ public static async Task ListFilteredTests(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "tests/{filter}")] HttpRequestData request,
+ [DurableClient] DurableTaskClient client,
+ FunctionContext executionContext,
+ string filter)
+ {
+ var context = new TestContext(client, executionContext);
+ string result = await TestRunner.RunAsync(context, filter, listOnly: true);
+ HttpResponseData response = request.CreateResponse(System.Net.HttpStatusCode.OK);
+ response.WriteString(result);
+ return response;
+ }
+}
+
+
+
diff --git a/test/IsolatedEntities/Common/ProblematicObject.cs b/test/IsolatedEntities/Common/ProblematicObject.cs
new file mode 100644
index 000000000..b1bd7adbd
--- /dev/null
+++ b/test/IsolatedEntities/Common/ProblematicObject.cs
@@ -0,0 +1,69 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.Serialization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using Azure.Core.Serialization;
+
+namespace IsolatedEntities
+{
+ internal static class CustomSerialization
+ {
+ public static ProblematicObject CreateUnserializableObject()
+ {
+ return new ProblematicObject(serializable: false, deserializable: false);
+ }
+
+ public static ProblematicObject CreateUndeserializableObject()
+ {
+ return new ProblematicObject(serializable: true, deserializable: false);
+ }
+
+ public class ProblematicObject
+ {
+ public ProblematicObject(bool serializable = true, bool deserializable = true)
+ {
+ this.Serializable = serializable;
+ this.Deserializable = deserializable;
+ }
+
+ public bool Serializable { get; set; }
+
+ public bool Deserializable { get; set; }
+ }
+
+ public class ProblematicObjectJsonConverter : JsonConverter
+ {
+ public override ProblematicObject Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ bool deserializable = reader.GetBoolean();
+ if (!deserializable)
+ {
+ throw new JsonException("problematic object: is not deserializable");
+ }
+ return new ProblematicObject(serializable: true, deserializable: true);
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ ProblematicObject value,
+ JsonSerializerOptions options)
+ {
+ if (!value.Serializable)
+ {
+ throw new JsonException("problematic object: is not serializable");
+ }
+ writer.WriteBooleanValue(value.Deserializable);
+ }
+ }
+ }
+}
diff --git a/test/IsolatedEntities/Common/Test.cs b/test/IsolatedEntities/Common/Test.cs
new file mode 100644
index 000000000..73fc8b151
--- /dev/null
+++ b/test/IsolatedEntities/Common/Test.cs
@@ -0,0 +1,19 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace IsolatedEntities;
+
+internal abstract class Test
+{
+ public virtual string Name => this.GetType().Name;
+
+ public abstract Task RunAsync(TestContext context);
+
+ public virtual TimeSpan Timeout => TimeSpan.FromSeconds(30);
+}
diff --git a/test/IsolatedEntities/Common/TestContext.cs b/test/IsolatedEntities/Common/TestContext.cs
new file mode 100644
index 000000000..6d8cf7789
--- /dev/null
+++ b/test/IsolatedEntities/Common/TestContext.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace IsolatedEntities;
+
+internal class TestContext
+{
+ public TestContext(DurableTaskClient client, FunctionContext executionContext)
+ {
+ this.ExecutionContext = executionContext;
+ this.Client = client;
+ this.Logger = executionContext.GetLogger(nameof(IsolatedEntities));
+ }
+
+ public FunctionContext ExecutionContext { get; }
+
+ public DurableTaskClient Client { get; }
+
+ public ILogger Logger { get; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ public bool BackendSupportsImplicitEntityDeletion { get; set; } = false; // false for Azure Storage, true for Netherite and MSSQL
+}
diff --git a/test/IsolatedEntities/Common/TestContextExtensions.cs b/test/IsolatedEntities/Common/TestContextExtensions.cs
new file mode 100644
index 000000000..b9e891f2f
--- /dev/null
+++ b/test/IsolatedEntities/Common/TestContextExtensions.cs
@@ -0,0 +1,77 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace IsolatedEntities;
+
+internal static class TestContextExtensions
+{
+ public static async Task WaitForEntityStateAsync(
+ this TestContext context,
+ EntityInstanceId entityInstanceId,
+ TimeSpan? timeout = null,
+ Func? describeWhatWeAreWaitingFor = null)
+ {
+ if (timeout == null)
+ {
+ timeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(30);
+ }
+
+ Stopwatch sw = Stopwatch.StartNew();
+
+ EntityMetadata? response;
+
+ do
+ {
+ response = await context.Client.Entities.GetEntityAsync(entityInstanceId, includeState: true);
+
+ if (response != null)
+ {
+ if (describeWhatWeAreWaitingFor == null)
+ {
+ break;
+ }
+ else
+ {
+ var waitForResult = describeWhatWeAreWaitingFor(response.State.ReadAs());
+
+ if (string.IsNullOrEmpty(waitForResult))
+ {
+ break;
+ }
+ else
+ {
+ context.Logger.LogInformation($"Waiting for {entityInstanceId} : {waitForResult}");
+ }
+ }
+ }
+ else
+ {
+ context.Logger.LogInformation($"Waiting for {entityInstanceId} to have state.");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(100));
+ }
+ while (sw.Elapsed < timeout);
+
+ if (response != null)
+ {
+ string serializedState = response.State.Value;
+ context.Logger.LogInformation($"Found state: {serializedState}");
+ return response.State.ReadAs();
+ }
+ else
+ {
+ throw new TimeoutException($"Durable entity '{entityInstanceId}' still doesn't have any state!");
+ }
+ }
+}
diff --git a/test/IsolatedEntities/Common/TestRunner.cs b/test/IsolatedEntities/Common/TestRunner.cs
new file mode 100644
index 000000000..17ca3bd17
--- /dev/null
+++ b/test/IsolatedEntities/Common/TestRunner.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace IsolatedEntities;
+
+internal static class TestRunner
+{
+ public static async Task RunAsync(TestContext context, string? filter = null, bool listOnly = false)
+ {
+ var output = new StringBuilder();
+
+ foreach (var test in All.GetAllTests())
+ {
+ if (filter == null || test.Name.ToLowerInvariant().Equals(filter.ToLowerInvariant()))
+ {
+ if (listOnly)
+ {
+ output.AppendLine(test.Name);
+ }
+ else
+ {
+ context.Logger.LogWarning("------------ starting {testName}", test.Name);
+
+ // if debugging, time out after 60m
+ // otherwise, time out either when the http request times out or when the individual test time limit is exceeded
+ using CancellationTokenSource cancellationTokenSource
+ = Debugger.IsAttached ? new() : CancellationTokenSource.CreateLinkedTokenSource(context.ExecutionContext.CancellationToken);
+ cancellationTokenSource.CancelAfter(Debugger.IsAttached ? TimeSpan.FromMinutes(60) : test.Timeout);
+ context.CancellationToken = cancellationTokenSource.Token;
+
+ try
+ {
+ await test.RunAsync(context);
+ output.AppendLine($"PASSED {test.Name}");
+ }
+ catch (Exception ex)
+ {
+ context.Logger.LogError(ex, "test {testName} failed", test.Name);
+ output.AppendLine($"FAILED {test.Name} {ex.ToString()}");
+ break;
+ }
+ }
+ }
+ }
+
+ return output.ToString();
+ }
+}
diff --git a/test/IsolatedEntities/Entities/BatchEntity.cs b/test/IsolatedEntities/Entities/BatchEntity.cs
new file mode 100644
index 000000000..7f352f827
--- /dev/null
+++ b/test/IsolatedEntities/Entities/BatchEntity.cs
@@ -0,0 +1,54 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Dataflow;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Extensions.DurableTask;
+using Microsoft.DurableTask;
+using Microsoft.DurableTask.Entities;
+
+namespace IsolatedEntities;
+
+///
+/// An entity that records all batch positions and batch sizes
+///
+class BatchEntity : ITaskEntity
+{
+ int operationCounter;
+
+ public ValueTask