From c36d3d99c222f42f6c954ca85ec68973692a74fc Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Thu, 7 Sep 2023 10:42:50 -0700 Subject: [PATCH 01/30] udpate readme. --- release_notes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/release_notes.md b/release_notes.md index bd9dde7db..eadfb3f9c 100644 --- a/release_notes.md +++ b/release_notes.md @@ -2,6 +2,9 @@ ### New Features +- Updates to take advantage of new core-entity support +- Support entities for Isolated + ### Bug Fixes ### Breaking Changes From 10111e0cbe0c474844c7a546b00b71f584254f44 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 11 Sep 2023 16:17:34 -0700 Subject: [PATCH 02/30] update durability provider class for new core-entities support. (#2570) * update durability provider class for new core-entities support. * add configuration setting for max entity concurrency to DurableTaskOptions * minor fixes. --- .../AzureStorageDurabilityProvider.cs | 27 +++++++++++++++++-- .../AzureStorageDurabilityProviderFactory.cs | 16 +++++++++++ .../DurabilityProvider.cs | 10 ++++++- .../Options/DurableTaskOptions.cs | 17 ++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProvider.cs index f395c23de..c386e1ed2 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProvider.cs @@ -9,6 +9,7 @@ using DurableTask.AzureStorage; using DurableTask.AzureStorage.Tracking; using DurableTask.Core; +using DurableTask.Core.Entities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -18,6 +19,7 @@ using Microsoft.Azure.WebJobs.Host.Scale; #endif using AzureStorage = DurableTask.AzureStorage; +using DTCore = DurableTask.Core; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask { @@ -53,8 +55,6 @@ public AzureStorageDurabilityProvider( this.logger = logger; } - public override bool SupportsEntities => true; - public override bool CheckStatusBeforeRaiseEvent => true; /// @@ -97,6 +97,29 @@ public async override Task> GetAllOrchestrationStatesW /// public async override Task RetrieveSerializedEntityState(EntityId entityId, JsonSerializerSettings serializerSettings) + { + EntityBackendQueries entityBackendQueries = (this.serviceClient as IEntityOrchestrationService)?.EntityBackendQueries; + + if (entityBackendQueries != null) // entity queries are natively supported + { + var entity = await entityBackendQueries.GetEntityAsync(new DTCore.Entities.EntityId(entityId.EntityName, entityId.EntityKey), cancellation: default); + + if (entity == null) + { + return null; + } + else + { + return entity.Value.SerializedState; + } + } + else // fall back to old implementation + { + return await this.LegacyImplementationOfRetrieveSerializedEntityState(entityId, serializerSettings); + } + } + + private async Task LegacyImplementationOfRetrieveSerializedEntityState(EntityId entityId, JsonSerializerSettings serializerSettings) { var instanceId = EntityId.GetSchedulerIdFromEntityId(entityId); IList stateList = await this.serviceClient.GetOrchestrationStateAsync(instanceId, false); diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs index c86fd2be1..5354b6b3c 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs @@ -19,6 +19,8 @@ internal class AzureStorageDurabilityProviderFactory : IDurabilityProviderFactor private readonly AzureStorageOptions azureStorageOptions; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; + private readonly bool useSeparateQueriesForEntities; + private readonly bool useSeparateQueueForEntityWorkItems; private readonly bool inConsumption; // If true, optimize defaults for consumption private AzureStorageDurabilityProvider defaultStorageProvider; @@ -56,6 +58,7 @@ public AzureStorageDurabilityProviderFactory( // different defaults for key configuration values. int maxConcurrentOrchestratorsDefault = this.inConsumption ? 5 : 10 * Environment.ProcessorCount; int maxConcurrentActivitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; + int maxConcurrentEntitiesDefault = this.inConsumption ? 10 : 10 * Environment.ProcessorCount; int maxEntityOperationBatchSizeDefault = this.inConsumption ? 50 : 5000; if (this.inConsumption) @@ -71,9 +74,19 @@ public AzureStorageDurabilityProviderFactory( } } + WorkerRuntimeType runtimeType = platformInfo.GetWorkerRuntimeType(); + if (runtimeType == WorkerRuntimeType.DotNetIsolated || + runtimeType == WorkerRuntimeType.Java || + runtimeType == WorkerRuntimeType.Custom) + { + this.useSeparateQueriesForEntities = true; + this.useSeparateQueueForEntityWorkItems = true; + } + // The following defaults are only applied if the customer did not explicitely set them on `host.json` this.options.MaxConcurrentOrchestratorFunctions = this.options.MaxConcurrentOrchestratorFunctions ?? maxConcurrentOrchestratorsDefault; this.options.MaxConcurrentActivityFunctions = this.options.MaxConcurrentActivityFunctions ?? maxConcurrentActivitiesDefault; + this.options.MaxConcurrentEntityFunctions = this.options.MaxConcurrentEntityFunctions ?? maxConcurrentEntitiesDefault; this.options.MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize ?? maxEntityOperationBatchSizeDefault; // Override the configuration defaults with user-provided values in host.json, if any. @@ -188,6 +201,7 @@ internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationSe WorkItemQueueVisibilityTimeout = this.azureStorageOptions.WorkItemQueueVisibilityTimeout, MaxConcurrentTaskOrchestrationWorkItems = this.options.MaxConcurrentOrchestratorFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), MaxConcurrentTaskActivityWorkItems = this.options.MaxConcurrentActivityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentOrchestratorFunctions)} needs a default value"), + MaxConcurrentTaskEntityWorkItems = this.options.MaxConcurrentEntityFunctions ?? throw new InvalidOperationException($"{nameof(this.options.MaxConcurrentEntityFunctions)} needs a default value"), ExtendedSessionsEnabled = this.options.ExtendedSessionsEnabled, ExtendedSessionIdleTimeout = extendedSessionTimeout, MaxQueuePollingInterval = this.azureStorageOptions.MaxQueuePollingInterval, @@ -202,6 +216,8 @@ internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationSe LoggerFactory = this.loggerFactory, UseLegacyPartitionManagement = this.azureStorageOptions.UseLegacyPartitionManagement, UseTablePartitionManagement = this.azureStorageOptions.UseTablePartitionManagement, + UseSeparateQueriesForEntities = this.useSeparateQueriesForEntities, + UseSeparateQueueForEntityWorkItems = this.useSeparateQueueForEntityWorkItems, }; if (this.inConsumption) diff --git a/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs index 2bc6f22e2..fbe0f1999 100644 --- a/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.History; using DurableTask.Core.Query; using Newtonsoft.Json; @@ -36,6 +37,7 @@ public class DurabilityProvider : private readonly string name; private readonly IOrchestrationService innerService; private readonly IOrchestrationServiceClient innerServiceClient; + private readonly IEntityOrchestrationService entityOrchestrationService; private readonly string connectionName; /// @@ -52,6 +54,7 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv this.name = storageProviderName ?? throw new ArgumentNullException(nameof(storageProviderName)); this.innerService = service ?? throw new ArgumentNullException(nameof(service)); this.innerServiceClient = serviceClient ?? throw new ArgumentNullException(nameof(serviceClient)); + this.entityOrchestrationService = service as IEntityOrchestrationService; this.connectionName = connectionName ?? throw new ArgumentNullException(connectionName); } @@ -64,7 +67,7 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv /// /// Specifies whether the durability provider supports Durable Entities. /// - public virtual bool SupportsEntities => false; + public virtual bool SupportsEntities => this.entityOrchestrationService != null; /// /// Specifies whether the backend's WaitForOrchestration is implemented without polling. @@ -101,6 +104,11 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv /// public virtual TimeSpan LongRunningTimerIntervalLength { get; set; } + /// + /// Returns the entity orchestration service, if this provider supports entities, or null otherwise. + /// + public virtual IEntityOrchestrationService EntityOrchestrationService => this.entityOrchestrationService; + /// /// Event source name (e.g. DurableTask-AzureStorage). /// diff --git a/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs b/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs index 47249848a..527d45070 100644 --- a/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs +++ b/src/WebJobs.Extensions.DurableTask/Options/DurableTaskOptions.cs @@ -98,6 +98,18 @@ public string HubName /// public int? MaxConcurrentOrchestratorFunctions { get; set; } = null; + /// + /// Gets or sets the maximum number of entity functions that can be processed concurrently on a single host instance. + /// + /// + /// Increasing entity function concurrency can result in increased throughput but can + /// also increase the total CPU and memory usage on a single worker instance. + /// + /// + /// A positive integer configured by the host. + /// + public int? MaxConcurrentEntityFunctions { get; set; } = null; + /// /// Gets or sets a value indicating whether to enable the local RPC endpoint managed by this extension. /// @@ -308,6 +320,11 @@ internal void Validate(INameResolver environmentVariableResolver, EndToEndTraceH throw new InvalidOperationException($"{nameof(this.MaxConcurrentOrchestratorFunctions)} must be a positive integer value."); } + if (this.MaxConcurrentEntityFunctions <= 0) + { + throw new InvalidOperationException($"{nameof(this.MaxConcurrentEntityFunctions)} must be a positive integer value."); + } + if (this.MaxEntityOperationBatchSize <= 0) { throw new InvalidOperationException($"{nameof(this.MaxEntityOperationBatchSize)} must be a positive integer value."); From 6ff3e7bc2bcac41af2de11fac824ee2bc127a58e Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 11 Sep 2023 16:23:10 -0700 Subject: [PATCH 03/30] update DurableClient to take advantage of native entity queries (#2571) * update DurableClient to take advantage of native entity queries if available * fix minor errors. * address PR feedback --- .../ContextImplementations/DurableClient.cs | 86 +++++++++++++++++++ .../DurableEntityStatus.cs | 20 +++++ 2 files changed, 106 insertions(+) diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs index 0c347e9aa..93703adf1 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs @@ -10,12 +10,14 @@ using System.Threading; using System.Threading.Tasks; using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.History; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.WebApiCompatShim; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using DTCore = DurableTask.Core; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask { @@ -548,6 +550,26 @@ Task> IDurableEntityClient.ReadEntityStateAsync(Entity } private async Task> ReadEntityStateAsync(DurabilityProvider provider, EntityId entityId) + { + if (this.HasNativeEntityQuerySupport(this.durabilityProvider, out var entityBackendQueries)) + { + EntityBackendQueries.EntityMetadata? metaData = await entityBackendQueries.GetEntityAsync( + new DTCore.Entities.EntityId(entityId.EntityName, entityId.EntityKey), + cancellation: default); + + return new EntityStateResponse() + { + EntityExists = metaData.HasValue, + EntityState = metaData.HasValue ? this.messageDataConverter.Deserialize(metaData.Value.SerializedState) : default, + }; + } + else + { + return await this.ReadEntityStateLegacyAsync(provider, entityId); + } + } + + private async Task> ReadEntityStateLegacyAsync(DurabilityProvider provider, EntityId entityId) { string entityState = await provider.RetrieveSerializedEntityState(entityId, this.messageDataConverter.JsonSettings); @@ -611,6 +633,40 @@ private static EntityQueryResult ConvertToEntityQueryResult(IEnumerable async Task IDurableEntityClient.ListEntitiesAsync(EntityQuery query, CancellationToken cancellationToken) + { + if (this.HasNativeEntityQuerySupport(this.durabilityProvider, out var entityBackendQueries)) + { + var result = await entityBackendQueries.QueryEntitiesAsync( + new EntityBackendQueries.EntityQuery() + { + InstanceIdStartsWith = query.EntityName != null ? $"${query.EntityName}" : null, + IncludeDeleted = query.IncludeDeleted, + IncludeState = query.FetchState, + LastModifiedFrom = query.LastOperationFrom == DateTime.MinValue ? null : query.LastOperationFrom, + LastModifiedTo = query.LastOperationTo, + PageSize = query.PageSize, + ContinuationToken = query.ContinuationToken, + }, + cancellationToken); + + return new EntityQueryResult() + { + Entities = result.Results.Select(ConvertEntityMetadata).ToList(), + ContinuationToken = result.ContinuationToken, + }; + + DurableEntityStatus ConvertEntityMetadata(EntityBackendQueries.EntityMetadata metadata) + { + return new DurableEntityStatus(metadata); + } + } + else + { + return await this.ListEntitiesLegacyAsync(query, cancellationToken); + } + } + + private async Task ListEntitiesLegacyAsync(EntityQuery query, CancellationToken cancellationToken) { var condition = new OrchestrationStatusQueryCondition(query); EntityQueryResult entityResult; @@ -633,6 +689,30 @@ async Task IDurableEntityClient.ListEntitiesAsync(EntityQuery /// async Task IDurableEntityClient.CleanEntityStorageAsync(bool removeEmptyEntities, bool releaseOrphanedLocks, CancellationToken cancellationToken) + { + if (this.HasNativeEntityQuerySupport(this.durabilityProvider, out var entityBackendQueries)) + { + var result = await entityBackendQueries.CleanEntityStorageAsync( + new EntityBackendQueries.CleanEntityStorageRequest() + { + RemoveEmptyEntities = removeEmptyEntities, + ReleaseOrphanedLocks = releaseOrphanedLocks, + }, + cancellationToken); + + return new CleanEntityStorageResult() + { + NumberOfEmptyEntitiesRemoved = result.EmptyEntitiesRemoved, + NumberOfOrphanedLocksRemoved = result.OrphanedLocksReleased, + }; + } + else + { + return await CleanEntityStorageLegacyAsync(removeEmptyEntities, releaseOrphanedLocks, cancellationToken); + } + } + + private async Task CleanEntityStorageLegacyAsync(bool removeEmptyEntities, bool releaseOrphanedLocks, CancellationToken cancellationToken) { DateTime now = DateTime.UtcNow; CleanEntityStorageResult finalResult = default; @@ -706,6 +786,12 @@ async Task CheckForOrphanedLockAndFixIt(DurableOrchestrationStatus status, strin return finalResult; } + private bool HasNativeEntityQuerySupport(DurabilityProvider provider, out EntityBackendQueries entityBackendQueries) + { + entityBackendQueries = provider.EntityOrchestrationService?.EntityBackendQueries; + return entityBackendQueries != null; + } + private async Task GetOrchestrationInstanceStateAsync(string instanceId) { return await GetOrchestrationInstanceStateAsync(this.client, instanceId); diff --git a/src/WebJobs.Extensions.DurableTask/DurableEntityStatus.cs b/src/WebJobs.Extensions.DurableTask/DurableEntityStatus.cs index 3142fba4e..2bc29598a 100644 --- a/src/WebJobs.Extensions.DurableTask/DurableEntityStatus.cs +++ b/src/WebJobs.Extensions.DurableTask/DurableEntityStatus.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.Serialization; +using DurableTask.Core.Entities; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -40,6 +41,25 @@ internal DurableEntityStatus(DurableOrchestrationStatus orchestrationStatus) } } + internal DurableEntityStatus(EntityBackendQueries.EntityMetadata metadata) + { + this.EntityId = new EntityId(metadata.EntityId.Name, metadata.EntityId.Key); + this.LastOperationTime = metadata.LastModifiedTime; + if (metadata.SerializedState != null) + { + try + { + // Entity state is expected to be JSON-compatible + this.State = JToken.Parse(metadata.SerializedState); + } + catch (JsonException) + { + // Just in case the above assumption is ever wrong, fallback to a raw string + this.State = metadata.SerializedState; + } + } + } + /// /// Gets the EntityId of the queried entity instance. /// From d1d6074a0c1ac321f682734cb396a187ae5d4d92 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 11 Sep 2023 16:36:05 -0700 Subject: [PATCH 04/30] implement passthrough middleware for entities (#2572) * implement passthrough middleware for entities. * propagate changes to protocol * update/simplify protobuf format * address PR feedback --- .../EntityTriggerAttributeBindingProvider.cs | 52 ++++-- .../RemoteEntityContext.cs | 28 +++ .../DurableTaskExtension.cs | 3 +- .../OutOfProcMiddleware.cs | 162 ++++++++++++++++++ .../ProtobufUtils.cs | 143 ++++++++++++++++ 5 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteEntityContext.cs diff --git a/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs b/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs index 3f3e8c02b..b1ab20d86 100644 --- a/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs @@ -9,6 +9,7 @@ using Microsoft.Azure.WebJobs.Host.Listeners; using Microsoft.Azure.WebJobs.Host.Protocols; using Microsoft.Azure.WebJobs.Host.Triggers; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask @@ -57,6 +58,8 @@ public EntityTriggerAttributeBindingProvider( private class EntityTriggerBinding : ITriggerBinding { + private static readonly IReadOnlyDictionary EmptyBindingData = new Dictionary(capacity: 0); + private readonly DurableTaskExtension config; private readonly ParameterInfo parameterInfo; private readonly FunctionName entityName; @@ -95,15 +98,16 @@ private static IReadOnlyDictionary GetBindingDataContract(Paramete public Task BindAsync(object value, ValueBindingContext context) { - var entityContext = (DurableEntityContext)value; - Type destinationType = this.parameterInfo.ParameterType; - - object? convertedValue = null; - if (destinationType == typeof(IDurableEntityContext)) + if (value is DurableEntityContext entityContext) { - convertedValue = entityContext; + Type destinationType = this.parameterInfo.ParameterType; + + object? convertedValue = null; + if (destinationType == typeof(IDurableEntityContext)) + { + convertedValue = entityContext; #if !FUNCTIONS_V1 - ((IDurableEntityContext)value).FunctionBindingContext = context.FunctionContext; + ((IDurableEntityContext)value).FunctionBindingContext = context.FunctionContext; #endif } else if (destinationType == typeof(string)) @@ -111,15 +115,35 @@ public Task BindAsync(object value, ValueBindingContext context) convertedValue = EntityContextToString(entityContext); } - var inputValueProvider = new ObjectValueProvider( - convertedValue ?? value, - this.parameterInfo.ParameterType); + var inputValueProvider = new ObjectValueProvider( + convertedValue ?? value, + this.parameterInfo.ParameterType); - var bindingData = new Dictionary(StringComparer.OrdinalIgnoreCase); - bindingData[this.parameterInfo.Name!] = convertedValue; + var bindingData = new Dictionary(StringComparer.OrdinalIgnoreCase); + bindingData[this.parameterInfo.Name!] = convertedValue; - var triggerData = new TriggerData(inputValueProvider, bindingData); - return Task.FromResult(triggerData); + var triggerData = new TriggerData(inputValueProvider, bindingData); + return Task.FromResult(triggerData); + } +#if FUNCTIONS_V3_OR_GREATER + else if (value is RemoteEntityContext remoteContext) + { + // Generate a byte array which is the serialized protobuf payload + // https://developers.google.com/protocol-buffers/docs/csharptutorial#parsing_and_serialization + var entityBatchRequest = remoteContext.Request.ToEntityBatchRequest(); + + // We convert the binary payload into a base64 string because that seems to be the most commonly supported + // format for Azure Functions language workers. Attempts to send unencoded byte[] payloads were unsuccessful. + string encodedRequest = ProtobufUtils.Base64Encode(entityBatchRequest); + var contextValueProvider = new ObjectValueProvider(encodedRequest, typeof(string)); + var triggerData = new TriggerData(contextValueProvider, EmptyBindingData); + return Task.FromResult(triggerData); + } +#endif + else + { + throw new ArgumentException($"Don't know how to bind to {value?.GetType().Name ?? "null"}.", nameof(value)); + } } public ParameterDescriptor ToParameterDescriptor() diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteEntityContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteEntityContext.cs new file mode 100644 index 000000000..07ea0e921 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteEntityContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#nullable enable +using System; +using System.Collections.Generic; +using DurableTask.Core; +using DurableTask.Core.Command; +using DurableTask.Core.Entities.OperationFormat; +using DurableTask.Core.History; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask +{ + internal class RemoteEntityContext + { + public RemoteEntityContext(EntityBatchRequest batchRequest) + { + this.Request = batchRequest; + } + + [JsonProperty("request")] + public EntityBatchRequest Request { get; private set; } + + [JsonIgnore] + internal EntityBatchResult? Result { get; set; } + } +} diff --git a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs index cc1e63976..ccc715547 100644 --- a/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs +++ b/src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs @@ -439,10 +439,11 @@ void IExtensionConfigProvider.Initialize(ExtensionConfigContext context) { #if FUNCTIONS_V3_OR_GREATER // This is a newer, more performant flavor of orchestration/activity middleware that is being - // enabled for newer language runtimes. Support for entities in this model is TBD. + // enabled for newer language runtimes. var ooprocMiddleware = new OutOfProcMiddleware(this); this.taskHubWorker.AddActivityDispatcherMiddleware(ooprocMiddleware.CallActivityAsync); this.taskHubWorker.AddOrchestrationDispatcherMiddleware(ooprocMiddleware.CallOrchestratorAsync); + this.taskHubWorker.AddEntityDispatcherMiddleware(ooprocMiddleware.CallEntityAsync); #else // This can happen if, for example, a Java user tries to use Durable Functions while targeting V2 or V3 extension bundles // because those bundles target .NET Core 2.2, which doesn't support the gRPC libraries used in the modern out-of-proc implementation. diff --git a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs index 1813c131c..434762fd1 100644 --- a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs +++ b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs @@ -7,10 +7,12 @@ using System.Linq; using System.Threading.Tasks; using DurableTask.Core; +using DurableTask.Core.Entities.OperationFormat; using DurableTask.Core.Exceptions; using DurableTask.Core.History; using DurableTask.Core.Middleware; using Microsoft.Azure.WebJobs.Host.Executors; +using Newtonsoft.Json; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask { @@ -235,6 +237,166 @@ await this.LifeCycleNotificationHelper.OrchestratorFailedAsync( dispatchContext.SetProperty(orchestratorResult); } + /// + /// Durable Task Framework entity middleware that invokes an out-of-process orchestrator function. + /// + /// This middleware context provided by the framework that contains information about the entity. + /// The next middleware handler in the pipeline. + /// Thrown if there is a recoverable error in the Functions runtime that's expected to be handled gracefully. + public async Task CallEntityAsync(DispatchMiddlewareContext dispatchContext, Func next) + { + EntityBatchRequest? batchRequest = dispatchContext.GetProperty(); + + if (batchRequest == null) + { + // This should never happen, and there's no good response we can return if it does. + throw new InvalidOperationException($"An entity was scheduled but no {nameof(EntityBatchRequest)} was found!"); + } + + if (batchRequest.InstanceId == null) + { + // This should never happen, and there's no good response we can return if it does. + throw new InvalidOperationException($"An entity was scheduled but InstanceId is null!"); + } + + EntityId entityId = EntityId.GetEntityIdFromSchedulerId(batchRequest.InstanceId); + FunctionName functionName = new FunctionName(entityId.EntityName); + RegisteredFunctionInfo functionInfo = this.extension.GetEntityInfo(functionName); + + void SetErrorResult(FailureDetails failureDetails) + { + // Returns a result with no operation results and no state change, + // and with failure details that explain what error was encountered. + dispatchContext.SetProperty(new EntityBatchResult() + { + Actions = { }, + EntityState = batchRequest!.EntityState, + Results = { }, + FailureDetails = failureDetails, + }); + } + + if (functionInfo == null) + { + SetErrorResult(new FailureDetails( + errorType: "EntityFunctionNotFound", + errorMessage: this.extension.GetInvalidEntityFunctionMessage(functionName.Name), + stackTrace: null, + innerFailure: null, + isNonRetriable: true)); + return; + } + + this.TraceHelper.FunctionStarting( + this.Options.HubName, + functionName.Name, + batchRequest.InstanceId, + this.extension.GetIntputOutputTrace(batchRequest.EntityState), + functionType: FunctionType.Entity, + isReplay: false); + + var context = new RemoteEntityContext(batchRequest); + + var input = new TriggeredFunctionData + { + TriggerValue = context, +#pragma warning disable CS0618 // Type or member is obsolete (not intended for general public use) + InvokeHandler = async functionInvoker => + { + // Invoke the function and look for a return value. Trigger return values are an undocumented feature that we depend on. + Task invokeTask = functionInvoker(); + if (invokeTask is not Task invokeTaskWithResult) + { + // This should never happen + throw new InvalidOperationException("The internal function invoker returned a task that does not support return values!"); + } + + // The return value is expected to be a base64 string containing the protobuf-encoding of the batch result. + string? triggerReturnValue = (await invokeTaskWithResult) as string; + if (string.IsNullOrEmpty(triggerReturnValue)) + { + throw new InvalidOperationException( + "The function invocation resulted in a null response. This means that either the entity function was implemented " + + "incorrectly, the Durable Task language SDK was implemented incorrectly, or that the destination language worker is not " + + "sending the function result back to the host."); + } + + byte[] triggerReturnValueBytes = Convert.FromBase64String(triggerReturnValue); + var response = Microsoft.DurableTask.Protobuf.EntityBatchResult.Parser.ParseFrom(triggerReturnValueBytes); + context.Result = response.ToEntityBatchResult(); + +#pragma warning restore CS0618 // Type or member is obsolete (not intended for general public use) + }, + }; + + FunctionResult functionResult; + try + { + functionResult = await functionInfo.Executor.TryExecuteAsync( + input, + cancellationToken: this.HostLifetimeService.OnStopping); + + if (!functionResult.Succeeded) + { + // Shutdown can surface as a completed invocation in a failed state. + // Re-throw so we can abort this invocation. + this.HostLifetimeService.OnStopping.ThrowIfCancellationRequested(); + } + } + catch (Exception hostRuntimeException) + { + string reason = this.HostLifetimeService.OnStopping.IsCancellationRequested ? + "The Functions/WebJobs runtime is shutting down!" : + $"Unhandled exception in the Functions/WebJobs runtime: {hostRuntimeException}"; + + this.TraceHelper.FunctionAborted( + this.Options.HubName, + functionName.Name, + batchRequest.InstanceId, + reason, + functionType: FunctionType.Entity); + + // This will abort the current execution and force an durable retry + throw new SessionAbortedException(reason); + } + + if (!functionResult.Succeeded) + { + this.TraceHelper.FunctionFailed( + this.Options.HubName, + functionName.Name, + batchRequest.InstanceId, + functionResult.Exception.ToString(), + FunctionType.Orchestrator, + isReplay: false); + + SetErrorResult(new FailureDetails( + errorType: "FunctionInvocationFailed", + errorMessage: $"Invocation of function '{functionName}' failed with an exception.", + stackTrace: null, + innerFailure: new FailureDetails(functionResult.Exception), + isNonRetriable: true)); + + return; + } + + EntityBatchResult batchResult = context.Result + ?? throw new InvalidOperationException($"The entity function executed successfully but {nameof(context.Result)} is still null!"); + + this.TraceHelper.FunctionCompleted( + this.Options.HubName, + functionName.Name, + batchRequest.InstanceId, + this.extension.GetIntputOutputTrace(batchRequest.EntityState), + batchResult.EntityState != null, + FunctionType.Entity, + isReplay: false); + + // Send the result of the orchestrator function to the DTFx dispatch pipeline. + // This allows us to bypass the default, in-process execution and process the given results immediately. + dispatchContext.SetProperty(batchResult); + } + /// /// Durable Task Framework activity middleware that invokes an out-of-process orchestrator function. /// diff --git a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs index b4cd992ef..e0a9b3853 100644 --- a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs +++ b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs @@ -5,10 +5,13 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using DurableTask.Core; using DurableTask.Core.Command; +using DurableTask.Core.Entities; +using DurableTask.Core.Entities.OperationFormat; using DurableTask.Core.History; using DurableTask.Core.Query; using Google.Protobuf; @@ -430,6 +433,146 @@ internal static P.PurgeInstancesResponse CreatePurgeInstancesResponse(PurgeResul DeletedInstanceCount = result.DeletedInstanceCount, }; } + + /// + /// Converts a to . + /// + /// The operation request to convert. + /// The converted operation request. + [return: NotNullIfNotNull("entityBatchRequest")] + internal static P.EntityBatchRequest? ToEntityBatchRequest(this EntityBatchRequest? entityBatchRequest) + { + if (entityBatchRequest == null) + { + return null; + } + + var batchRequest = new P.EntityBatchRequest() + { + InstanceId = entityBatchRequest.InstanceId, + EntityState = entityBatchRequest.EntityState, + }; + + foreach (var operation in entityBatchRequest.Operations ?? Enumerable.Empty()) + { + batchRequest.Operations.Add(operation.ToOperationRequest()); + } + + return batchRequest; + } + + /// + /// Converts a to . + /// + /// The operation request to convert. + /// The converted operation request. + [return: NotNullIfNotNull("operationRequest")] + internal static P.OperationRequest? ToOperationRequest(this OperationRequest? operationRequest) + { + if (operationRequest == null) + { + return null; + } + + return new P.OperationRequest() + { + Operation = operationRequest.Operation, + Input = operationRequest.Input, + RequestId = operationRequest.Id.ToString(), + }; + } + + /// + /// Converts a to a . + /// + /// The operation result to convert. + /// The converted operation result. + [return: NotNullIfNotNull("entityBatchResult")] + internal static EntityBatchResult? ToEntityBatchResult(this P.EntityBatchResult? entityBatchResult) + { + if (entityBatchResult == null) + { + return null; + } + + return new EntityBatchResult() + { + Actions = entityBatchResult.Actions.Select(operationAction => operationAction!.ToOperationAction()).ToList(), + EntityState = entityBatchResult.EntityState, + Results = entityBatchResult.Results.Select(operationResult => operationResult!.ToOperationResult()).ToList(), + }; + } + + /// + /// Converts a to a . + /// + /// The operation action to convert. + /// The converted operation action. + [return: NotNullIfNotNull("operationAction")] + internal static OperationAction? ToOperationAction(this P.OperationAction? operationAction) + { + if (operationAction == null) + { + return null; + } + + switch (operationAction.OperationActionTypeCase) + { + case P.OperationAction.OperationActionTypeOneofCase.SendSignal: + + return new SendSignalOperationAction() + { + Name = operationAction.SendSignal.Name, + Input = operationAction.SendSignal.Input, + InstanceId = operationAction.SendSignal.InstanceId, + ScheduledTime = operationAction.SendSignal.ScheduledTime?.ToDateTime(), + }; + + case P.OperationAction.OperationActionTypeOneofCase.StartNewOrchestration: + + return new StartNewOrchestrationOperationAction() + { + Name = operationAction.StartNewOrchestration.Name, + Input = operationAction.StartNewOrchestration.Input, + InstanceId = operationAction.StartNewOrchestration.InstanceId, + Version = operationAction.StartNewOrchestration.Version, + }; + default: + throw new NotSupportedException($"Deserialization of {operationAction.OperationActionTypeCase} is not supported."); + } + } + + /// + /// Converts a to a . + /// + /// The operation result to convert. + /// The converted operation result. + [return: NotNullIfNotNull("operationResult")] + internal static OperationResult? ToOperationResult(this P.OperationResult? operationResult) + { + if (operationResult == null) + { + return null; + } + + switch (operationResult.ResultTypeCase) + { + case P.OperationResult.ResultTypeOneofCase.Success: + return new OperationResult() + { + Result = operationResult.Success.Result, + }; + + case P.OperationResult.ResultTypeOneofCase.Failure: + return new OperationResult() + { + FailureDetails = GetFailureDetails(operationResult.Failure.FailureDetails), + }; + + default: + throw new NotSupportedException($"Deserialization of {operationResult.ResultTypeCase} is not supported."); + } + } } } #endif \ No newline at end of file From eb961e0c7fa51c00f3c2bf688510734a7b317163 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 11 Sep 2023 16:36:18 -0700 Subject: [PATCH 05/30] implement entity queries for grpc listener (#2573) * implement entity queries for grpc listener * propagate changes to protocol * update/simplify protobuf format --- .../LocalGrpcListener.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 3aef3d808..edaa3b66c 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -5,14 +5,17 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.History; using DurableTask.Core.Query; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.Extensions.Hosting; +using DTCore = DurableTask.Core; using P = Microsoft.DurableTask.Protobuf; namespace Microsoft.Azure.WebJobs.Extensions.DurableTask @@ -163,6 +166,95 @@ public override Task Hello(Empty request, ServerCallContext context) return new P.RaiseEventResponse(); } + public async override Task SignalEntity(P.SignalEntityRequest request, ServerCallContext context) + { + this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); + + EntityMessageEvent eventToSend = ClientEntityHelpers.EmitOperationSignal( + new OrchestrationInstance() { InstanceId = request.InstanceId }, + Guid.Parse(request.RequestId), + request.Name, + request.Input, + EntityMessageEvent.GetCappedScheduledTime( + DateTime.UtcNow, + entityOrchestrationService.EntityBackendProperties.MaximumSignalDelayTime, + request.ScheduledTime?.ToDateTime())); + + await durabilityProvider.SendTaskOrchestrationMessageAsync(eventToSend.AsTaskMessage()); + + // No fields in the response + return new P.SignalEntityResponse(); + } + + public async override Task GetEntity(P.GetEntityRequest request, ServerCallContext context) + { + this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); + + EntityBackendQueries.EntityMetadata? metaData = await entityOrchestrationService.EntityBackendQueries.GetEntityAsync( + DTCore.Entities.EntityId.FromString(request.InstanceId), + request.IncludeState, + includeDeleted: false, + context.CancellationToken); + + return new P.GetEntityResponse() + { + Exists = metaData.HasValue, + Entity = metaData.HasValue ? this.ConvertEntityMetadata(metaData.Value) : default, + }; + } + + public async override Task QueryEntities(P.QueryEntitiesRequest request, ServerCallContext context) + { + this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); + + P.EntityQuery query = request.Query; + EntityBackendQueries.EntityQueryResult result = await entityOrchestrationService.EntityBackendQueries.QueryEntitiesAsync( + new EntityBackendQueries.EntityQuery() + { + InstanceIdStartsWith = query.InstanceIdStartsWith, + LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), + LastModifiedTo = query.LastModifiedTo?.ToDateTime(), + IncludeDeleted = false, + IncludeState = query.IncludeState, + ContinuationToken = query.ContinuationToken, + PageSize = query.PageSize, + }, + context.CancellationToken); + + var response = new P.QueryEntitiesResponse() + { + ContinuationToken = result.ContinuationToken, + }; + + foreach (EntityBackendQueries.EntityMetadata entityMetadata in result.Results) + { + response.Entities.Add(this.ConvertEntityMetadata(entityMetadata)); + } + + return response; + } + + public async override Task CleanEntityStorage(P.CleanEntityStorageRequest request, ServerCallContext context) + { + this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); + + EntityBackendQueries.CleanEntityStorageResult result = await entityOrchestrationService.EntityBackendQueries.CleanEntityStorageAsync( + new EntityBackendQueries.CleanEntityStorageRequest() + { + RemoveEmptyEntities = request.RemoveEmptyEntities, + ReleaseOrphanedLocks = request.ReleaseOrphanedLocks, + ContinuationToken = request.ContinuationToken, + }, + context.CancellationToken); + + return new P.CleanEntityStorageResponse() + { + EmptyEntitiesRemoved = result.EmptyEntitiesRemoved, + OrphanedLocksReleased = result.OrphanedLocksReleased, + ContinuationToken = result.ContinuationToken, + }; + } + public async override Task TerminateInstance(P.TerminateRequest request, ServerCallContext context) { await this.GetClient(context).TerminateAsync(request.InstanceId, request.Output); @@ -321,6 +413,26 @@ private IDurableClient GetClient(ServerCallContext context) { return this.extension.GetClient(this.GetAttribute(context)); } + + private void CheckEntitySupport(ServerCallContext context, out DurabilityProvider durabilityProvider, out IEntityOrchestrationService entityOrchestrationService) + { + durabilityProvider = this.GetDurabilityProvider(context); + entityOrchestrationService = durabilityProvider.EntityOrchestrationService; + if (entityOrchestrationService == null) + { + throw new NotSupportedException($"The provider '{durabilityProvider.GetType().Name}' does not support entities."); + } + } + + private P.EntityMetadata ConvertEntityMetadata(EntityBackendQueries.EntityMetadata metaData) + { + return new P.EntityMetadata() + { + InstanceId = metaData.ToString(), + LastModifiedTime = metaData.LastModifiedTime.ToTimestamp(), + SerializedState = metaData.SerializedState, + }; + } } } } From cc7b93a334c70bb7033b87ed8c8039f36d985f10 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Tue, 19 Sep 2023 10:49:53 -0700 Subject: [PATCH 06/30] Various fixes (#2585) * durability provider must implement and pass-through IEntityOrchestrationService since it wraps the orchestration service * simple mistake * fix misunderstanding of initializer syntax (produced null, not empty list) * fix missing failure details * fix missing compile-time switch for trigger value type * fix missing optional arguments * fix missing override --- .../EntityTriggerAttributeBindingProvider.cs | 5 +++- .../ContextImplementations/DurableClient.cs | 4 +++- .../DurabilityProvider.cs | 24 +++++++++++++------ .../LocalGrpcListener.cs | 6 ++--- .../OutOfProcMiddleware.cs | 5 ++-- .../ProtobufUtils.cs | 1 + .../FunctionsDurableTaskClient.cs | 3 +++ 7 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs b/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs index b1ab20d86..8e30157c8 100644 --- a/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/Bindings/EntityTriggerAttributeBindingProvider.cs @@ -78,7 +78,10 @@ public EntityTriggerBinding( this.BindingDataContract = GetBindingDataContract(parameterInfo); } - public Type TriggerValueType => typeof(IDurableEntityContext); + // Out-of-proc V2 uses a different trigger value type + public Type TriggerValueType => this.config.OutOfProcProtocol == OutOfProcOrchestrationProtocol.MiddlewarePassthrough ? + typeof(RemoteEntityContext) : + typeof(IDurableEntityContext); public IReadOnlyDictionary BindingDataContract { get; } diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs index 93703adf1..0c33c6b19 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs @@ -555,6 +555,8 @@ private async Task> ReadEntityStateAsync(DurabilityPro { EntityBackendQueries.EntityMetadata? metaData = await entityBackendQueries.GetEntityAsync( new DTCore.Entities.EntityId(entityId.EntityName, entityId.EntityKey), + includeState: true, + includeDeleted: false, cancellation: default); return new EntityStateResponse() @@ -788,7 +790,7 @@ async Task CheckForOrphanedLockAndFixIt(DurableOrchestrationStatus status, strin private bool HasNativeEntityQuerySupport(DurabilityProvider provider, out EntityBackendQueries entityBackendQueries) { - entityBackendQueries = provider.EntityOrchestrationService?.EntityBackendQueries; + entityBackendQueries = (provider as IEntityOrchestrationService)?.EntityBackendQueries; return entityBackendQueries != null; } diff --git a/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs b/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs index fbe0f1999..e1d3016f4 100644 --- a/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/DurabilityProvider.cs @@ -28,7 +28,8 @@ public class DurabilityProvider : IOrchestrationService, IOrchestrationServiceClient, IOrchestrationServiceQueryClient, - IOrchestrationServicePurgeClient + IOrchestrationServicePurgeClient, + IEntityOrchestrationService { internal const string NoConnectionDetails = "default"; @@ -67,7 +68,7 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv /// /// Specifies whether the durability provider supports Durable Entities. /// - public virtual bool SupportsEntities => this.entityOrchestrationService != null; + public virtual bool SupportsEntities => this.entityOrchestrationService?.EntityBackendProperties != null; /// /// Specifies whether the backend's WaitForOrchestration is implemented without polling. @@ -104,11 +105,6 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv /// public virtual TimeSpan LongRunningTimerIntervalLength { get; set; } - /// - /// Returns the entity orchestration service, if this provider supports entities, or null otherwise. - /// - public virtual IEntityOrchestrationService EntityOrchestrationService => this.entityOrchestrationService; - /// /// Event source name (e.g. DurableTask-AzureStorage). /// @@ -129,6 +125,20 @@ public DurabilityProvider(string storageProviderName, IOrchestrationService serv /// public int MaxConcurrentTaskActivityWorkItems => this.GetOrchestrationService().MaxConcurrentTaskActivityWorkItems; + /// + EntityBackendProperties IEntityOrchestrationService.EntityBackendProperties => this.entityOrchestrationService?.EntityBackendProperties; + + /// + EntityBackendQueries IEntityOrchestrationService.EntityBackendQueries => this.entityOrchestrationService?.EntityBackendQueries; + + /// + Task IEntityOrchestrationService.LockNextOrchestrationWorkItemAsync(TimeSpan receiveTimeout, CancellationToken cancellationToken) + => this.entityOrchestrationService.LockNextOrchestrationWorkItemAsync(receiveTimeout, cancellationToken); + + /// + Task IEntityOrchestrationService.LockNextEntityWorkItemAsync(TimeSpan receiveTimeout, CancellationToken cancellationToken) + => this.entityOrchestrationService.LockNextEntityWorkItemAsync(receiveTimeout, cancellationToken); + internal string GetBackendInfo() { return this.GetOrchestrationService().ToString(); diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index edaa3b66c..243debfef 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -417,8 +417,8 @@ private IDurableClient GetClient(ServerCallContext context) private void CheckEntitySupport(ServerCallContext context, out DurabilityProvider durabilityProvider, out IEntityOrchestrationService entityOrchestrationService) { durabilityProvider = this.GetDurabilityProvider(context); - entityOrchestrationService = durabilityProvider.EntityOrchestrationService; - if (entityOrchestrationService == null) + entityOrchestrationService = durabilityProvider as IEntityOrchestrationService; + if (entityOrchestrationService?.EntityBackendProperties == null) { throw new NotSupportedException($"The provider '{durabilityProvider.GetType().Name}' does not support entities."); } @@ -428,7 +428,7 @@ private P.EntityMetadata ConvertEntityMetadata(EntityBackendQueries.EntityMetada { return new P.EntityMetadata() { - InstanceId = metaData.ToString(), + InstanceId = metaData.EntityId.ToString(), LastModifiedTime = metaData.LastModifiedTime.ToTimestamp(), SerializedState = metaData.SerializedState, }; diff --git a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs index 434762fd1..0ff803c6f 100644 --- a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs +++ b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs @@ -3,6 +3,7 @@ #nullable enable #if FUNCTIONS_V3_OR_GREATER using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; @@ -269,9 +270,9 @@ void SetErrorResult(FailureDetails failureDetails) // and with failure details that explain what error was encountered. dispatchContext.SetProperty(new EntityBatchResult() { - Actions = { }, + Actions = new List(), + Results = new List(), EntityState = batchRequest!.EntityState, - Results = { }, FailureDetails = failureDetails, }); } diff --git a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs index e0a9b3853..c3cb1ff38 100644 --- a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs +++ b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs @@ -500,6 +500,7 @@ internal static P.PurgeInstancesResponse CreatePurgeInstancesResponse(PurgeResul Actions = entityBatchResult.Actions.Select(operationAction => operationAction!.ToOperationAction()).ToList(), EntityState = entityBatchResult.EntityState, Results = entityBatchResult.Results.Select(operationResult => operationResult!.ToOperationResult()).ToList(), + FailureDetails = GetFailureDetails(entityBatchResult.FailureDetails), }; } diff --git a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs index 234192ece..d675f2ab2 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.DurableTask; using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; namespace Microsoft.Azure.Functions.Worker; @@ -25,6 +26,8 @@ public FunctionsDurableTaskClient(DurableTaskClient inner, string? queryString) public string? QueryString { get; } + public override DurableEntityClient Entities => this.inner.Entities; + public override ValueTask DisposeAsync() { // We do not dispose inner client as it has a longer life than this class. From db60e7ff05c3d78391f931954231ac44d943815b Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Tue, 19 Sep 2023 10:50:29 -0700 Subject: [PATCH 07/30] simplify how entities are excluded from instance queries (#2586) --- .../AzureStorageDurabilityProviderFactory.cs | 3 --- src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs index 5354b6b3c..1f6faaf00 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs @@ -19,7 +19,6 @@ internal class AzureStorageDurabilityProviderFactory : IDurabilityProviderFactor private readonly AzureStorageOptions azureStorageOptions; private readonly INameResolver nameResolver; private readonly ILoggerFactory loggerFactory; - private readonly bool useSeparateQueriesForEntities; private readonly bool useSeparateQueueForEntityWorkItems; private readonly bool inConsumption; // If true, optimize defaults for consumption private AzureStorageDurabilityProvider defaultStorageProvider; @@ -79,7 +78,6 @@ public AzureStorageDurabilityProviderFactory( runtimeType == WorkerRuntimeType.Java || runtimeType == WorkerRuntimeType.Custom) { - this.useSeparateQueriesForEntities = true; this.useSeparateQueueForEntityWorkItems = true; } @@ -216,7 +214,6 @@ internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationSe LoggerFactory = this.loggerFactory, UseLegacyPartitionManagement = this.azureStorageOptions.UseLegacyPartitionManagement, UseTablePartitionManagement = this.azureStorageOptions.UseTablePartitionManagement, - UseSeparateQueriesForEntities = this.useSeparateQueriesForEntities, UseSeparateQueueForEntityWorkItems = this.useSeparateQueueForEntityWorkItems, }; diff --git a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs index c3cb1ff38..b0c059e9e 100644 --- a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs +++ b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs @@ -366,6 +366,7 @@ internal static OrchestrationQuery ToOrchestrationQuery(P.QueryInstancesRequest ContinuationToken = request.Query.ContinuationToken, InstanceIdPrefix = request.Query.InstanceIdPrefix, FetchInputsAndOutputs = request.Query.FetchInputsAndOutputs, + ExcludeEntities = true, }; // Empty lists are not allowed by the underlying code that takes in an OrchestrationQuery. However, From 2c5a7e556c0cae38f62a9d98d25436ac6019608a Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Thu, 21 Sep 2023 13:30:44 -0700 Subject: [PATCH 08/30] add an entity example to the DotNetIsolated smoke test project. (#2584) * add an entity example to the DotNetIsolated smoke test project. * remove superfluous argument. * address PR feedback --- .../DotNetIsolated/Counter.cs | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs diff --git a/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs new file mode 100644 index 000000000..bf188572d --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs @@ -0,0 +1,142 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Text; +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 DotNetIsolated; + + +/// +/// A simple counter, demonstrating entity use. +/// +public class Counter +{ + public int CurrentValue { get; set; } + + public void Add(int amount) + { + this.CurrentValue += amount; + } + + public void Reset() + { + this.CurrentValue = 0; + } + + public int Get() + { + return this.CurrentValue; + } + + [Function(nameof(Counter))] + public static Task CounterEntity([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} + +/// +/// Provides three http triggers to test the counter entity. +/// +/// +/// (POST) send 5 increment signals to the counter instance @counter@aa: +/// curl http://localhost:7071/api/counter/aa -d '5' +/// (GET) read the current value of the counter instance @counter@aa: +/// curl http://localhost:7071/api/counter/aa +/// (DELETE) delete the counter instance @counter@aa: +/// curl http://localhost:7071/api/counter/aa -X delete +/// +public static class CounterTest +{ + [Function(nameof(SignalCounter))] + public static async Task SignalCounter( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "counter/{id}")] HttpRequestData request, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext, + CancellationToken cancellation, + string id) + { + ILogger logger = executionContext.GetLogger(nameof(Counter)); + + using StreamReader reader = new StreamReader(request.Body, Encoding.UTF8); + string body = await reader.ReadToEndAsync(); + if (! int.TryParse(body, out var count)) + { + var httpResponse = request.CreateResponse(System.Net.HttpStatusCode.BadRequest); + httpResponse.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + httpResponse.WriteString($"Request body must contain an integer that indicates the number of signals to send.\n"); + return httpResponse; + }; + + var entityId = new EntityInstanceId("Counter", id); + logger.LogInformation($"Sending {count} increment messages to {entityId}..."); + + await Parallel.ForEachAsync( + Enumerable.Range(0, count), + cancellation, + (int i, CancellationToken cancellation) => + { + return new ValueTask(client.Entities.SignalEntityAsync(entityId, "add", 1, cancellation:cancellation)); + }); + + logger.LogInformation($"Sent {count} increment messages to {entityId}."); + return request.CreateResponse(System.Net.HttpStatusCode.Accepted); + } + + [Function(nameof(ReadCounter))] + public static async Task ReadCounter( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "counter/{id}")] HttpRequestData request, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext, + string id) + { + ILogger logger = executionContext.GetLogger(nameof(Counter)); + var entityId = new EntityInstanceId("Counter", id); + + logger.LogInformation($"Reading state of {entityId}..."); + var response = await client.Entities.GetEntityAsync(entityId, includeState: true); + logger.LogInformation($"Read state of {entityId}: {response?.SerializedState ?? "(null: entity does not exist)"}"); + + if (response == null) + { + return request.CreateResponse(System.Net.HttpStatusCode.NotFound); + } + else + { + int currentValue = response.ReadStateAs()!.CurrentValue; + var httpResponse = request.CreateResponse(System.Net.HttpStatusCode.OK); + httpResponse.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + httpResponse.WriteString($"{currentValue}\n"); + return httpResponse; + } + } + + [Function(nameof(DeleteCounter))] + public static async Task DeleteCounter( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "counter/{id}")] HttpRequestData request, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext, + string id) + { + ILogger logger = executionContext.GetLogger(nameof(Counter)); + var entityId = new EntityInstanceId("Counter", id); + logger.LogInformation($"Deleting {entityId}..."); + + // All entities have a "delete" operation built in, so we can just send a signal + await client.Entities.SignalEntityAsync(entityId, "delete"); + + logger.LogInformation($"Sent deletion signal to {entityId}."); + return request.CreateResponse(System.Net.HttpStatusCode.OK); + } +} + + + From ac6e0d2e77409ac427598a1ebfe7d43c0d13e524 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Thu, 21 Sep 2023 13:34:23 -0700 Subject: [PATCH 09/30] Entities: Add worker side entity trigger and logic (#2576) * Add worker side entity trigger and logic * update comments * Address PR comments --- .../DurableTaskFunctionsMiddleware.cs | 68 +++++++++++++---- .../EntityTriggerAttribute.cs | 32 ++++++++ .../TaskEntityDispatcher.cs | 74 +++++++++++++++++++ 3 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 src/Worker.Extensions.DurableTask/EntityTriggerAttribute.cs create mode 100644 src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs diff --git a/src/Worker.Extensions.DurableTask/DurableTaskFunctionsMiddleware.cs b/src/Worker.Extensions.DurableTask/DurableTaskFunctionsMiddleware.cs index 24d2be295..3da4d35d5 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskFunctionsMiddleware.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskFunctionsMiddleware.cs @@ -15,41 +15,83 @@ namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask; internal class DurableTaskFunctionsMiddleware : IFunctionsWorkerMiddleware { /// - public async Task Invoke(FunctionContext functionContext, FunctionExecutionDelegate next) + public Task Invoke(FunctionContext functionContext, FunctionExecutionDelegate next) { - if (!IsOrchestrationTrigger(functionContext, out BindingMetadata? triggerMetadata)) + if (IsOrchestrationTrigger(functionContext, out BindingMetadata? triggerBinding)) { - await next(functionContext); - return; + return RunOrchestrationAsync(functionContext, triggerBinding, next); } - InputBindingData triggerInputData = await functionContext.BindInputAsync(triggerMetadata); + if (IsEntityTrigger(functionContext, out triggerBinding)) + { + return RunEntityAsync(functionContext, triggerBinding, next); + } + + return next(functionContext); + } + + private static bool IsOrchestrationTrigger( + FunctionContext context, [NotNullWhen(true)] out BindingMetadata? orchestrationTriggerBinding) + { + foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values) + { + if (string.Equals(binding.Type, "orchestrationTrigger", StringComparison.OrdinalIgnoreCase)) + { + orchestrationTriggerBinding = binding; + return true; + } + } + + orchestrationTriggerBinding = null; + return false; + } + + static async Task RunOrchestrationAsync( + FunctionContext context, BindingMetadata triggerBinding, FunctionExecutionDelegate next) + { + InputBindingData triggerInputData = await context.BindInputAsync(triggerBinding); if (triggerInputData?.Value is not string encodedOrchestratorState) { throw new InvalidOperationException("Orchestration history state was either missing from the input or not a string value."); } - FunctionsOrchestrator orchestrator = new(functionContext, next, triggerInputData); + FunctionsOrchestrator orchestrator = new(context, next, triggerInputData); string orchestratorOutput = GrpcOrchestrationRunner.LoadAndRun( - encodedOrchestratorState, orchestrator, functionContext.InstanceServices); + encodedOrchestratorState, orchestrator, context.InstanceServices); // Send the encoded orchestrator output as the return value seen by the functions host extension - functionContext.GetInvocationResult().Value = orchestratorOutput; + context.GetInvocationResult().Value = orchestratorOutput; } - private static bool IsOrchestrationTrigger( - FunctionContext context, [NotNullWhen(true)] out BindingMetadata? orchestrationTriggerBinding) + private static bool IsEntityTrigger( + FunctionContext context, [NotNullWhen(true)] out BindingMetadata? entityTriggerBinding) { foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values) { - if (string.Equals(binding.Type, "orchestrationTrigger")) + if (string.Equals(binding.Type, "entityTrigger", StringComparison.OrdinalIgnoreCase)) { - orchestrationTriggerBinding = binding; + entityTriggerBinding = binding; return true; } } - orchestrationTriggerBinding = null; + entityTriggerBinding = null; return false; } + + static async Task RunEntityAsync( + FunctionContext context, BindingMetadata triggerBinding, FunctionExecutionDelegate next) + { + InputBindingData triggerInputData = await context.BindInputAsync(triggerBinding); + if (triggerInputData?.Value is not string encodedEntityBatch) + { + throw new InvalidOperationException("Entity batch was either missing from the input or not a string value."); + } + + TaskEntityDispatcher dispatcher = new(encodedEntityBatch, context.InstanceServices); + triggerInputData.Value = dispatcher; + + await next(context); + context.GetInvocationResult().Value = dispatcher.Result; + } } diff --git a/src/Worker.Extensions.DurableTask/EntityTriggerAttribute.cs b/src/Worker.Extensions.DurableTask/EntityTriggerAttribute.cs new file mode 100644 index 000000000..8b9d450b3 --- /dev/null +++ b/src/Worker.Extensions.DurableTask/EntityTriggerAttribute.cs @@ -0,0 +1,32 @@ +// 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.Diagnostics; +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; + +namespace Microsoft.Azure.Functions.Worker; + +/// +/// Trigger attribute used for durable entity functions. +/// +/// +/// Entity triggers must bind to . +/// +[AttributeUsage(AttributeTargets.Parameter)] +[DebuggerDisplay("{EntityName}")] +public sealed class EntityTriggerAttribute : TriggerBindingAttribute +{ + /// + /// Gets or sets the name of the entity function. + /// + /// + /// If not specified, the function name is used as the name of the entity. + /// This property supports binding parameters. + /// + /// + /// The name of the entity function or null to use the function name. + /// + public string? EntityName { get; set; } +} diff --git a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs new file mode 100644 index 000000000..247759c2b --- /dev/null +++ b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs @@ -0,0 +1,74 @@ +// 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.Threading.Tasks; +using Microsoft.DurableTask.Entities; +using Microsoft.DurableTask.Worker.Grpc; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.Functions.Worker; + +/// +/// Represents a task entity dispatch invocation. +/// +/// +/// This type is used to aid in dispatching a to the operation receiver object. +/// +public sealed class TaskEntityDispatcher +{ + private readonly string request; + private readonly IServiceProvider services; + + internal TaskEntityDispatcher(string request, IServiceProvider services) + { + this.request = request; + this.services = services; + } + + internal string Result { get; private set; } = string.Empty; + + /// + /// Dispatches this entity trigger to the provided . + /// + /// The task entity to dispatch to. + /// A task that completes when the dispatch has finished. + public async Task DispatchAsync(ITaskEntity entity) + { + if (entity == null) + { + throw new ArgumentNullException(nameof(entity)); + } + + this.Result = await GrpcEntityRunner.LoadAndRunAsync(this.request, entity); + } + + /// + /// Dispatches the entity trigger to an instance of the provided . + /// + /// If is a , it will be activated from + /// and then be dispatched to. + /// + /// + /// If is not , it is assumed the + /// represents the entity state and it will be deserialized and dispatched directly to the state. + /// + /// + /// The type to dispatch to. + /// A task that completes when the dispatch has finished. + public Task DispatchAsync() + { + if (typeof(ITaskEntity).IsAssignableFrom(typeof(T))) + { + ITaskEntity entity = (ITaskEntity)ActivatorUtilities.GetServiceOrCreateInstance(this.services); + return this.DispatchAsync(entity); + } + + return this.DispatchAsync(new StateEntity()); + } + + private class StateEntity : TaskEntity + { + public override bool AllowStateDispatch => true; + } +} From 6251fcada1fdc70f4f0fdad1859b2fdf8b6152bf Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 25 Sep 2023 10:06:33 -0700 Subject: [PATCH 10/30] another small fix that got lost somewhere. (#2596) --- src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs index 247759c2b..dc50d861d 100644 --- a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs +++ b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs @@ -69,6 +69,6 @@ public Task DispatchAsync() private class StateEntity : TaskEntity { - public override bool AllowStateDispatch => true; + protected override bool AllowStateDispatch => true; } } From 06d0713d0320a46aed68ab49d32653c0bd9eeba0 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Tue, 26 Sep 2023 09:59:41 -0700 Subject: [PATCH 11/30] Update packages and version for entities preview (#2599) --- .../WebJobs.Extensions.DurableTask.csproj | 10 +++++----- src/Worker.Extensions.DurableTask/AssemblyInfo.cs | 2 +- .../Worker.Extensions.DurableTask.csproj | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 8ee4cb106..a041079ef 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -5,9 +5,9 @@ Microsoft.Azure.WebJobs.Extensions.DurableTask Microsoft.Azure.WebJobs.Extensions.DurableTask 2 - 11 - 3 - $(MajorVersion).$(MinorVersion).$(PatchVersion) + 12 + 0 + $(MajorVersion).$(MinorVersion).$(PatchVersion)-entities-preview.1 $(MajorVersion).$(MinorVersion).$(PatchVersion) $(MajorVersion).0.0.0 Microsoft Corporation @@ -104,8 +104,8 @@ - - + + diff --git a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs index 9c2e574e4..4df165636 100644 --- a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs +++ b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs @@ -4,4 +4,4 @@ using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; // TODO: Find a way to generate this dynamically at build-time -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.11.*")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.12.0-entities-preview.1")] diff --git a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj index 4c7aba5e3..5f8cbc086 100644 --- a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj +++ b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj @@ -29,7 +29,8 @@ ..\..\sign.snk - 1.0.3 + 1.1.0 + entities-preview.1 $(VersionPrefix).0 $(VersionPrefix).$(FileVersionRevision) @@ -38,8 +39,8 @@ - - + + From 5429a276e0989530ac795e10cbe467cecc2be540 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Wed, 27 Sep 2023 11:28:07 -0700 Subject: [PATCH 12/30] Switch to Microsoft.DurableTask.Grpc (#2605) --- .../ContextImplementations/DurableClient.cs | 2 +- .../LocalGrpcListener.cs | 13 +++++----- ...t.Azure.WebJobs.Extensions.DurableTask.xml | 24 +++++++++++++++++++ .../WebJobs.Extensions.DurableTask.csproj | 2 +- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs index 0c33c6b19..20fe48edf 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs @@ -710,7 +710,7 @@ async Task IDurableEntityClient.CleanEntityStorageAsyn } else { - return await CleanEntityStorageLegacyAsync(removeEmptyEntities, releaseOrphanedLocks, cancellationToken); + return await this.CleanEntityStorageLegacyAsync(removeEmptyEntities, releaseOrphanedLocks, cancellationToken); } } diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 4a6253a4e..24b65440b 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using DurableTask.Core; @@ -208,7 +207,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( request.Input, EntityMessageEvent.GetCappedScheduledTime( DateTime.UtcNow, - entityOrchestrationService.EntityBackendProperties.MaximumSignalDelayTime, + entityOrchestrationService.EntityBackendProperties!.MaximumSignalDelayTime, request.ScheduledTime?.ToDateTime())); await durabilityProvider.SendTaskOrchestrationMessageAsync(eventToSend.AsTaskMessage()); @@ -221,7 +220,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( { this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - EntityBackendQueries.EntityMetadata? metaData = await entityOrchestrationService.EntityBackendQueries.GetEntityAsync( + EntityBackendQueries.EntityMetadata? metaData = await entityOrchestrationService.EntityBackendQueries!.GetEntityAsync( DTCore.Entities.EntityId.FromString(request.InstanceId), request.IncludeState, includeDeleted: false, @@ -239,7 +238,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); P.EntityQuery query = request.Query; - EntityBackendQueries.EntityQueryResult result = await entityOrchestrationService.EntityBackendQueries.QueryEntitiesAsync( + EntityBackendQueries.EntityQueryResult result = await entityOrchestrationService.EntityBackendQueries!.QueryEntitiesAsync( new EntityBackendQueries.EntityQuery() { InstanceIdStartsWith = query.InstanceIdStartsWith, @@ -269,7 +268,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( { this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - EntityBackendQueries.CleanEntityStorageResult result = await entityOrchestrationService.EntityBackendQueries.CleanEntityStorageAsync( + EntityBackendQueries.CleanEntityStorageResult result = await entityOrchestrationService.EntityBackendQueries!.CleanEntityStorageAsync( new EntityBackendQueries.CleanEntityStorageRequest() { RemoveEmptyEntities = request.RemoveEmptyEntities, @@ -447,10 +446,10 @@ private OrchestrationStatus[] GetStatusesNotToOverride() private void CheckEntitySupport(ServerCallContext context, out DurabilityProvider durabilityProvider, out IEntityOrchestrationService entityOrchestrationService) { durabilityProvider = this.GetDurabilityProvider(context); - entityOrchestrationService = durabilityProvider as IEntityOrchestrationService; + entityOrchestrationService = durabilityProvider; if (entityOrchestrationService?.EntityBackendProperties == null) { - throw new NotSupportedException($"The provider '{durabilityProvider.GetType().Name}' does not support entities."); + throw new NotSupportedException($"The provider '{durabilityProvider.GetBackendInfo()}' does not support entities."); } } diff --git a/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml b/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml index 518e1305d..35960e225 100644 --- a/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml +++ b/src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml @@ -2026,6 +2026,18 @@ + + + + + + + + + + + + @@ -4287,6 +4299,18 @@ A positive integer configured by the host. + + + Gets or sets the maximum number of entity functions that can be processed concurrently on a single host instance. + + + Increasing entity function concurrency can result in increased throughput but can + also increase the total CPU and memory usage on a single worker instance. + + + A positive integer configured by the host. + + Gets or sets a value indicating whether to enable the local RPC endpoint managed by this extension. diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index a041079ef..9e1802c57 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -99,7 +99,7 @@ - + From 991c8f00bbd455c49f1dcba3ee3f78e196d482bb Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Wed, 4 Oct 2023 16:32:24 -0700 Subject: [PATCH 13/30] Fix grpc core (#2616) --- .../WebJobs.Extensions.DurableTask.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 9e1802c57..0d72171db 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -6,7 +6,7 @@ Microsoft.Azure.WebJobs.Extensions.DurableTask 2 12 - 0 + 1 $(MajorVersion).$(MinorVersion).$(PatchVersion)-entities-preview.1 $(MajorVersion).$(MinorVersion).$(PatchVersion) $(MajorVersion).0.0.0 @@ -100,6 +100,7 @@ + From 9f4cb5b833dcb3e96cbe579a7d533a3e349a8454 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Thu, 5 Oct 2023 09:00:39 -0700 Subject: [PATCH 14/30] new test suite for isolated entities. --- WebJobs.Extensions.DurableTask.sln | 7 + test/IsolatedEntities/Common/HttpTriggers.cs | 79 ++++++ .../Common/ProblematicObject.cs | 69 +++++ test/IsolatedEntities/Common/Test.cs | 19 ++ test/IsolatedEntities/Common/TestContext.cs | 34 +++ .../Common/TestContextExtensions.cs | 77 ++++++ test/IsolatedEntities/Common/TestRunner.cs | 57 ++++ test/IsolatedEntities/Entities/BatchEntity.cs | 54 ++++ test/IsolatedEntities/Entities/Counter.cs | 43 +++ .../IsolatedEntities/Entities/FaultyEntity.cs | 189 ++++++++++++++ test/IsolatedEntities/Entities/Launcher.cs | 63 +++++ test/IsolatedEntities/Entities/Relay.cs | 40 +++ .../Entities/SchedulerEntity.cs | 49 ++++ .../Entities/SelfSchedulingEntity.cs | 58 +++++ test/IsolatedEntities/Entities/StringStore.cs | 119 +++++++++ test/IsolatedEntities/IsolatedEntities.csproj | 33 +++ test/IsolatedEntities/Program.cs | 19 ++ test/IsolatedEntities/Tests/All.cs | 61 +++++ .../Tests/BatchedEntitySignals.cs | 68 +++++ test/IsolatedEntities/Tests/CallAndDelete.cs | 102 ++++++++ test/IsolatedEntities/Tests/CallCounter.cs | 59 +++++ .../Tests/CallFaultyEntity.cs | 142 ++++++++++ .../Tests/CallFaultyEntityBatches.cs | 147 +++++++++++ .../Tests/CleanOrphanedLock.cs | 133 ++++++++++ test/IsolatedEntities/Tests/EntityQueries1.cs | 246 ++++++++++++++++++ test/IsolatedEntities/Tests/EntityQueries2.cs | 147 +++++++++++ test/IsolatedEntities/Tests/FireAndForget.cs | 90 +++++++ .../IsolatedEntities/Tests/InvalidEntityId.cs | 81 ++++++ test/IsolatedEntities/Tests/LargeEntity.cs | 85 ++++++ .../Tests/MultipleLockedTransfers.cs | 85 ++++++ test/IsolatedEntities/Tests/SelfScheduling.cs | 36 +++ test/IsolatedEntities/Tests/SetAndGet.cs | 47 ++++ test/IsolatedEntities/Tests/SignalAndCall.cs | 68 +++++ test/IsolatedEntities/Tests/SignalThenPoll.cs | 93 +++++++ .../Tests/SingleLockedTransfer.cs | 104 ++++++++ test/IsolatedEntities/host.json | 16 ++ test/IsolatedEntities/local.settings.json | 7 + 37 files changed, 2826 insertions(+) create mode 100644 test/IsolatedEntities/Common/HttpTriggers.cs create mode 100644 test/IsolatedEntities/Common/ProblematicObject.cs create mode 100644 test/IsolatedEntities/Common/Test.cs create mode 100644 test/IsolatedEntities/Common/TestContext.cs create mode 100644 test/IsolatedEntities/Common/TestContextExtensions.cs create mode 100644 test/IsolatedEntities/Common/TestRunner.cs create mode 100644 test/IsolatedEntities/Entities/BatchEntity.cs create mode 100644 test/IsolatedEntities/Entities/Counter.cs create mode 100644 test/IsolatedEntities/Entities/FaultyEntity.cs create mode 100644 test/IsolatedEntities/Entities/Launcher.cs create mode 100644 test/IsolatedEntities/Entities/Relay.cs create mode 100644 test/IsolatedEntities/Entities/SchedulerEntity.cs create mode 100644 test/IsolatedEntities/Entities/SelfSchedulingEntity.cs create mode 100644 test/IsolatedEntities/Entities/StringStore.cs create mode 100644 test/IsolatedEntities/IsolatedEntities.csproj create mode 100644 test/IsolatedEntities/Program.cs create mode 100644 test/IsolatedEntities/Tests/All.cs create mode 100644 test/IsolatedEntities/Tests/BatchedEntitySignals.cs create mode 100644 test/IsolatedEntities/Tests/CallAndDelete.cs create mode 100644 test/IsolatedEntities/Tests/CallCounter.cs create mode 100644 test/IsolatedEntities/Tests/CallFaultyEntity.cs create mode 100644 test/IsolatedEntities/Tests/CallFaultyEntityBatches.cs create mode 100644 test/IsolatedEntities/Tests/CleanOrphanedLock.cs create mode 100644 test/IsolatedEntities/Tests/EntityQueries1.cs create mode 100644 test/IsolatedEntities/Tests/EntityQueries2.cs create mode 100644 test/IsolatedEntities/Tests/FireAndForget.cs create mode 100644 test/IsolatedEntities/Tests/InvalidEntityId.cs create mode 100644 test/IsolatedEntities/Tests/LargeEntity.cs create mode 100644 test/IsolatedEntities/Tests/MultipleLockedTransfers.cs create mode 100644 test/IsolatedEntities/Tests/SelfScheduling.cs create mode 100644 test/IsolatedEntities/Tests/SetAndGet.cs create mode 100644 test/IsolatedEntities/Tests/SignalAndCall.cs create mode 100644 test/IsolatedEntities/Tests/SignalThenPoll.cs create mode 100644 test/IsolatedEntities/Tests/SingleLockedTransfer.cs create mode 100644 test/IsolatedEntities/host.json create mode 100644 test/IsolatedEntities/local.settings.json diff --git a/WebJobs.Extensions.DurableTask.sln b/WebJobs.Extensions.DurableTask.sln index d821ef64a..8e2c8476f 100644 --- a/WebJobs.Extensions.DurableTask.sln +++ b/WebJobs.Extensions.DurableTask.sln @@ -98,6 +98,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 @@ -182,6 +184,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 @@ -215,6 +221,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..53be17d03 --- /dev/null +++ b/test/IsolatedEntities/Common/TestContext.cs @@ -0,0 +1,34 @@ +// 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; } +} 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..e6f9b4fff --- /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 sb = new StringBuilder(); + + foreach (var test in All.GetAllTests()) + { + if (filter == null || test.Name.ToLowerInvariant().Equals(filter.ToLowerInvariant())) + { + if (listOnly) + { + sb.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); + sb.AppendLine($"PASSED {test.Name}"); + } + catch (Exception ex) + { + context.Logger.LogError(ex, "test {testName} failed", test.Name); + sb.AppendLine($"FAILED {test.Name} {ex.ToString()}"); + break; + } + } + } + } + + return sb.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 RunAsync(TaskEntityOperation operation) + { + List? state = (List?) operation.State.GetState(typeof(List)); + int batchNo; + if (state == null) + { + batchNo = 0; + state = new List(); + } + else if (operationCounter == 0) + { + batchNo = state.Last().batch + 1; + } + else + { + batchNo = state.Last().batch; + } + + state.Add(new Entry(batchNo, operationCounter++)); + operation.State.SetState(state); + return default; + } + + public record struct Entry(int batch, int operation); + + [Function(nameof(BatchEntity))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(new BatchEntity()); + } +} diff --git a/test/IsolatedEntities/Entities/Counter.cs b/test/IsolatedEntities/Entities/Counter.cs new file mode 100644 index 000000000..8c5c21ae8 --- /dev/null +++ b/test/IsolatedEntities/Entities/Counter.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace IsolatedEntities; + +class Counter : TaskEntity +{ + public void Increment() + { + this.State++; + } + + public void Add(int amount) + { + this.State += amount; + } + + public int Get() + { + return this.State; + } + + public void Set(int value) + { + this.State = value; + } + + [Function(nameof(Counter))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} diff --git a/test/IsolatedEntities/Entities/FaultyEntity.cs b/test/IsolatedEntities/Entities/FaultyEntity.cs new file mode 100644 index 000000000..eba7bb250 --- /dev/null +++ b/test/IsolatedEntities/Entities/FaultyEntity.cs @@ -0,0 +1,189 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace IsolatedEntities +{ + // we use a low-level ITaskEntity so we can intercept some of the operations without going through + // the default sequence of serialization and deserialization of state. This is needed to construct + // this type of test, it does not reflect typical useage. + public class FaultyEntity : ITaskEntity + { + class State + { + [JsonInclude] + public int Value { get; set; } + + [JsonInclude] + [JsonConverter(typeof(CustomSerialization.ProblematicObjectJsonConverter))] + public CustomSerialization.ProblematicObject? ProblematicObject { get; set; } + + [JsonInclude] + public int NumberIncrementsSent { get; set; } + + public Task Send(EntityInstanceId target, TaskEntityContext context) + { + var desc = $"{++this.NumberIncrementsSent}:{this.Value}"; + context.SignalEntity(target, desc); + return Task.CompletedTask; + } + } + + [Function(nameof(FaultyEntity))] + public async Task EntryPoint([EntityTrigger] TaskEntityDispatcher dispatcher) + { + await dispatcher.DispatchAsync(); + } + + public static void ThrowTestException() + { + throw new TestException("KABOOM"); + } + + [Serializable] + public class TestException : Exception + { + public TestException() : base() { } + public TestException(string message) : base(message) { } + public TestException(string message, Exception inner) : base(message, inner) { } + } + + public async ValueTask RunAsync(TaskEntityOperation operation) + { + State? Get() + { + return (State?)operation.State.GetState(typeof(State)); + } + State GetOrCreate() + { + State? s = Get(); + if (s is null) + { + operation.State.SetState(s = new State()); + } + return s; + } + + switch (operation.Name) + { + case "Exists": + { + try + { + return Get() != null; + } + catch (Exception) // the entity has state, even if that state is corrupted + { + return true; + } + } + case "Delay": + { + int delayInSeconds = (int)operation.GetInput(typeof(int))!; + await Task.Delay(TimeSpan.FromSeconds(delayInSeconds)); + return default; + } + case "Delete": + { + operation.State.SetState(null); + return default; + } + case "DeleteWithoutReading": + { + // do not read the state first otherwise the deserialization may throw before we can delete it + operation.State.SetState(null); + return default; + } + case "DeleteThenThrow": + { + operation.State.SetState(null); + ThrowTestException(); + return default; + } + case "Throw": + { + ThrowTestException(); + return default; + } + case "Get": + { + return GetOrCreate().Value; + } + case "GetNumberIncrementsSent": + { + return GetOrCreate().NumberIncrementsSent; + } + case "Set": + { + State state = GetOrCreate(); + state.Value = (int)operation.GetInput(typeof(int))!; + operation.State.SetState(state); + return default; + } + case "SetToUnserializable": + { + State state = GetOrCreate(); + state.ProblematicObject = CustomSerialization.CreateUnserializableObject(); + operation.State.SetState(state); + return default; + } + case "SetToUndeserializable": + { + State state = GetOrCreate(); + state.ProblematicObject = CustomSerialization.CreateUndeserializableObject(); + operation.State.SetState(state); + return default; + } + case "SetThenThrow": + { + State state = GetOrCreate(); + state.Value = (int)operation.GetInput(typeof(int))!; + operation.State.SetState(state); + ThrowTestException(); + return default; + } + case "Send": + { + State state = GetOrCreate(); + EntityInstanceId entityId = (EntityInstanceId)operation.GetInput(typeof(EntityId))!; + await state.Send(entityId, operation.Context); + operation.State.SetState(state); + return default; + } + case "SendThenThrow": + { + State state = GetOrCreate(); + EntityInstanceId entityId = (EntityInstanceId)operation.GetInput(typeof(EntityId))!; + await state.Send(entityId, operation.Context); + operation.State.SetState(state); + ThrowTestException(); + return default; + } + case "SendThenMakeUnserializable": + { + State state = GetOrCreate(); + EntityInstanceId entityId = (EntityInstanceId)operation.GetInput(typeof(EntityId))!; + await state.Send(entityId, operation.Context); + state.ProblematicObject = CustomSerialization.CreateUnserializableObject(); + operation.State.SetState(state); + return default; + } + default: + { + throw new InvalidOperationException($"undefined entity operation: {operation.Name}"); + } + } + } + } +} diff --git a/test/IsolatedEntities/Entities/Launcher.cs b/test/IsolatedEntities/Entities/Launcher.cs new file mode 100644 index 000000000..98b82a534 --- /dev/null +++ b/test/IsolatedEntities/Entities/Launcher.cs @@ -0,0 +1,63 @@ +// 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 Castle.Core.Logging; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace IsolatedEntities; + +class Launcher +{ + public string? OrchestrationInstanceId { get; set; } + + public DateTime? ScheduledTime { get; set; } + + public bool IsDone { get; set; } + + public string? ErrorMessage { get; set; } + + public void Launch(TaskEntityContext context, DateTime? scheduledTime = null) + { + this.OrchestrationInstanceId = context.ScheduleNewOrchestration( + nameof(FireAndForget.SignallingOrchestration), + context.Id, + new StartOrchestrationOptions(StartAt: scheduledTime)); + } + + public string? Get() + { + if (this.ErrorMessage != null) + { + throw new Exception(this.ErrorMessage); + } + return this.IsDone ? this.OrchestrationInstanceId : null; + } + + public void Done() + { + this.IsDone = true; + + if (this.ScheduledTime != null) + { + DateTime now = DateTime.UtcNow; + if (now < this.ScheduledTime) + { + this.ErrorMessage = $"delay was too short, expected >= {this.ScheduledTime}, actual = {now}"; + } + } + } + + [Function(nameof(Launcher))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} diff --git a/test/IsolatedEntities/Entities/Relay.cs b/test/IsolatedEntities/Entities/Relay.cs new file mode 100644 index 000000000..b25a4fc47 --- /dev/null +++ b/test/IsolatedEntities/Entities/Relay.cs @@ -0,0 +1,40 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace IsolatedEntities; + +/// +/// A stateless entity that forwards signals +/// +class Relay : ITaskEntity +{ + [Function(nameof(Relay))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } + + public record Input(EntityInstanceId entityInstanceId, string operationName, DateTimeOffset? scheduledTime); + + public ValueTask RunAsync(TaskEntityOperation operation) + { + T GetInput() => (T)operation.GetInput(typeof(T))!; + + Input input = GetInput(); + + operation.Context.SignalEntity(input.entityInstanceId, input.operationName, new SignalEntityOptions() { SignalTime = input.scheduledTime }); + + return default; + } +} diff --git a/test/IsolatedEntities/Entities/SchedulerEntity.cs b/test/IsolatedEntities/Entities/SchedulerEntity.cs new file mode 100644 index 000000000..a31322411 --- /dev/null +++ b/test/IsolatedEntities/Entities/SchedulerEntity.cs @@ -0,0 +1,49 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace IsolatedEntities; + +class SchedulerEntity : ITaskEntity +{ + private readonly ILogger logger; + + public SchedulerEntity(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(SchedulerEntity))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } + + public ValueTask RunAsync(TaskEntityOperation operation) + { + this.logger.LogInformation("{entityId} received {operationName} signal", operation.Context.Id, operation.Name); + + List state = (List?)operation.State.GetState(typeof(List)) ?? new List(); + + if (state.Contains(operation.Name)) + { + this.logger.LogError($"duplicate: {operation.Name}"); + } + else + { + state.Add(operation.Name); + } + + return default; + } +} diff --git a/test/IsolatedEntities/Entities/SelfSchedulingEntity.cs b/test/IsolatedEntities/Entities/SelfSchedulingEntity.cs new file mode 100644 index 000000000..39ea0d0cb --- /dev/null +++ b/test/IsolatedEntities/Entities/SelfSchedulingEntity.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask.Entities; + +namespace IsolatedEntities +{ + public class SelfSchedulingEntity + { + public string Value { get; set; } = ""; + + public void Start(TaskEntityContext context) + { + var now = DateTime.UtcNow; + + var timeA = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(1); + var timeB = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2); + var timeC = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(3); + var timeD = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(4); + + context.SignalEntity(context.Id, nameof(D), options: timeD); + context.SignalEntity(context.Id, nameof(C), options: timeC); + context.SignalEntity(context.Id, nameof(B), options: timeB); + context.SignalEntity(context.Id, nameof(A), options: timeA); + } + + public void A() + { + this.Value += "A"; + } + + public Task B() + { + this.Value += "B"; + return Task.Delay(100); + } + + public void C() + { + this.Value += "C"; + } + + public Task D() + { + this.Value += "D"; + return Task.FromResult(111); + } + + [Function(nameof(SelfSchedulingEntity))] + public static Task SelfSchedulingEntityFunction([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } + } +} diff --git a/test/IsolatedEntities/Entities/StringStore.cs b/test/IsolatedEntities/Entities/StringStore.cs new file mode 100644 index 000000000..03c87b15c --- /dev/null +++ b/test/IsolatedEntities/Entities/StringStore.cs @@ -0,0 +1,119 @@ +// 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.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace IsolatedEntities; + +// three variations of the same simple entity: an entity that stores a string +// supporting get, set, and delete operations. There are slight semantic differences. + +//-------------- a class-based implementation ----------------- + +public class StringStore +{ + [JsonInclude] + public string Value { get; set; } + + public StringStore() + { + this.Value = string.Empty; + } + + public string Get() + { + return this.Value; + } + + public void Set(string value) + { + this.Value = value; + } + + // Delete is implicitly defined + + [Function(nameof(StringStore))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} + +//-------------- a TaskEntity-based implementation ----------------- + +public class StringStore2 : TaskEntity +{ + public string Get() + { + return this.State; + } + + public void Set(string value) + { + this.State = value; + } + + protected override string InitializeState(TaskEntityOperation operation) + { + return string.Empty; + } + + // Delete is implicitly defined + + [Function(nameof(StringStore2))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} + +//-------------- a direct ITaskEntity-based implementation ----------------- + +class StringStore3 : ITaskEntity +{ + public ValueTask RunAsync(TaskEntityOperation operation) + { + switch (operation.Name) + { + case "set": + operation.State.SetState((string?)operation.GetInput(typeof(string))); + return default; + + case "get": + // note: this does not assign a state to the entity if it does not already exist + return new ValueTask((string?)operation.State.GetState(typeof(string))); + + case "delete": + if (operation.State.GetState(typeof(string)) == null) + { + return new ValueTask(false); + } + else + { + operation.State.SetState(null); + return new ValueTask(true); + } + + default: + throw new NotImplementedException("no such operation"); + } + } + + [Function(nameof(StringStore3))] + public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) + { + return dispatcher.DispatchAsync(); + } +} + + diff --git a/test/IsolatedEntities/IsolatedEntities.csproj b/test/IsolatedEntities/IsolatedEntities.csproj new file mode 100644 index 000000000..7db30d5ce --- /dev/null +++ b/test/IsolatedEntities/IsolatedEntities.csproj @@ -0,0 +1,33 @@ + + + net6.0 + v4 + exe + false + enable + enable + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + diff --git a/test/IsolatedEntities/Program.cs b/test/IsolatedEntities/Program.cs new file mode 100644 index 000000000..ae3ff181c --- /dev/null +++ b/test/IsolatedEntities/Program.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 Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace IsolatedEntities; + +public class Program +{ + public static void Main() + { + IHost host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + host.Run(); + } +} diff --git a/test/IsolatedEntities/Tests/All.cs b/test/IsolatedEntities/Tests/All.cs new file mode 100644 index 000000000..4a26f735c --- /dev/null +++ b/test/IsolatedEntities/Tests/All.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Diagnostics; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Azure.Core; +using IsolatedEntities.Tests; +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; +using Xunit; + +namespace IsolatedEntities; + +/// +/// A collection containing all the unit tests. +/// +static class All +{ + public static IEnumerable GetAllTests() + { + yield return new SetAndGet(); + yield return new CallCounter(); + yield return new BatchedEntitySignals(100); + yield return new SignalAndCall(typeof(StringStore)); + yield return new SignalAndCall(typeof(StringStore2)); + yield return new SignalAndCall(typeof(StringStore3)); + yield return new CallAndDelete(typeof(StringStore)); + yield return new CallAndDelete(typeof(StringStore2)); + yield return new CallAndDelete(typeof(StringStore3)); + yield return new SignalThenPoll(direct: true, delayed: false); + yield return new SignalThenPoll(direct: true, delayed: true); + yield return new SignalThenPoll(direct: false, delayed: false); + yield return new SignalThenPoll(direct: false, delayed: true); + yield return new SelfScheduling(); + yield return new FireAndForget(null); + yield return new FireAndForget(0); + yield return new FireAndForget(5); + yield return new SingleLockedTransfer(); + yield return new MultipleLockedTransfers(2); + yield return new MultipleLockedTransfers(5); + yield return new MultipleLockedTransfers(100); + yield return new LargeEntity(); + yield return new CallFaultyEntity(); + yield return new CallFaultyEntityBatches(); + yield return new EntityQueries1(); + yield return new EntityQueries2(); + yield return new CleanOrphanedLock(); + yield return new InvalidEntityId(InvalidEntityId.Location.ClientGet); + yield return new InvalidEntityId(InvalidEntityId.Location.ClientSignal); + yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationCall); + yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationSignal); + } + +} diff --git a/test/IsolatedEntities/Tests/BatchedEntitySignals.cs b/test/IsolatedEntities/Tests/BatchedEntitySignals.cs new file mode 100644 index 000000000..6cc7ea4dd --- /dev/null +++ b/test/IsolatedEntities/Tests/BatchedEntitySignals.cs @@ -0,0 +1,68 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class BatchedEntitySignals : Test +{ + readonly int numIterations; + + public BatchedEntitySignals(int numIterations) + { + this.numIterations = numIterations; + } + + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(BatchEntity), Guid.NewGuid().ToString().Substring(0,8)); + + // send a number of signals immediately after each other + List tasks = new List(); + for (int i = 0; i < numIterations; i++) + { + tasks.Add(context.Client.Entities.SignalEntityAsync(entityId, string.Empty, i)); + } + + await Task.WhenAll(tasks); + + var result = await context.WaitForEntityStateAsync>( + entityId, + timeout: default, + list => list.Count == this.numIterations ? null : $"waiting for {this.numIterations - list.Count} signals"); + + Assert.Equal(new BatchEntity.Entry(0, 0), result[0]); + Assert.Equal(this.numIterations, result.Count); + + for (int i = 0; i < numIterations - 1; i++) + { + if (result[i].batch == result[i + 1].batch) + { + Assert.Equal(result[i].operation + 1, result[i + 1].operation); + } + else + { + Assert.Equal(result[i].batch + 1, result[i + 1].batch); + Assert.Equal(0, result[i + 1].operation); + } + } + + // there should always be some batching going on + int numBatches = result.Last().batch + 1; + Assert.True(numBatches < numIterations); + context.Logger.LogInformation($"completed {numIterations} operations in {numBatches} batches"); + } +} diff --git a/test/IsolatedEntities/Tests/CallAndDelete.cs b/test/IsolatedEntities/Tests/CallAndDelete.cs new file mode 100644 index 000000000..7f242c1c7 --- /dev/null +++ b/test/IsolatedEntities/Tests/CallAndDelete.cs @@ -0,0 +1,102 @@ +// 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.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class CallAndDelete : Test +{ + private readonly Type stringStoreType; + + public CallAndDelete(Type stringStoreType) + { + this.stringStoreType = stringStoreType; + } + + public override string Name => $"{base.Name}.{this.stringStoreType.Name}"; + + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(this.stringStoreType.Name, Guid.NewGuid().ToString()); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(CallAndDeleteOrchestration), entityId); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + + // check that entity was deleted + var entityMetadata = await context.Client.Entities.GetEntityAsync(entityId); + Assert.Null(entityMetadata); + } + + static bool GetOperationInitializesEntity(EntityInstanceId entityInstanceId) + => !string.Equals(entityInstanceId.Name, nameof(StringStore3).ToLowerInvariant(), StringComparison.InvariantCulture); + + static bool DeleteReturnsBoolean(EntityInstanceId entityInstanceId) + => string.Equals(entityInstanceId.Name, nameof(StringStore3).ToLowerInvariant(), StringComparison.InvariantCulture); + + [Function(nameof(CallAndDeleteOrchestration))] + public static async Task CallAndDeleteOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + EntityInstanceId entityId = context.GetInput(); + await context.Entities.CallEntityAsync(entityId, "set", "333"); + + string value = await context.Entities.CallEntityAsync(entityId, "get"); + Assert.Equal("333", value); + + if (DeleteReturnsBoolean(entityId)) + { + bool deleted = await context.Entities.CallEntityAsync(entityId, "delete"); + Assert.True(deleted); + + bool deletedAgain = await context.Entities.CallEntityAsync(entityId, "delete"); + Assert.False(deletedAgain); + } + else + { + await context.Entities.CallEntityAsync(entityId, "delete"); + } + + string getValue = await context.Entities.CallEntityAsync(entityId, "get"); + if (GetOperationInitializesEntity(entityId)) + { + Assert.Equal("", getValue); + } + else + { + Assert.Equal(null, getValue); + } + + if (DeleteReturnsBoolean(entityId)) + { + bool deletedAgain = await context.Entities.CallEntityAsync(entityId, "delete"); + if (GetOperationInitializesEntity(entityId)) + { + Assert.True(deletedAgain); + } + else + { + Assert.False(deletedAgain); + } + } + else + { + await context.Entities.CallEntityAsync(entityId, "delete"); + } + + return "ok"; + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/CallCounter.cs b/test/IsolatedEntities/Tests/CallCounter.cs new file mode 100644 index 000000000..1dfd614d1 --- /dev/null +++ b/test/IsolatedEntities/Tests/CallCounter.cs @@ -0,0 +1,59 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class CallCounter : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(Counter), Guid.NewGuid().ToString()); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(CallCounterOrchestration), entityId); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("OK", metadata.ReadOutputAs()); + + // entity ids cannot be used for orchestration instance queries + await Assert.ThrowsAsync(() => context.Client.GetInstanceAsync(entityId.ToString())); + + // and are not returned by them + List results = await context.Client.GetAllInstancesAsync().ToListAsync(); + Assert.DoesNotContain(results, metadata => metadata.InstanceId.StartsWith("@")); + + // check that entity state is correct + EntityMetadata? entityMetadata = await context.Client.Entities.GetEntityAsync(entityId, includeState:true); + Assert.NotNull(entityMetadata); + Assert.Equal(33, entityMetadata!.State); + } + + [Function(nameof(CallCounterOrchestration))] + public static async Task CallCounterOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + EntityInstanceId entityId = context.GetInput(); + await context.Entities.CallEntityAsync(entityId, "set", 33); + int result = await context.Entities.CallEntityAsync(entityId, "get"); + + if (result == 33) + { + return "OK"; + } + else + { + return $"wrong result: {result} instead of 33"; + } + } +} diff --git a/test/IsolatedEntities/Tests/CallFaultyEntity.cs b/test/IsolatedEntities/Tests/CallFaultyEntity.cs new file mode 100644 index 000000000..6a44ca149 --- /dev/null +++ b/test/IsolatedEntities/Tests/CallFaultyEntity.cs @@ -0,0 +1,142 @@ +// 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.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class CallFaultyEntity : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(FaultyEntity), Guid.NewGuid().ToString()); + string orchestrationName = nameof(CallFaultyEntityOrchestration); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, entityId); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } +} + +class CallFaultyEntityOrchestration +{ + readonly ILogger logger; + + public CallFaultyEntityOrchestration(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(CallFaultyEntityOrchestration))] + public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entityId = context.GetInput(); + + async Task ExpectOperationExceptionAsync(Task t, EntityInstanceId entityId, string operationName, string errorText) + { + try + { + await t; + throw new Exception("expected operation exception, but none was thrown"); + } + catch(EntityOperationFailedException entityException) + { + Assert.Equal(operationName, entityException.OperationName); + Assert.Equal(entityId, entityException.EntityId); + Assert.Contains(errorText, entityException.Message); + Assert.NotNull(entityException.FailureDetails); + } + catch (Exception e) + { + throw new Exception($"wrong exception thrown", e); + } + } + + try + { + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "SetToUnserializable"), + entityId, + "SetToUnserializable", + "problematic object: is not serializable"); + + // since the operation failed, the entity state is unchanged, meaning the entity still does not exist + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await context.Entities.CallEntityAsync(entityId, "SetToUndeserializable"); + + Assert.True(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "Get"), + entityId, + "Get", + "problematic object: is not deserializable"); + + await context.Entities.CallEntityAsync(entityId, "DeleteWithoutReading"); + + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await context.Entities.CallEntityAsync(entityId, "Set", 3); + + Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "SetThenThrow", 333), + entityId, + "SetThenThrow", + "KABOOM"); + + // value should be unchanged + Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "DeleteThenThrow"), + entityId, + "DeleteThenThrow", + "KABOOM"); + + // value should be unchanged + Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); + + await context.Entities.CallEntityAsync(entityId, "Delete"); + + // entity was deleted + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "SetThenThrow", 333), + entityId, + "SetThenThrow", + "KABOOM"); + + // must have rolled back to non-existing state + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + return "ok"; + } + catch (Exception e) + { + logger.LogError("exception in CallFaultyEntityOrchestration: {exception}", e); + return e.ToString(); + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/CallFaultyEntityBatches.cs b/test/IsolatedEntities/Tests/CallFaultyEntityBatches.cs new file mode 100644 index 000000000..8abf6405c --- /dev/null +++ b/test/IsolatedEntities/Tests/CallFaultyEntityBatches.cs @@ -0,0 +1,147 @@ +// 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.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class CallFaultyEntityBatches : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(FaultyEntity), Guid.NewGuid().ToString()); + string orchestrationName = nameof(CallFaultyEntityBatchesOrchestration); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, entityId); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } +} + +class CallFaultyEntityBatchesOrchestration +{ + readonly ILogger logger; + + public CallFaultyEntityBatchesOrchestration(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(CallFaultyEntityBatchesOrchestration))] + public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entityId = context.GetInput(); + + // we use this utility function to try to enforce that a bunch of signals is delivered as a single batch. + // This is required for some of the tests here to work, since the batching affects the entity state management. + // The "enforcement" mechanism we use is not 100% failsafe (it still makes timing assumptions about the provider) + // but it should be more reliable than the original version of this test which failed quite frequently, as it was + // simply assuming that signals that are sent at the same time are always processed as a batch. + async Task ProcessSignalBatch(IEnumerable<(string,int?)> signals) + { + // first issue a signal that, when delivered, keeps the entity busy for a split second + await context.Entities.SignalEntityAsync(entityId, "Delay", 0.5); + + // we now need to yield briefly so that the delay signal is sent before the others + await context.CreateTimer(context.CurrentUtcDateTime + TimeSpan.FromMilliseconds(1), CancellationToken.None); + + // now send the signals one by one. These should all arrive and get queued (inside the storage provider) + // while the entity is executing the delay operation. Therefore, after the delay operation finishes, + // all of the signals are processed in a single batch. + foreach ((string operation, int? arg) in signals) + { + await context.Entities.SignalEntityAsync(entityId, operation, arg); + } + } + + try + { + await ProcessSignalBatch(new (string, int?)[] + { + new("Set", 42), // state that survives + new("SetThenThrow", 333), + new("DeleteThenThrow", null), + }); + + Assert.Equal(42, await context.Entities.CallEntityAsync(entityId, "Get")); + + await ProcessSignalBatch(new (string, int?)[] + { + new("Get", null), + new("Set", 42), + new("Delete", null), + new("Set", 43), // state that survives + new("DeleteThenThrow", null), + }); + + Assert.Equal(43, await context.Entities.CallEntityAsync(entityId, "Get")); + + await ProcessSignalBatch(new (string, int?)[] + { + new("Set", 55), // state that survives + new("SetToUnserializable", null), + }); + + + Assert.Equal(55, await context.Entities.CallEntityAsync(entityId, "Get")); + + await ProcessSignalBatch(new (string, int?)[] + { + new("Set", 1), + new("Delete", null), + new("Set", 2), + new("Delete", null), // state that survives + new("SetThenThrow", 333), + }); + + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await ProcessSignalBatch(new (string, int?)[] + { + new("Set", 1), + new("Delete", null), + new("Set", 2), + new("Delete", null), // state that survives + new("SetThenThrow", 333), + }); + + // must have rolled back to non-existing state + Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + + await ProcessSignalBatch(new (string, int?)[] + { + new("Set", 1), + new("SetThenThrow", 333), + new("Set", 2), + new("DeleteThenThrow", null), + new("Delete", null), + new("Set", 3), // state that survives + }); + + Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); + + return "ok"; + } + catch (Exception e) + { + logger.LogError("exception in CallFaultyEntityBatchesOrchestration: {exception}", e); + return e.ToString(); + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/CleanOrphanedLock.cs b/test/IsolatedEntities/Tests/CleanOrphanedLock.cs new file mode 100644 index 000000000..9f9ffbfcc --- /dev/null +++ b/test/IsolatedEntities/Tests/CleanOrphanedLock.cs @@ -0,0 +1,133 @@ +// 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.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using DurableTask.Core.Entities.OperationFormat; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class CleanOrphanedLock : Test +{ + + public override async Task RunAsync(TestContext context) + { + // clean the storage before starting the test so we start from a clean slate + await context.Client.Entities.CleanEntityStorageAsync(new()); + + DateTime startTime = DateTime.UtcNow; + + // construct unique names for this test + string prefix = Guid.NewGuid().ToString("N").Substring(0, 6); + var orphanedEntityId = new EntityInstanceId(nameof(Counter), $"{prefix}-orphaned"); + var orchestrationA = $"{prefix}-A"; + var orchestrationB = $"{prefix}-B"; + + // start an orchestration A that acquires the lock and then waits forever + await context.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(BadLockerOrchestration), + orphanedEntityId, + new StartOrchestrationOptions() { InstanceId = orchestrationA }, + context.CancellationToken); + await context.Client.WaitForInstanceStartAsync(orchestrationA, context.CancellationToken); + + // start an orchestration B that queues behind A for the lock (and thus gets stuck) + await context.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(UnluckyWaiterOrchestration), + orphanedEntityId, + new StartOrchestrationOptions() { InstanceId = orchestrationB }, + context.CancellationToken); + await context.Client.WaitForInstanceStartAsync(orchestrationB, context.CancellationToken); + + // brutally and unsafely purge the running orchestrationA from storage, leaving the lock orphaned + await context.Client.PurgeInstanceAsync(orchestrationA); + + // check the status of the entity to confirm that the lock is held + List results = await context.Client.Entities.GetAllEntitiesAsync( + new Microsoft.DurableTask.Client.Entities.EntityQuery() + { + InstanceIdStartsWith = orphanedEntityId.ToString(), + IncludeStateless = true, + IncludeState = true, + }).ToListAsync(); + Assert.Equal(1, results.Count); + Assert.Equal(orphanedEntityId, results[0].Id); + Assert.False(results[0].IncludesState); + Assert.True(results[0].LastModifiedTime > startTime); + Assert.Equal(orchestrationA, results[0].LockedBy); + Assert.Equal(1, results[0].BacklogQueueSize); // that's the request that is waiting for the lock + DateTimeOffset lastModified = results[0].LastModifiedTime; + + // clean the entity storage to remove the orphaned lock + var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new()); + Assert.Equal(0, cleaningResponse.EmptyEntitiesRemoved); + Assert.Equal(1, cleaningResponse.OrphanedLocksReleased); + + // now wait for orchestration B to finish + OrchestrationMetadata metadata = await context.Client.WaitForInstanceCompletionAsync(orchestrationB, getInputsAndOutputs: true, context.CancellationToken); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + + // clean the entity storage again, this time there should be nothing left to clean + cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new()); + Assert.Equal(0, cleaningResponse.EmptyEntitiesRemoved); + Assert.Equal(0, cleaningResponse.OrphanedLocksReleased); + + // check the status of the entity to confirm that the lock is no longer held + results = await context.Client.Entities.GetAllEntitiesAsync( + new Microsoft.DurableTask.Client.Entities.EntityQuery() + { + InstanceIdStartsWith = orphanedEntityId.ToString(), + IncludeStateless = true, + IncludeState = true, + }).ToListAsync(); + Assert.Equal(1, results.Count); + Assert.Equal(orphanedEntityId, results[0].Id); + Assert.True(results[0].IncludesState); + Assert.Equal(1, results[0].State.ReadAs()); + Assert.True(results[0].LastModifiedTime > lastModified); + Assert.Null(results[0].LockedBy); + Assert.Equal(0, results[0].BacklogQueueSize); + } + + [Function(nameof(BadLockerOrchestration))] + public static async Task BadLockerOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entityId = context.GetInput(); + + await using (await context.Entities.LockEntitiesAsync(entityId)) + { + await context.CreateTimer(DateTime.UtcNow + TimeSpan.FromDays(365), CancellationToken.None); + } + + // will never reach the end here because we get purged in the middle + return "ok"; + } + + [Function(nameof(UnluckyWaiterOrchestration))] + public static async Task UnluckyWaiterOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entityId = context.GetInput(); + + await using (await context.Entities.LockEntitiesAsync(entityId)) + { + await context.Entities.CallEntityAsync(entityId, "increment"); + + // we got the entity + return "ok"; + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/EntityQueries1.cs b/test/IsolatedEntities/Tests/EntityQueries1.cs new file mode 100644 index 000000000..241f833bf --- /dev/null +++ b/test/IsolatedEntities/Tests/EntityQueries1.cs @@ -0,0 +1,246 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class EntityQueries1 : Test +{ + public override async Task RunAsync(TestContext context) + { + // ----- first, delete all already-existing entities in storage to ensure queries have predictable results + context.Logger.LogInformation("deleting existing entities"); + + // we simply delete all the running instances + await context.Client.PurgeAllInstancesAsync( + new PurgeInstancesFilter() + { + CreatedFrom = DateTime.MinValue, + Statuses = new OrchestrationRuntimeStatus[] { OrchestrationRuntimeStatus.Running } + }, + context.CancellationToken); + + var yesterday = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); + var tomorrow = DateTime.UtcNow.Add(TimeSpan.FromDays(1)); + + // check that a blank entity query returns no elements now + + var e = context.Client.Entities.GetAllEntitiesAsync(new EntityQuery()).GetAsyncEnumerator(); + Assert.False(await e.MoveNextAsync()); + + // ----- next, run a number of orchestrations in order to create specific instances + context.Logger.LogInformation("creating entities"); + + List entityIds = new List() + { + new EntityInstanceId("StringStore", "foo"), + new EntityInstanceId("StringStore", "bar"), + new EntityInstanceId("StringStore", "baz"), + new EntityInstanceId("StringStore2", "foo"), + }; + + await Parallel.ForEachAsync( + Enumerable.Range(0, entityIds.Count), + context.CancellationToken, + async (int i, CancellationToken cancellation) => + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(SignalAndCall.SignalAndCallOrchestration), entityIds[i]); + await context.Client.WaitForInstanceCompletionAsync(instanceId, cancellation); + }); + + await Task.Delay(TimeSpan.FromSeconds(3)); // accounts for delay in updating instance tables + + // ----- to more easily read this, we first create a collection of (query, validation function) pairs + context.Logger.LogInformation("starting query tests"); + + var tests = new (EntityQuery query, Action> test)[] + { + (new EntityQuery + { + InstanceIdStartsWith = "StringStore", + }, + result => + { + Assert.Equal(4, result.Count()); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore", + }, + result => + { + Assert.Equal(4, result.Count()); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@stringstore", + }, + result => + { + Assert.Equal(4, result.Count()); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@", + }, + result => + { + Assert.Equal(3, result.Count()); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "StringStore@", + }, + result => + { + Assert.Equal(3, result.Count()); + }), + + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@foo", + }, + result => + { + Assert.Equal(1, result.Count); + Assert.True(result[0].IncludesState); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@foo", + IncludeState = false, + }, + result => + { + Assert.Equal(1, result.Count); + Assert.False(result[0].IncludesState); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(3, result.Count); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "StringStore@ba", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(2, result.Count); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "stringstore@BA", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(0, result.Count); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@ba", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(2, result.Count); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@stringstore@BA", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(0, result.Count); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@StringStore@", + PageSize = 2, + }, + result => + { + Assert.Equal(3, result.Count()); + }), + + (new EntityQuery + { + InstanceIdStartsWith = "@noResult", + LastModifiedFrom = yesterday, + LastModifiedTo = tomorrow, + }, + result => + { + Assert.Equal(0, result.Count()); + }), + + (new EntityQuery + { + LastModifiedFrom = tomorrow, + }, + result => + { + Assert.Equal(0, result.Count()); + }), + + (new EntityQuery + { + LastModifiedTo = yesterday, + }, + result => + { + Assert.Equal(0, result.Count()); + }), + + }; + + foreach (var item in tests) + { + List results = new List(); + await foreach (var element in context.Client.Entities.GetAllEntitiesAsync(item.query)) + { + results.Add(element); + } + + item.test(results); + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/EntityQueries2.cs b/test/IsolatedEntities/Tests/EntityQueries2.cs new file mode 100644 index 000000000..2326c5c52 --- /dev/null +++ b/test/IsolatedEntities/Tests/EntityQueries2.cs @@ -0,0 +1,147 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class EntityQueries2 : Test +{ + public override async Task RunAsync(TestContext context) + { + // ----- first, delete all already-existing entities in storage to ensure queries have predictable results + context.Logger.LogInformation("deleting existing entities"); + + // we simply delete all the running instances which does also purge all entities + await context.Client.PurgeAllInstancesAsync( + new PurgeInstancesFilter() + { + CreatedFrom = DateTime.MinValue, + Statuses = new OrchestrationRuntimeStatus[] { OrchestrationRuntimeStatus.Running } + }, + context.CancellationToken); + + var yesterday = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); + var tomorrow = DateTime.UtcNow.Add(TimeSpan.FromDays(1)); + + // check that everything is completely blank, there are no entities, not even stateless ones + + var e = context.Client.Entities.GetAllEntitiesAsync(new EntityQuery() { IncludeStateless = true }).GetAsyncEnumerator(); + Assert.False(await e.MoveNextAsync()); + + // ----- next, run a number of orchestrations in order to create and/or delete specific instances + context.Logger.LogInformation("creating and deleting entities"); + + List orchestrations = new List() + { + nameof(SignalAndCall.SignalAndCallOrchestration), + nameof(CallAndDelete.CallAndDeleteOrchestration), + nameof(SignalAndCall.SignalAndCallOrchestration), + nameof(CallAndDelete.CallAndDeleteOrchestration), + nameof(SignalAndCall.SignalAndCallOrchestration), + nameof(CallAndDelete.CallAndDeleteOrchestration), + nameof(SignalAndCall.SignalAndCallOrchestration), + nameof(CallAndDelete.CallAndDeleteOrchestration), + }; + + List entityIds = new List() + { + new EntityInstanceId("StringStore", "foo"), + new EntityInstanceId("StringStore2", "bar"), + new EntityInstanceId("StringStore2", "baz"), + new EntityInstanceId("StringStore2", "foo"), + new EntityInstanceId("StringStore2", "ffo"), + new EntityInstanceId("StringStore2", "zzz"), + new EntityInstanceId("StringStore2", "aaa"), + new EntityInstanceId("StringStore2", "bbb"), + }; + + await Parallel.ForEachAsync( + Enumerable.Range(0, entityIds.Count), + context.CancellationToken, + async (int i, CancellationToken cancellation) => + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrations[i], entityIds[i]); + await context.Client.WaitForInstanceCompletionAsync(instanceId, cancellation); + }); + + await Task.Delay(TimeSpan.FromSeconds(3)); // accounts for delay in updating instance tables + + // ----- use a collection of (query, validation function) pairs + context.Logger.LogInformation("starting query tests"); + + var tests = new (EntityQuery query, Action> test)[] + { + (new EntityQuery + { + }, + result => + { + Assert.Equal(4, result.Count()); + }), + + (new EntityQuery + { + IncludeStateless = true, + }, + result => + { + Assert.Equal(8, result.Count()); // TODO this is provider-specific + }), + + + (new EntityQuery + { + PageSize = 3, + }, + result => + { + Assert.Equal(4, result.Count()); + }), + + (new EntityQuery + { + IncludeStateless = true, + PageSize = 3, + }, + result => + { + Assert.Equal(8, result.Count()); // TODO this is provider-specific + }), + }; + + foreach (var item in tests) + { + List results = new List(); + await foreach (var element in context.Client.Entities.GetAllEntitiesAsync(item.query)) + { + results.Add(element); + } + + item.test(results); + } + + // ----- remove the 4 deleted entities whose metadata still lingers in Azure Storage provider + // TODO this is provider-specific + + context.Logger.LogInformation("starting storage cleaning"); + + var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new CleanEntityStorageRequest()); + + Assert.Equal(4, cleaningResponse.EmptyEntitiesRemoved); + Assert.Equal(0, cleaningResponse.OrphanedLocksReleased); + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/FireAndForget.cs b/test/IsolatedEntities/Tests/FireAndForget.cs new file mode 100644 index 000000000..a54d29d33 --- /dev/null +++ b/test/IsolatedEntities/Tests/FireAndForget.cs @@ -0,0 +1,90 @@ +// 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.Net.Mime; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +/// +/// Scenario that starts a new orchestration from an entity. +/// +class FireAndForget : Test +{ + private readonly int? delay; + + public FireAndForget(int? delay) + { + this.delay = delay; + } + + public override string Name => $"{base.Name}.{(this.delay.HasValue ? "Delay" + this.delay.Value.ToString() : "NoDelay")}"; + + public override async Task RunAsync(TestContext context) + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(LaunchOrchestrationFromEntity), this.delay, context.CancellationToken); + OrchestrationMetadata metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true, context.CancellationToken); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + + string? launchedId = metadata.ReadOutputAs(); + Assert.NotNull(launchedId); + var launchedMetadata = await context.Client.GetInstanceAsync(launchedId!, getInputsAndOutputs: true, context.CancellationToken); + Assert.NotNull(launchedMetadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, launchedMetadata!.RuntimeStatus); + Assert.Equal("ok", launchedMetadata!.ReadOutputAs()); + } + + [Function(nameof(LaunchOrchestrationFromEntity))] + public static async Task LaunchOrchestrationFromEntity([OrchestrationTrigger] TaskOrchestrationContext context) + { + int? delay = context.GetInput(); + + var entityId = new EntityInstanceId("Launcher", context.NewGuid().ToString().Substring(0, 8)); + + if (delay.HasValue) + { + await context.Entities.CallEntityAsync(entityId, "launch", context.CurrentUtcDateTime + TimeSpan.FromSeconds(delay.Value)); + } + else + { + await context.Entities.CallEntityAsync(entityId, "launch"); + } + + while (true) + { + string? launchedOrchestrationId = await context.Entities.CallEntityAsync(entityId, "get"); + + if (launchedOrchestrationId != null) + { + return launchedOrchestrationId; + } + + await context.CreateTimer(DateTime.UtcNow + TimeSpan.FromSeconds(1), CancellationToken.None); + } + } + + [Function(nameof(SignallingOrchestration))] + public static async Task SignallingOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + var entityId = context.GetInput(); + + await context.CreateTimer(DateTime.UtcNow + TimeSpan.FromSeconds(.2), CancellationToken.None); + + await context.Entities.SignalEntityAsync(entityId, "done"); + + return "ok"; + } +} diff --git a/test/IsolatedEntities/Tests/InvalidEntityId.cs b/test/IsolatedEntities/Tests/InvalidEntityId.cs new file mode 100644 index 000000000..fbf62c346 --- /dev/null +++ b/test/IsolatedEntities/Tests/InvalidEntityId.cs @@ -0,0 +1,81 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +/// +/// This test is not entity related, but discovered an issue with how failures in orchestrators are captured. +/// +class InvalidEntityId : Test +{ + public enum Location + { + ClientSignal, + ClientGet, + OrchestrationSignal, + OrchestrationCall, + } + + readonly Location location; + + public InvalidEntityId(Location location) + { + this.location = location; + } + + public override string Name => $"{base.Name}.{this.location}"; + + public override async Task RunAsync(TestContext context) + { + switch (this.location) + { + case Location.ClientSignal: + await Assert.ThrowsAsync( + typeof(ArgumentNullException), + async () => + { + await context.Client.Entities.SignalEntityAsync(default, "add", 1); + }); + return; + + case Location.ClientGet: + await Assert.ThrowsAsync( + typeof(ArgumentNullException), + async () => + { + await context.Client.Entities.GetEntityAsync(default); + }); + return; + + case Location.OrchestrationSignal: + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(SignalAndCall.SignalAndCallOrchestration) /* missing input */); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + //Assert.NotNull(metadata.FailureDetails); // TODO currently failing because FailureDetails are not propagated for some reason + } + break; + + case Location.OrchestrationCall: + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(CallCounter.CallCounterOrchestration) /* missing input */); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + //Assert.NotNull(metadata.FailureDetails); // TODO currently failing because FailureDetails are not propagated for some reason + } + break; + } + } +} diff --git a/test/IsolatedEntities/Tests/LargeEntity.cs b/test/IsolatedEntities/Tests/LargeEntity.cs new file mode 100644 index 000000000..e1af2412a --- /dev/null +++ b/test/IsolatedEntities/Tests/LargeEntity.cs @@ -0,0 +1,85 @@ +// 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 Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities.Tests +{ + /// + /// validates a simple entity scenario where an entity's state is + /// larger than what fits into Azure table rows. + /// + internal class LargeEntity : Test + { + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(StringStore2), Guid.NewGuid().ToString().Substring(0, 8)); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(LargeEntityOrchestration), entityId); + + // wait for completion of the orchestration + { + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } + + // read untyped without including state + { + EntityMetadata? metadata = await context.Client.Entities.GetEntityAsync(entityId, includeState: false, context.CancellationToken); + Assert.NotNull(metadata); + Assert.Throws(() => metadata!.State); + } + + // read untyped including state + { + EntityMetadata? metadata = await context.Client.Entities.GetEntityAsync(entityId, includeState:true, context.CancellationToken); + Assert.NotNull(metadata); + Assert.NotNull(metadata!.State); + Assert.Equal(100000, metadata!.State.ReadAs().Length); + } + + // read typed without including state + { + EntityMetadata? metadata = await context.Client.Entities.GetEntityAsync(entityId, includeState: false, context.CancellationToken); + Assert.NotNull(metadata); + Assert.Throws(() => metadata!.State); + } + + // read typed including state + { + EntityMetadata? metadata = await context.Client.Entities.GetEntityAsync(entityId, includeState: true, context.CancellationToken); + Assert.NotNull(metadata); + Assert.NotNull(metadata!.State); + Assert.Equal(100000, metadata!.State.Length); + } + } + + [Function(nameof(LargeEntityOrchestration))] + public static async Task LargeEntityOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + var entityId = context.GetInput(); + string content = new string('.', 100000); + + await context.Entities.CallEntityAsync(entityId, "set", content); + + var result = await context.Entities.CallEntityAsync(entityId, "get"); + + if (result != content) + { + return $"fail: wrong entity state"; + } + + return "ok"; + } + } +} diff --git a/test/IsolatedEntities/Tests/MultipleLockedTransfers.cs b/test/IsolatedEntities/Tests/MultipleLockedTransfers.cs new file mode 100644 index 000000000..ed7f559c6 --- /dev/null +++ b/test/IsolatedEntities/Tests/MultipleLockedTransfers.cs @@ -0,0 +1,85 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class MultipleLockedTransfers : Test +{ + readonly int numberEntities; + + public MultipleLockedTransfers(int numberEntities) + { + this.numberEntities = numberEntities; + } + + public override string Name => $"{base.Name}.{this.numberEntities}"; + + public override async Task RunAsync(TestContext context) + { + // create specified number of counters + var counters = new EntityInstanceId[this.numberEntities]; + for (int i = 0; i < this.numberEntities; i++) + { + counters[i] = new EntityInstanceId(nameof(Counter), Guid.NewGuid().ToString().Substring(0, 8)); + } + + // in parallel, start one transfer per counter, each decrementing a counter and incrementing + // its successor (where the last one wraps around to the first) + // This is a pattern that would deadlock if we didn't order the lock acquisition. + var instances = new Task[this.numberEntities]; + for (int i = 0; i < this.numberEntities; i++) + { + instances[i] = context.Client.ScheduleNewOrchestrationInstanceAsync( + nameof(SingleLockedTransfer.LockedTransferOrchestration), + new[] { counters[i], counters[(i + 1) % this.numberEntities] }, + context.CancellationToken); + } + await Task.WhenAll(instances); + + + // in parallel, wait for all transfers to complete + var metadata = new Task[this.numberEntities]; + for (int i = 0; i < this.numberEntities; i++) + { + metadata[i] = context.Client.WaitForInstanceCompletionAsync(instances[i].Result, getInputsAndOutputs: true, context.CancellationToken); + } + await Task.WhenAll(metadata); + + // check that they all completed + for (int i = 0; i < this.numberEntities; i++) + { + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata[i].Result.RuntimeStatus); + } + + // in parallel, read all the entity states + var entityMetadata = new Task?>[this.numberEntities]; + for (int i = 0; i < this.numberEntities; i++) + { + entityMetadata[i] = context.Client.Entities.GetEntityAsync(counters[i], includeState: true, context.CancellationToken); + } + await Task.WhenAll(entityMetadata); + + // check that the counter states are all back to 0 + // (since each participated in 2 transfers, one incrementing and one decrementing) + for (int i = 0; i < numberEntities; i++) + { + EntityMetadata? response = entityMetadata[i].Result; + Assert.NotNull(response); + Assert.Equal(0, response!.State); + } + } +} diff --git a/test/IsolatedEntities/Tests/SelfScheduling.cs b/test/IsolatedEntities/Tests/SelfScheduling.cs new file mode 100644 index 000000000..394d4aee2 --- /dev/null +++ b/test/IsolatedEntities/Tests/SelfScheduling.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.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class SelfScheduling : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(SelfSchedulingEntity), Guid.NewGuid().ToString().Substring(0,8)); + + await context.Client.Entities.SignalEntityAsync(entityId, "start"); + + var result = await context.WaitForEntityStateAsync( + entityId, + timeout: default, + entityState => entityState.Value.Length == 4 ? null : "expect 4 letters"); + + Assert.NotNull(result); + Assert.Equal("ABCD", result.Value); + } +} diff --git a/test/IsolatedEntities/Tests/SetAndGet.cs b/test/IsolatedEntities/Tests/SetAndGet.cs new file mode 100644 index 000000000..16fdb09e1 --- /dev/null +++ b/test/IsolatedEntities/Tests/SetAndGet.cs @@ -0,0 +1,47 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class SetAndGet : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(Counter), Guid.NewGuid().ToString()); + + // entity should not yet exist + EntityMetadata? result = await context.Client.Entities.GetEntityAsync(entityId); + Assert.Null(result); + + // entity should still not exist + result = await context.Client.Entities.GetEntityAsync(entityId, includeState:true); + Assert.Null(result); + + // send one signal + await context.Client.Entities.SignalEntityAsync(entityId, "Set", 1); + + // wait for state + int state = await context.WaitForEntityStateAsync(entityId); + Assert.Equal(1, state); + + // entity still exists + result = await context.Client.Entities.GetEntityAsync(entityId); + + Assert.NotNull(result); + Assert.Equal(1,result!.State); + } +} diff --git a/test/IsolatedEntities/Tests/SignalAndCall.cs b/test/IsolatedEntities/Tests/SignalAndCall.cs new file mode 100644 index 000000000..29893300a --- /dev/null +++ b/test/IsolatedEntities/Tests/SignalAndCall.cs @@ -0,0 +1,68 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class SignalAndCall : Test +{ + readonly Type entityType; + + public SignalAndCall(Type entityType) + { + this.entityType = entityType; + } + + public override string Name => $"{base.Name}.{entityType.Name}"; + + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(this.entityType.Name, Guid.NewGuid().ToString().Substring(0, 8)); + + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(SignalAndCallOrchestration), entityId, context.CancellationToken); + OrchestrationMetadata metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs:true, context.CancellationToken); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } + + [Function(nameof(SignalAndCallOrchestration))] + public static async Task SignalAndCallOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entity = context.GetInput(); + + // signal and call (both of these will be delivered close together, typically in the same batch, and always in order) + await context.Entities.SignalEntityAsync(entity, "set", "333"); + + string? result = await context.Entities.CallEntityAsync(entity, "get"); + + if (result != "333") + { + return $"fail: wrong entity state: expected 333, got {result}"; + } + + // make another call to see if the state survives replay + result = await context.Entities.CallEntityAsync(entity, "get"); + + if (result != "333") + { + return $"fail: wrong entity state: expected 333 still, but got {result}"; + } + + return "ok"; + } +} diff --git a/test/IsolatedEntities/Tests/SignalThenPoll.cs b/test/IsolatedEntities/Tests/SignalThenPoll.cs new file mode 100644 index 000000000..a39288f09 --- /dev/null +++ b/test/IsolatedEntities/Tests/SignalThenPoll.cs @@ -0,0 +1,93 @@ +// 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.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class SignalThenPoll : Test +{ + private readonly bool direct; + private readonly bool delayed; + + public SignalThenPoll(bool direct, bool delayed) + { + this.direct = direct; + this.delayed = delayed; + } + + public override string Name => $"{base.Name}.{(this.direct ? "Direct" : "Indirect")}.{(this.delayed ? "Delayed" : "Immediately")}"; + + public override async Task RunAsync(TestContext context) + { + var counterEntityId = new EntityInstanceId(nameof(Counter), Guid.NewGuid().ToString().Substring(0,8)); + var relayEntityId = new EntityInstanceId("Relay", ""); + + string pollingInstance = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(PollingOrchestration), counterEntityId, context.CancellationToken); + DateTimeOffset? scheduledTime = this.delayed ? DateTime.UtcNow + TimeSpan.FromSeconds(5) : null; + + if (this.direct) + { + await context.Client.Entities.SignalEntityAsync( + counterEntityId, + "increment", + new SignalEntityOptions() { SignalTime = scheduledTime }, + context.CancellationToken); + } + else + { + await context.Client.Entities.SignalEntityAsync( + relayEntityId, + operationName: "", + input: new Relay.Input(counterEntityId, "increment", scheduledTime), + options: null, + context.CancellationToken); + } + + var metadata = await context.Client.WaitForInstanceCompletionAsync(pollingInstance, true, context.CancellationToken); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + + if (this.delayed) + { + Assert.True(metadata.LastUpdatedAt > scheduledTime - TimeSpan.FromMilliseconds(100)); + } + } + + [Function(nameof(PollingOrchestration))] + public static async Task PollingOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entityId = context.GetInput(); + + while (true) + { + var result = await context.Entities.CallEntityAsync(entityId, "get"); + + if (result != 0) + { + if (result == 1) + { + return "ok"; + } + else + { + return $"fail: wrong entity state: expected 1, got {result}"; + } + } + + await context.CreateTimer(DateTime.UtcNow + TimeSpan.FromSeconds(1), CancellationToken.None); + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/Tests/SingleLockedTransfer.cs b/test/IsolatedEntities/Tests/SingleLockedTransfer.cs new file mode 100644 index 000000000..1ff43cc91 --- /dev/null +++ b/test/IsolatedEntities/Tests/SingleLockedTransfer.cs @@ -0,0 +1,104 @@ +// 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.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Xunit; + +namespace IsolatedEntities; + +class SingleLockedTransfer : Test +{ + public override async Task RunAsync(TestContext context) + { + var counter1 = new EntityInstanceId("Counter", Guid.NewGuid().ToString().Substring(0, 8)); + var counter2 = new EntityInstanceId("Counter", Guid.NewGuid().ToString().Substring(0, 8)); + + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(nameof(LockedTransferOrchestration), new[] { counter1, counter2 }, context.CancellationToken); + OrchestrationMetadata metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs:true, context.CancellationToken); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal(new[] { -1, 1 }, metadata.ReadOutputAs()); + + // validate the state of the counters + EntityMetadata? response1 = await context.Client.Entities.GetEntityAsync(counter1, true, context.CancellationToken); + EntityMetadata? response2 = await context.Client.Entities.GetEntityAsync(counter2, true, context.CancellationToken); + Assert.NotNull(response1); + Assert.NotNull(response2); + Assert.Equal(-1, response1!.State); + Assert.Equal(1, response2!.State); + } + + [Function(nameof(LockedTransferOrchestration))] + public static async Task LockedTransferOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read entity id from input + var entities = context.GetInput(); + var from = entities![0]; + var to = entities![1]; + + if (from.Equals(to)) + { + throw new ArgumentException("from and to must be distinct"); + } + + ExpectSynchState(false); + + int fromBalance; + int toBalance; + + await using (await context.Entities.LockEntitiesAsync(from, to)) + { + ExpectSynchState(true, from, to); + + // read balances in parallel + var t1 = context.Entities.CallEntityAsync(from, "get"); + ExpectSynchState(true, to); + var t2 = context.Entities.CallEntityAsync(to, "get"); + ExpectSynchState(true); + + + fromBalance = await t1; + toBalance = await t2; + ExpectSynchState(true, from, to); + + // modify + fromBalance--; + toBalance++; + + // write balances in parallel + var t3 = context.Entities.CallEntityAsync(from, "set", fromBalance); + ExpectSynchState(true, to); + var t4 = context.Entities.CallEntityAsync(to, "set", toBalance); + ExpectSynchState(true); + await t4; + await t3; + ExpectSynchState(true, to, from); + + } // lock is released here + + ExpectSynchState(false); + + return new int[] { fromBalance, toBalance }; + + void ExpectSynchState(bool inCriticalSection, params EntityInstanceId[] ids) + { + Assert.Equal(inCriticalSection, context.Entities.InCriticalSection(out var currentLocks)); + if (inCriticalSection) + { + Assert.Equal( + ids.Select(i => i.ToString()).OrderBy(s => s), + currentLocks!.Select(i => i.ToString()).OrderBy(s => s)); + } + } + } +} diff --git a/test/IsolatedEntities/host.json b/test/IsolatedEntities/host.json new file mode 100644 index 000000000..cb3864033 --- /dev/null +++ b/test/IsolatedEntities/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "durableTask": { + "entityMessageReorderWindowInMinutes": 0 // need this just for testing the CleanEntityStorage + } + } +} \ No newline at end of file diff --git a/test/IsolatedEntities/local.settings.json b/test/IsolatedEntities/local.settings.json new file mode 100644 index 000000000..88e9efa1e --- /dev/null +++ b/test/IsolatedEntities/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + } +} \ No newline at end of file From 0e26d1515d92fba5d814ff0640ef71aaf5469391 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Thu, 5 Oct 2023 15:24:04 -0700 Subject: [PATCH 15/30] pass entity parameters for task orchestration. (#2611) --- ...OrchestrationTriggerAttributeBindingProvider.cs | 1 + .../RemoteOrchestratorContext.cs | 7 ++++++- .../OutOfProcMiddleware.cs | 5 ++++- .../ProtobufUtils.cs | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/Bindings/OrchestrationTriggerAttributeBindingProvider.cs b/src/WebJobs.Extensions.DurableTask/Bindings/OrchestrationTriggerAttributeBindingProvider.cs index ce1758146..3a277a505 100644 --- a/src/WebJobs.Extensions.DurableTask/Bindings/OrchestrationTriggerAttributeBindingProvider.cs +++ b/src/WebJobs.Extensions.DurableTask/Bindings/OrchestrationTriggerAttributeBindingProvider.cs @@ -170,6 +170,7 @@ public Task BindAsync(object? value, ValueBindingContext context) InstanceId = remoteContext.InstanceId, PastEvents = { remoteContext.PastEvents.Select(ProtobufUtils.ToHistoryEventProto) }, NewEvents = { remoteContext.NewEvents.Select(ProtobufUtils.ToHistoryEventProto) }, + EntityParameters = remoteContext.EntityParameters.ToProtobuf(), }; // We convert the binary payload into a base64 string because that seems to be the most commonly supported diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs index 3894256ec..a1d9319e3 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using DurableTask.Core; using DurableTask.Core.Command; +using DurableTask.Core.Entities; using DurableTask.Core.History; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -17,9 +18,10 @@ internal class RemoteOrchestratorContext private OrchestratorExecutionResult? executionResult; - public RemoteOrchestratorContext(OrchestrationRuntimeState runtimeState) + public RemoteOrchestratorContext(OrchestrationRuntimeState runtimeState, TaskOrchestrationEntityParameters? entityParameters) { this.runtimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState)); + this.EntityParameters = entityParameters; } [JsonProperty("instanceId")] @@ -43,6 +45,9 @@ public RemoteOrchestratorContext(OrchestrationRuntimeState runtimeState) [JsonIgnore] internal string? SerializedOutput { get; private set; } + [JsonIgnore] + internal TaskOrchestrationEntityParameters? EntityParameters { get; private set; } + internal void SetResult(IEnumerable actions, string customStatus) { var result = new OrchestratorExecutionResult diff --git a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs index 0ff803c6f..65751729b 100644 --- a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs +++ b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.Entities.OperationFormat; using DurableTask.Core.Exceptions; using DurableTask.Core.History; @@ -86,6 +87,8 @@ public async Task CallOrchestratorAsync(DispatchMiddlewareContext dispatchContex return; } + TaskOrchestrationEntityParameters? entityParameters = dispatchContext.GetProperty(); + bool isReplaying = runtimeState.PastEvents.Any(); this.TraceHelper.FunctionStarting( @@ -107,7 +110,7 @@ await this.LifeCycleNotificationHelper.OrchestratorStartingAsync( isReplay: false); } - var context = new RemoteOrchestratorContext(runtimeState); + var context = new RemoteOrchestratorContext(runtimeState, entityParameters); var input = new TriggeredFunctionData { diff --git a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs index b0c059e9e..57b84012b 100644 --- a/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs +++ b/src/WebJobs.Extensions.DurableTask/ProtobufUtils.cs @@ -301,6 +301,20 @@ public static OrchestratorAction ToOrchestratorAction(P.OrchestratorAction a) } } + [return: NotNullIfNotNull("parameters")] + public static P.OrchestratorEntityParameters? ToProtobuf(this TaskOrchestrationEntityParameters? parameters) + { + if (parameters == null) + { + return null; + } + + return new P.OrchestratorEntityParameters + { + EntityMessageReorderWindow = Duration.FromTimeSpan(parameters.EntityMessageReorderWindow), + }; + } + public static string Base64Encode(IMessage message) { // Create a serialized payload using lower-level protobuf APIs. We do this to avoid allocating From 07ecbc87571c92e70cd842775811677696afa8d2 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Thu, 5 Oct 2023 15:24:32 -0700 Subject: [PATCH 16/30] Core entities/various fixes and updates (#2619) * assign the necessary AzureStorageOrchestrationServiceSettings * propagate changes to query name and metadata parameters * add missing override for TaskOrchestrationEntityFeature --- .../AzureStorageDurabilityProviderFactory.cs | 2 ++ .../ContextImplementations/DurableClient.cs | 4 ++-- src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs | 6 ++++-- .../FunctionsOrchestrationContext.cs | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs index 1f6faaf00..0162d26b4 100644 --- a/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs +++ b/src/WebJobs.Extensions.DurableTask/AzureStorageDurabilityProviderFactory.cs @@ -215,6 +215,8 @@ internal AzureStorageOrchestrationServiceSettings GetAzureStorageOrchestrationSe UseLegacyPartitionManagement = this.azureStorageOptions.UseLegacyPartitionManagement, UseTablePartitionManagement = this.azureStorageOptions.UseTablePartitionManagement, UseSeparateQueueForEntityWorkItems = this.useSeparateQueueForEntityWorkItems, + EntityMessageReorderWindowInMinutes = this.options.EntityMessageReorderWindowInMinutes, + MaxEntityOperationBatchSize = this.options.MaxEntityOperationBatchSize, }; if (this.inConsumption) diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs index 20fe48edf..64613ad5f 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs @@ -556,7 +556,7 @@ private async Task> ReadEntityStateAsync(DurabilityPro EntityBackendQueries.EntityMetadata? metaData = await entityBackendQueries.GetEntityAsync( new DTCore.Entities.EntityId(entityId.EntityName, entityId.EntityKey), includeState: true, - includeDeleted: false, + includeStateless: false, cancellation: default); return new EntityStateResponse() @@ -642,7 +642,7 @@ async Task IDurableEntityClient.ListEntitiesAsync(EntityQuery new EntityBackendQueries.EntityQuery() { InstanceIdStartsWith = query.EntityName != null ? $"${query.EntityName}" : null, - IncludeDeleted = query.IncludeDeleted, + IncludeStateless = query.IncludeDeleted, IncludeState = query.FetchState, LastModifiedFrom = query.LastOperationFrom == DateTime.MinValue ? null : query.LastOperationFrom, LastModifiedTo = query.LastOperationTo, diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 24b65440b..9065b8092 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -223,7 +223,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( EntityBackendQueries.EntityMetadata? metaData = await entityOrchestrationService.EntityBackendQueries!.GetEntityAsync( DTCore.Entities.EntityId.FromString(request.InstanceId), request.IncludeState, - includeDeleted: false, + includeStateless: false, context.CancellationToken); return new P.GetEntityResponse() @@ -244,7 +244,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( InstanceIdStartsWith = query.InstanceIdStartsWith, LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), LastModifiedTo = query.LastModifiedTo?.ToDateTime(), - IncludeDeleted = false, + IncludeStateless = query.IncludeStateless, IncludeState = query.IncludeState, ContinuationToken = query.ContinuationToken, PageSize = query.PageSize, @@ -459,6 +459,8 @@ private P.EntityMetadata ConvertEntityMetadata(EntityBackendQueries.EntityMetada { InstanceId = metaData.EntityId.ToString(), LastModifiedTime = metaData.LastModifiedTime.ToTimestamp(), + BacklogQueueSize = metaData.BacklogQueueSize, + LockedBy = metaData.LockedBy, SerializedState = metaData.SerializedState, }; } diff --git a/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs index 7083993f5..8e0333a57 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; using Microsoft.DurableTask.Worker; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -48,6 +49,8 @@ public FunctionsOrchestrationContext(TaskOrchestrationContext innerContext, Func protected override ILoggerFactory LoggerFactory { get; } + public override TaskOrchestrationEntityFeature Entities => this.innerContext.Entities; + public override T GetInput() { this.EnsureLegalAccess(); From 62d70493fcda55e5c5dd14b04083d7c1f5e44e06 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Fri, 6 Oct 2023 09:00:25 -0700 Subject: [PATCH 17/30] Update to entities preview 2 (#2620) --- .../WebJobs.Extensions.DurableTask.csproj | 4 ++-- src/Worker.Extensions.DurableTask/AssemblyInfo.cs | 2 +- .../Worker.Extensions.DurableTask.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 0d72171db..ddc5b4e0c 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -6,8 +6,8 @@ Microsoft.Azure.WebJobs.Extensions.DurableTask 2 12 - 1 - $(MajorVersion).$(MinorVersion).$(PatchVersion)-entities-preview.1 + 0 + $(MajorVersion).$(MinorVersion).$(PatchVersion)-entities-preview.2 $(MajorVersion).$(MinorVersion).$(PatchVersion) $(MajorVersion).0.0.0 Microsoft Corporation diff --git a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs index 4df165636..ae5ed1c33 100644 --- a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs +++ b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs @@ -4,4 +4,4 @@ using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; // TODO: Find a way to generate this dynamically at build-time -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.12.0-entities-preview.1")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.12.0-entities-preview.2")] diff --git a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj index 5f8cbc086..0865adc27 100644 --- a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj +++ b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj @@ -30,7 +30,7 @@ 1.1.0 - entities-preview.1 + entities-preview.2 $(VersionPrefix).0 $(VersionPrefix).$(FileVersionRevision) From 9e311a69eb29e884551f0b6d4ad7a11b154d0dcb Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Fri, 6 Oct 2023 15:14:33 -0700 Subject: [PATCH 18/30] Add callback handler for entity dispatching (#2624) --- .../TaskEntityDispatcher.cs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs index dc50d861d..7d0f06043 100644 --- a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs +++ b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs @@ -60,15 +60,45 @@ public Task DispatchAsync() { if (typeof(ITaskEntity).IsAssignableFrom(typeof(T))) { - ITaskEntity entity = (ITaskEntity)ActivatorUtilities.GetServiceOrCreateInstance(this.services); + ITaskEntity entity = (ITaskEntity)ActivatorUtilities.GetServiceOrCreateInstance(this.services)!; return this.DispatchAsync(entity); } return this.DispatchAsync(new StateEntity()); } + /// + /// Dispatches the entity trigger to the provided callback. + /// + /// The callback to handle the entity operation(s). + /// A task that completes when the operation(s) have finished. + public Task DispatchAsync(Func> handler) + { + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return this.DispatchAsync(new DelegateEntity(handler)); + } + private class StateEntity : TaskEntity { protected override bool AllowStateDispatch => true; } + + private class DelegateEntity : ITaskEntity + { + readonly Func> handler; + + public DelegateEntity(Func> handler) + { + this.handler = handler; + } + + public ValueTask RunAsync(TaskEntityOperation operation) + { + return this.handler(operation); + } + } } From a6b3622215e628af040aeadca2b2ebbdfdd46649 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Fri, 6 Oct 2023 16:28:36 -0700 Subject: [PATCH 19/30] propagate changes --- test/IsolatedEntities/Tests/CleanOrphanedLock.cs | 10 +++++----- test/IsolatedEntities/Tests/EntityQueries2.cs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/IsolatedEntities/Tests/CleanOrphanedLock.cs b/test/IsolatedEntities/Tests/CleanOrphanedLock.cs index 9f9ffbfcc..8050dc47d 100644 --- a/test/IsolatedEntities/Tests/CleanOrphanedLock.cs +++ b/test/IsolatedEntities/Tests/CleanOrphanedLock.cs @@ -24,7 +24,7 @@ class CleanOrphanedLock : Test public override async Task RunAsync(TestContext context) { // clean the storage before starting the test so we start from a clean slate - await context.Client.Entities.CleanEntityStorageAsync(new()); + await context.Client.Entities.CleanEntityStorageAsync(); DateTime startTime = DateTime.UtcNow; @@ -58,7 +58,7 @@ await context.Client.ScheduleNewOrchestrationInstanceAsync( new Microsoft.DurableTask.Client.Entities.EntityQuery() { InstanceIdStartsWith = orphanedEntityId.ToString(), - IncludeStateless = true, + IncludeTransient = true, IncludeState = true, }).ToListAsync(); Assert.Equal(1, results.Count); @@ -70,7 +70,7 @@ await context.Client.ScheduleNewOrchestrationInstanceAsync( DateTimeOffset lastModified = results[0].LastModifiedTime; // clean the entity storage to remove the orphaned lock - var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new()); + var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(); Assert.Equal(0, cleaningResponse.EmptyEntitiesRemoved); Assert.Equal(1, cleaningResponse.OrphanedLocksReleased); @@ -80,7 +80,7 @@ await context.Client.ScheduleNewOrchestrationInstanceAsync( Assert.Equal("ok", metadata.ReadOutputAs()); // clean the entity storage again, this time there should be nothing left to clean - cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new()); + cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(); Assert.Equal(0, cleaningResponse.EmptyEntitiesRemoved); Assert.Equal(0, cleaningResponse.OrphanedLocksReleased); @@ -89,7 +89,7 @@ await context.Client.ScheduleNewOrchestrationInstanceAsync( new Microsoft.DurableTask.Client.Entities.EntityQuery() { InstanceIdStartsWith = orphanedEntityId.ToString(), - IncludeStateless = true, + IncludeTransient = true, IncludeState = true, }).ToListAsync(); Assert.Equal(1, results.Count); diff --git a/test/IsolatedEntities/Tests/EntityQueries2.cs b/test/IsolatedEntities/Tests/EntityQueries2.cs index 2326c5c52..3321ff42c 100644 --- a/test/IsolatedEntities/Tests/EntityQueries2.cs +++ b/test/IsolatedEntities/Tests/EntityQueries2.cs @@ -39,7 +39,7 @@ await context.Client.PurgeAllInstancesAsync( // check that everything is completely blank, there are no entities, not even stateless ones - var e = context.Client.Entities.GetAllEntitiesAsync(new EntityQuery() { IncludeStateless = true }).GetAsyncEnumerator(); + var e = context.Client.Entities.GetAllEntitiesAsync(new EntityQuery() { IncludeTransient = true }).GetAsyncEnumerator(); Assert.False(await e.MoveNextAsync()); // ----- next, run a number of orchestrations in order to create and/or delete specific instances @@ -95,7 +95,7 @@ await Parallel.ForEachAsync( (new EntityQuery { - IncludeStateless = true, + IncludeTransient = true, }, result => { @@ -114,7 +114,7 @@ await Parallel.ForEachAsync( (new EntityQuery { - IncludeStateless = true, + IncludeTransient = true, PageSize = 3, }, result => @@ -139,7 +139,7 @@ await Parallel.ForEachAsync( context.Logger.LogInformation("starting storage cleaning"); - var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(new CleanEntityStorageRequest()); + var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(); Assert.Equal(4, cleaningResponse.EmptyEntitiesRemoved); Assert.Equal(0, cleaningResponse.OrphanedLocksReleased); From c4a89b041c40533666b0117c89df9fd5f403b0a5 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Mon, 9 Oct 2023 08:46:05 -0700 Subject: [PATCH 20/30] Core entities/propagate changes (#2625) * add configuration for EnableEntitySupport * rename includeStateless to includeTransient --- .../ContextImplementations/DurableClient.cs | 2 +- src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs | 2 +- .../DurableTaskExtensionStartup.cs | 2 ++ .../FunctionsDurableClientProvider.cs | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs index 64613ad5f..a0e0db6d0 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableClient.cs @@ -642,7 +642,7 @@ async Task IDurableEntityClient.ListEntitiesAsync(EntityQuery new EntityBackendQueries.EntityQuery() { InstanceIdStartsWith = query.EntityName != null ? $"${query.EntityName}" : null, - IncludeStateless = query.IncludeDeleted, + IncludeTransient = query.IncludeDeleted, IncludeState = query.FetchState, LastModifiedFrom = query.LastOperationFrom == DateTime.MinValue ? null : query.LastOperationFrom, LastModifiedTo = query.LastOperationTo, diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 9065b8092..4ed5b62c5 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -244,7 +244,7 @@ await this.GetDurabilityProvider(context).SendTaskOrchestrationMessageAsync( InstanceIdStartsWith = query.InstanceIdStartsWith, LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), LastModifiedTo = query.LastModifiedTo?.ToDateTime(), - IncludeStateless = query.IncludeStateless, + IncludeTransient = query.IncludeTransient, IncludeState = query.IncludeState, ContinuationToken = query.ContinuationToken, PageSize = query.PageSize, diff --git a/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs b/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs index 516fd41af..e1edbd095 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskExtensionStartup.cs @@ -30,6 +30,7 @@ public override void Configure(IFunctionsWorkerApplicationBuilder applicationBui { applicationBuilder.Services.AddSingleton(); applicationBuilder.Services.AddOptions() + .Configure(options => options.EnableEntitySupport = true) .PostConfigure((opt, sp) => { if (GetConverter(sp) is DataConverter converter) @@ -39,6 +40,7 @@ public override void Configure(IFunctionsWorkerApplicationBuilder applicationBui }); applicationBuilder.Services.AddOptions() + .Configure(options => options.EnableEntitySupport = true) .PostConfigure((opt, sp) => { if (GetConverter(sp) is DataConverter converter) diff --git a/src/Worker.Extensions.DurableTask/FunctionsDurableClientProvider.cs b/src/Worker.Extensions.DurableTask/FunctionsDurableClientProvider.cs index 03edaf463..2169306a7 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsDurableClientProvider.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsDurableClientProvider.cs @@ -136,6 +136,7 @@ public DurableTaskClient GetClient(Uri endpoint, string? taskHub, string? connec { Channel = channel, DataConverter = this.options.DataConverter, + EnableEntitySupport = this.options.EnableEntitySupport, }; ILogger logger = this.loggerFactory.CreateLogger(); From 565d5486b05cafb16d833dc649db9d4e26b2fc54 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Mon, 9 Oct 2023 11:34:24 -0700 Subject: [PATCH 21/30] Rev dependencies to entities-preview.2 (#2627) --- .../WebJobs.Extensions.DurableTask.csproj | 6 +++--- .../Worker.Extensions.DurableTask.csproj | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index ddc5b4e0c..1cebd0928 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -99,14 +99,14 @@ - + - - + + diff --git a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj index 0865adc27..a9b65350e 100644 --- a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj +++ b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj @@ -39,8 +39,8 @@ - - + + From cc0d0edc1a30e23da1e34e9c550bcd81167007b0 Mon Sep 17 00:00:00 2001 From: Jacob Viau Date: Tue, 10 Oct 2023 14:40:37 -0700 Subject: [PATCH 22/30] Call EnsureLegalAccess from EntityFeature in dotnet-isolated (#2633) --- release_notes.md | 3 + ...tionsOrchestrationContext.EntityFeature.cs | 58 +++++++++++++++++++ .../FunctionsOrchestrationContext.cs | 22 +++---- .../OrchestrationInputConverter.cs | 2 +- .../TaskEntityDispatcher.cs | 2 +- .../Worker.Extensions.DurableTask.csproj | 2 +- .../DotNetIsolated/Counter.cs | 11 +++- .../DotNetIsolated/DotNetIsolated.csproj | 4 +- 8 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.EntityFeature.cs diff --git a/release_notes.md b/release_notes.md index 80b9ce26b..e0a9c9521 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,5 +1,7 @@ # Release Notes +## Microsoft.Azure.Functions.Worker.Extensions.DurableTask v1.1.0-preview.1 + ### New Features - Updates to take advantage of new core-entity support @@ -8,6 +10,7 @@ ### Bug Fixes - Address input issues when using .NET isolated (#2581)[https://github.com/Azure/azure-functions-durable-extension/issues/2581] +- No longer fail orchestrations which return before accessing the `TaskOrchestrationContext`. ### Breaking Changes diff --git a/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.EntityFeature.cs b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.EntityFeature.cs new file mode 100644 index 000000000..67d10e324 --- /dev/null +++ b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.EntityFeature.cs @@ -0,0 +1,58 @@ +// 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.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.DurableTask.Entities; + +namespace Microsoft.Azure.Functions.Worker.Extensions.DurableTask; + +internal sealed partial class FunctionsOrchestrationContext +{ + private class EntityFeature : TaskOrchestrationEntityFeature + { + private readonly FunctionsOrchestrationContext parent; + private readonly TaskOrchestrationEntityFeature inner; + + public EntityFeature(FunctionsOrchestrationContext parent, TaskOrchestrationEntityFeature inner) + { + this.parent = parent; + this.inner = inner; + } + + public override Task CallEntityAsync( + EntityInstanceId id, string operationName, object? input = null, CallEntityOptions? options = null) + { + this.parent.EnsureLegalAccess(); + return this.inner.CallEntityAsync(id, operationName, input, options); + } + + public override Task CallEntityAsync( + EntityInstanceId id, string operationName, object? input = null, CallEntityOptions? options = null) + { + this.parent.EnsureLegalAccess(); + return this.inner.CallEntityAsync(id, operationName, input, options); + } + + public override Task SignalEntityAsync( + EntityInstanceId id, string operationName, object? input = null, SignalEntityOptions? options = null) + { + this.parent.EnsureLegalAccess(); + return this.inner.SignalEntityAsync(id, operationName, input, options); + } + + public override bool InCriticalSection([NotNullWhen(true)] out IReadOnlyList? entityIds) + { + this.parent.EnsureLegalAccess(); + return this.inner.InCriticalSection(out entityIds); + } + + public override Task LockEntitiesAsync(IEnumerable entityIds) + { + this.parent.EnsureLegalAccess(); + return this.inner.LockEntitiesAsync(entityIds); + } + } +} diff --git a/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs index 8e0333a57..53b7cca9e 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsOrchestrationContext.cs @@ -23,6 +23,7 @@ internal sealed partial class FunctionsOrchestrationContext : TaskOrchestrationC private readonly DurableTaskWorkerOptions options; private InputConverter? inputConverter; + private EntityFeature? entities; public FunctionsOrchestrationContext(TaskOrchestrationContext innerContext, FunctionContext functionContext) { @@ -49,7 +50,8 @@ public FunctionsOrchestrationContext(TaskOrchestrationContext innerContext, Func protected override ILoggerFactory LoggerFactory { get; } - public override TaskOrchestrationEntityFeature Entities => this.innerContext.Entities; + public override TaskOrchestrationEntityFeature Entities => + this.entities ??= new EntityFeature(this, this.innerContext.Entities); public override T GetInput() { @@ -118,15 +120,6 @@ public override Task WaitForExternalEvent(string eventName, CancellationTo return this.innerContext.WaitForExternalEvent(eventName, cancellationToken); } - /// - /// Throws if accessed by a non-orchestrator thread or marks the current object as accessed successfully. - /// - private void EnsureLegalAccess() - { - this.ThrowIfIllegalAccess(); - this.IsAccessed = true; - } - internal void ThrowIfIllegalAccess() { // Only the orchestrator thread is allowed to run the task continuation. If we detect that some other thread @@ -148,4 +141,13 @@ internal void ThrowIfIllegalAccess() } } } + + /// + /// Throws if accessed by a non-orchestrator thread or marks the current object as accessed successfully. + /// + private void EnsureLegalAccess() + { + this.ThrowIfIllegalAccess(); + this.IsAccessed = true; + } } diff --git a/src/Worker.Extensions.DurableTask/OrchestrationInputConverter.cs b/src/Worker.Extensions.DurableTask/OrchestrationInputConverter.cs index 9ed0e040a..8ba105b16 100644 --- a/src/Worker.Extensions.DurableTask/OrchestrationInputConverter.cs +++ b/src/Worker.Extensions.DurableTask/OrchestrationInputConverter.cs @@ -61,7 +61,7 @@ public ValueTask ConvertAsync(ConverterContext context) // 3. The TargetType matches our cached type. // If these are met, then we assume this parameter is the orchestration input. if (context.Source is null - && context.FunctionContext.Items.TryGetValue(OrchestrationInputKey, out object value) + && context.FunctionContext.Items.TryGetValue(OrchestrationInputKey, out object? value) && context.TargetType == value?.GetType()) { // Remove this from the items so we bind this only once. diff --git a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs index 7d0f06043..1fe449c23 100644 --- a/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs +++ b/src/Worker.Extensions.DurableTask/TaskEntityDispatcher.cs @@ -89,7 +89,7 @@ private class StateEntity : TaskEntity private class DelegateEntity : ITaskEntity { - readonly Func> handler; + private readonly Func> handler; public DelegateEntity(Func> handler) { diff --git a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj index a9b65350e..4b1d9ad7c 100644 --- a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj +++ b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj @@ -30,7 +30,7 @@ 1.1.0 - entities-preview.2 + entities-preview.3 $(VersionPrefix).0 $(VersionPrefix).$(FileVersionRevision) diff --git a/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs index bf188572d..8773d5a86 100644 --- a/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs +++ b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/Counter.cs @@ -103,7 +103,14 @@ public static async Task ReadCounter( logger.LogInformation($"Reading state of {entityId}..."); var response = await client.Entities.GetEntityAsync(entityId, includeState: true); - logger.LogInformation($"Read state of {entityId}: {response?.SerializedState ?? "(null: entity does not exist)"}"); + if (response?.IncludesState ?? false) + { + logger.LogInformation("Entity does not exist."); + } + else + { + logger.LogInformation("Entity state is: {State}", response!.State.Value); + } if (response == null) { @@ -111,7 +118,7 @@ public static async Task ReadCounter( } else { - int currentValue = response.ReadStateAs()!.CurrentValue; + int currentValue = response.State.ReadAs()!.CurrentValue; var httpResponse = request.CreateResponse(System.Net.HttpStatusCode.OK); httpResponse.Headers.Add("Content-Type", "text/plain; charset=utf-8"); httpResponse.WriteString($"{currentValue}\n"); diff --git a/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/DotNetIsolated.csproj b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/DotNetIsolated.csproj index 365d9c3e6..7c71f7d04 100644 --- a/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/DotNetIsolated.csproj +++ b/test/SmokeTests/OOProcSmokeTests/DotNetIsolated/DotNetIsolated.csproj @@ -1,4 +1,4 @@ - + net6.0 v4 @@ -11,7 +11,7 @@ - + From c545e4262689be02b15c216825d2e6112bc5b591 Mon Sep 17 00:00:00 2001 From: Sebastian Burckhardt Date: Thu, 12 Oct 2023 08:54:35 -0700 Subject: [PATCH 23/30] create a better error message in situations where client entity functions are called on a backend that does not support entities (#2630) --- src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 4ed5b62c5..9e2e473d8 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -449,7 +449,10 @@ private void CheckEntitySupport(ServerCallContext context, out DurabilityProvide entityOrchestrationService = durabilityProvider; if (entityOrchestrationService?.EntityBackendProperties == null) { - throw new NotSupportedException($"The provider '{durabilityProvider.GetBackendInfo()}' does not support entities."); + throw new RpcException(new Grpc.Core.Status( + Grpc.Core.StatusCode.Unimplemented, + $"Missing entity support for storage backend '{durabilityProvider.GetBackendInfo()}'. Entity support" + + $" may have not been implemented yet, or the selected package version is too old.")); } } From 9971383781fa9bf0c2174ef802be20d1bede7c57 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Tue, 17 Oct 2023 15:55:11 -0700 Subject: [PATCH 24/30] address PR feedback --- test/IsolatedEntities/Common/TestRunner.cs | 10 +++++----- test/IsolatedEntities/Tests/SetAndGet.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/IsolatedEntities/Common/TestRunner.cs b/test/IsolatedEntities/Common/TestRunner.cs index e6f9b4fff..17ca3bd17 100644 --- a/test/IsolatedEntities/Common/TestRunner.cs +++ b/test/IsolatedEntities/Common/TestRunner.cs @@ -16,7 +16,7 @@ internal static class TestRunner { public static async Task RunAsync(TestContext context, string? filter = null, bool listOnly = false) { - var sb = new StringBuilder(); + var output = new StringBuilder(); foreach (var test in All.GetAllTests()) { @@ -24,7 +24,7 @@ public static async Task RunAsync(TestContext context, string? filter = { if (listOnly) { - sb.AppendLine(test.Name); + output.AppendLine(test.Name); } else { @@ -40,18 +40,18 @@ public static async Task RunAsync(TestContext context, string? filter = try { await test.RunAsync(context); - sb.AppendLine($"PASSED {test.Name}"); + output.AppendLine($"PASSED {test.Name}"); } catch (Exception ex) { context.Logger.LogError(ex, "test {testName} failed", test.Name); - sb.AppendLine($"FAILED {test.Name} {ex.ToString()}"); + output.AppendLine($"FAILED {test.Name} {ex.ToString()}"); break; } } } } - return sb.ToString(); + return output.ToString(); } } diff --git a/test/IsolatedEntities/Tests/SetAndGet.cs b/test/IsolatedEntities/Tests/SetAndGet.cs index 16fdb09e1..93b501228 100644 --- a/test/IsolatedEntities/Tests/SetAndGet.cs +++ b/test/IsolatedEntities/Tests/SetAndGet.cs @@ -38,7 +38,7 @@ public override async Task RunAsync(TestContext context) int state = await context.WaitForEntityStateAsync(entityId); Assert.Equal(1, state); - // entity still exists + // if we query the entity state again it should still be the same result = await context.Client.Entities.GetEntityAsync(entityId); Assert.NotNull(result); From 82a1e8cfd747a7453af5ea12df9f351649062350 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Fri, 20 Oct 2023 09:54:59 -0700 Subject: [PATCH 25/30] fix merge error --- .../LocalGrpcListener.cs | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 564833c22..0bc7a9bac 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -256,95 +256,6 @@ public override Task Hello(Empty request, ServerCallContext context) }; } - public async override Task SignalEntity(P.SignalEntityRequest request, ServerCallContext context) - { - this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - - EntityMessageEvent eventToSend = ClientEntityHelpers.EmitOperationSignal( - new OrchestrationInstance() { InstanceId = request.InstanceId }, - Guid.Parse(request.RequestId), - request.Name, - request.Input, - EntityMessageEvent.GetCappedScheduledTime( - DateTime.UtcNow, - entityOrchestrationService.EntityBackendProperties!.MaximumSignalDelayTime, - request.ScheduledTime?.ToDateTime())); - - await durabilityProvider.SendTaskOrchestrationMessageAsync(eventToSend.AsTaskMessage()); - - // No fields in the response - return new P.SignalEntityResponse(); - } - - public async override Task GetEntity(P.GetEntityRequest request, ServerCallContext context) - { - this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - - EntityBackendQueries.EntityMetadata? metaData = await entityOrchestrationService.EntityBackendQueries!.GetEntityAsync( - DTCore.Entities.EntityId.FromString(request.InstanceId), - request.IncludeState, - includeStateless: false, - context.CancellationToken); - - return new P.GetEntityResponse() - { - Exists = metaData.HasValue, - Entity = metaData.HasValue ? this.ConvertEntityMetadata(metaData.Value) : default, - }; - } - - public async override Task QueryEntities(P.QueryEntitiesRequest request, ServerCallContext context) - { - this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - - P.EntityQuery query = request.Query; - EntityBackendQueries.EntityQueryResult result = await entityOrchestrationService.EntityBackendQueries!.QueryEntitiesAsync( - new EntityBackendQueries.EntityQuery() - { - InstanceIdStartsWith = query.InstanceIdStartsWith, - LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), - LastModifiedTo = query.LastModifiedTo?.ToDateTime(), - IncludeTransient = query.IncludeTransient, - IncludeState = query.IncludeState, - ContinuationToken = query.ContinuationToken, - PageSize = query.PageSize, - }, - context.CancellationToken); - - var response = new P.QueryEntitiesResponse() - { - ContinuationToken = result.ContinuationToken, - }; - - foreach (EntityBackendQueries.EntityMetadata entityMetadata in result.Results) - { - response.Entities.Add(this.ConvertEntityMetadata(entityMetadata)); - } - - return response; - } - - public async override Task CleanEntityStorage(P.CleanEntityStorageRequest request, ServerCallContext context) - { - this.CheckEntitySupport(context, out var durabilityProvider, out var entityOrchestrationService); - - EntityBackendQueries.CleanEntityStorageResult result = await entityOrchestrationService.EntityBackendQueries!.CleanEntityStorageAsync( - new EntityBackendQueries.CleanEntityStorageRequest() - { - RemoveEmptyEntities = request.RemoveEmptyEntities, - ReleaseOrphanedLocks = request.ReleaseOrphanedLocks, - ContinuationToken = request.ContinuationToken, - }, - context.CancellationToken); - - return new P.CleanEntityStorageResponse() - { - EmptyEntitiesRemoved = result.EmptyEntitiesRemoved, - OrphanedLocksReleased = result.OrphanedLocksReleased, - ContinuationToken = result.ContinuationToken, - }; - } - public async override Task TerminateInstance(P.TerminateRequest request, ServerCallContext context) { await this.GetClient(context).TerminateAsync(request.InstanceId, request.Output); @@ -535,31 +446,6 @@ private P.EntityMetadata ConvertEntityMetadata(EntityBackendQueries.EntityMetada SerializedState = metaData.SerializedState, }; } - - private void CheckEntitySupport(ServerCallContext context, out DurabilityProvider durabilityProvider, out IEntityOrchestrationService entityOrchestrationService) - { - durabilityProvider = this.GetDurabilityProvider(context); - entityOrchestrationService = durabilityProvider; - if (entityOrchestrationService?.EntityBackendProperties == null) - { - throw new RpcException(new Grpc.Core.Status( - Grpc.Core.StatusCode.Unimplemented, - $"Missing entity support for storage backend '{durabilityProvider.GetBackendInfo()}'. Entity support" + - $" may have not been implemented yet, or the selected package version is too old.")); - } - } - - private P.EntityMetadata ConvertEntityMetadata(EntityBackendQueries.EntityMetadata metaData) - { - return new P.EntityMetadata() - { - InstanceId = metaData.EntityId.ToString(), - LastModifiedTime = metaData.LastModifiedTime.ToTimestamp(), - BacklogQueueSize = metaData.BacklogQueueSize, - LockedBy = metaData.LockedBy, - SerializedState = metaData.SerializedState, - }; - } } } } From a4c370c136c9bcffef51e0e786a8838e7af9ff1a Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Wed, 13 Dec 2023 15:05:26 -0800 Subject: [PATCH 26/30] update SignalThenPoll test so it passes a non-null input, so that we are testing whether the input is propagated --- test/IsolatedEntities/Entities/Relay.cs | 8 ++++++-- test/IsolatedEntities/Tests/SignalThenPoll.cs | 20 ++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/test/IsolatedEntities/Entities/Relay.cs b/test/IsolatedEntities/Entities/Relay.cs index b25a4fc47..296a6d6e6 100644 --- a/test/IsolatedEntities/Entities/Relay.cs +++ b/test/IsolatedEntities/Entities/Relay.cs @@ -25,7 +25,7 @@ public static Task Boilerplate([EntityTrigger] TaskEntityDispatcher dispatcher) return dispatcher.DispatchAsync(); } - public record Input(EntityInstanceId entityInstanceId, string operationName, DateTimeOffset? scheduledTime); + public record Input(EntityInstanceId entityInstanceId, string operationName, object? input, DateTimeOffset? scheduledTime); public ValueTask RunAsync(TaskEntityOperation operation) { @@ -33,7 +33,11 @@ public record Input(EntityInstanceId entityInstanceId, string operationName, Dat Input input = GetInput(); - operation.Context.SignalEntity(input.entityInstanceId, input.operationName, new SignalEntityOptions() { SignalTime = input.scheduledTime }); + operation.Context.SignalEntity( + input.entityInstanceId, + input.operationName, + input.input, + new SignalEntityOptions() { SignalTime = input.scheduledTime }); return default; } diff --git a/test/IsolatedEntities/Tests/SignalThenPoll.cs b/test/IsolatedEntities/Tests/SignalThenPoll.cs index a39288f09..2e46aaf65 100644 --- a/test/IsolatedEntities/Tests/SignalThenPoll.cs +++ b/test/IsolatedEntities/Tests/SignalThenPoll.cs @@ -41,7 +41,8 @@ public override async Task RunAsync(TestContext context) { await context.Client.Entities.SignalEntityAsync( counterEntityId, - "increment", + "set", + 333, new SignalEntityOptions() { SignalTime = scheduledTime }, context.CancellationToken); } @@ -50,7 +51,7 @@ await context.Client.Entities.SignalEntityAsync( await context.Client.Entities.SignalEntityAsync( relayEntityId, operationName: "", - input: new Relay.Input(counterEntityId, "increment", scheduledTime), + input: new Relay.Input(counterEntityId, "set", 333, scheduledTime), options: null, context.CancellationToken); } @@ -63,6 +64,12 @@ await context.Client.Entities.SignalEntityAsync( { Assert.True(metadata.LastUpdatedAt > scheduledTime - TimeSpan.FromMilliseconds(100)); } + + int counterState = await context.WaitForEntityStateAsync( + counterEntityId, + timeout: default); + + Assert.Equal(333, counterState); } [Function(nameof(PollingOrchestration))] @@ -70,24 +77,27 @@ public static async Task PollingOrchestration([OrchestrationTrigger] Tas { // read entity id from input var entityId = context.GetInput(); + DateTime startTime = context.CurrentUtcDateTime; - while (true) + while (context.CurrentUtcDateTime < startTime + TimeSpan.FromSeconds(30)) { var result = await context.Entities.CallEntityAsync(entityId, "get"); if (result != 0) { - if (result == 1) + if (result == 333) { return "ok"; } else { - return $"fail: wrong entity state: expected 1, got {result}"; + return $"fail: wrong entity state: expected 333, got {result}"; } } await context.CreateTimer(DateTime.UtcNow + TimeSpan.FromSeconds(1), CancellationToken.None); } + + return "timed out while waiting for entity to have state"; } } \ No newline at end of file From 417f6506b2e95f382a29cfcbde40c97be4bc1025 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Fri, 16 Feb 2024 12:45:42 -0800 Subject: [PATCH 27/30] add test for faulty critical section. --- test/IsolatedEntities/Tests/All.cs | 1 + .../Tests/FaultyCriticalSection.cs | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 test/IsolatedEntities/Tests/FaultyCriticalSection.cs diff --git a/test/IsolatedEntities/Tests/All.cs b/test/IsolatedEntities/Tests/All.cs index 4a26f735c..e9edf0c0f 100644 --- a/test/IsolatedEntities/Tests/All.cs +++ b/test/IsolatedEntities/Tests/All.cs @@ -46,6 +46,7 @@ public static IEnumerable GetAllTests() yield return new MultipleLockedTransfers(2); yield return new MultipleLockedTransfers(5); yield return new MultipleLockedTransfers(100); + yield return new FaultyCriticalSection(); yield return new LargeEntity(); yield return new CallFaultyEntity(); yield return new CallFaultyEntityBatches(); diff --git a/test/IsolatedEntities/Tests/FaultyCriticalSection.cs b/test/IsolatedEntities/Tests/FaultyCriticalSection.cs new file mode 100644 index 000000000..493d76e11 --- /dev/null +++ b/test/IsolatedEntities/Tests/FaultyCriticalSection.cs @@ -0,0 +1,74 @@ +// 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.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class FaultyCriticalSection : Test +{ + public override async Task RunAsync(TestContext context) + { + var entityId = new EntityInstanceId(nameof(Counter), Guid.NewGuid().ToString()); + string orchestrationName = nameof(FaultyCriticalSectionOrchestration); + + // run the critical section but fail in the middle + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, new FaultyCriticalSectionOrchestration.Input(entityId, true)); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + Assert.True(metadata.SerializedOutput!.Contains("KABOOM")); + } + + // run the critical section again without failing this time - this will time out if lock was not released properly. + { + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, new FaultyCriticalSectionOrchestration.Input(entityId, false)); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } + } +} + +class FaultyCriticalSectionOrchestration +{ + readonly ILogger logger; + + public record Input(EntityInstanceId EntityInstanceId, bool Fail); + + public FaultyCriticalSectionOrchestration(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(FaultyCriticalSectionOrchestration))] + public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + // read input + var input = context.GetInput()!; + + await using (await context.Entities.LockEntitiesAsync(input.EntityInstanceId)) + { + if (input.Fail) + { + throw new Exception("KABOOM"); + } + } + + return "ok"; + } +} \ No newline at end of file From 37ef463e80665d28bec6cf4658f7a5db5e4e37f3 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Wed, 28 Feb 2024 11:12:59 -0800 Subject: [PATCH 28/30] add distinction on whether backend supports implicit deletion --- test/IsolatedEntities/Common/TestContext.cs | 2 ++ test/IsolatedEntities/Tests/CallFaultyEntity.cs | 2 +- test/IsolatedEntities/Tests/EntityQueries2.cs | 7 +++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/test/IsolatedEntities/Common/TestContext.cs b/test/IsolatedEntities/Common/TestContext.cs index 53be17d03..6d8cf7789 100644 --- a/test/IsolatedEntities/Common/TestContext.cs +++ b/test/IsolatedEntities/Common/TestContext.cs @@ -31,4 +31,6 @@ public TestContext(DurableTaskClient client, FunctionContext executionContext) 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/Tests/CallFaultyEntity.cs b/test/IsolatedEntities/Tests/CallFaultyEntity.cs index 6a44ca149..43380cf55 100644 --- a/test/IsolatedEntities/Tests/CallFaultyEntity.cs +++ b/test/IsolatedEntities/Tests/CallFaultyEntity.cs @@ -59,7 +59,7 @@ async Task ExpectOperationExceptionAsync(Task t, EntityInstanceId entityId, stri { Assert.Equal(operationName, entityException.OperationName); Assert.Equal(entityId, entityException.EntityId); - Assert.Contains(errorText, entityException.Message); + //Assert.Contains(errorText, entityException.Message); // requires microsoft/durabletask-dotnet#203 Assert.NotNull(entityException.FailureDetails); } catch (Exception e) diff --git a/test/IsolatedEntities/Tests/EntityQueries2.cs b/test/IsolatedEntities/Tests/EntityQueries2.cs index 3321ff42c..954158333 100644 --- a/test/IsolatedEntities/Tests/EntityQueries2.cs +++ b/test/IsolatedEntities/Tests/EntityQueries2.cs @@ -99,7 +99,7 @@ await Parallel.ForEachAsync( }, result => { - Assert.Equal(8, result.Count()); // TODO this is provider-specific + Assert.Equal(context.BackendSupportsImplicitEntityDeletion ? 4 : 8, result.Count()); }), @@ -119,7 +119,7 @@ await Parallel.ForEachAsync( }, result => { - Assert.Equal(8, result.Count()); // TODO this is provider-specific + Assert.Equal(context.BackendSupportsImplicitEntityDeletion ? 4 : 8, result.Count()); // TODO this is provider-specific }), }; @@ -135,13 +135,12 @@ await Parallel.ForEachAsync( } // ----- remove the 4 deleted entities whose metadata still lingers in Azure Storage provider - // TODO this is provider-specific context.Logger.LogInformation("starting storage cleaning"); var cleaningResponse = await context.Client.Entities.CleanEntityStorageAsync(); - Assert.Equal(4, cleaningResponse.EmptyEntitiesRemoved); + Assert.Equal(context.BackendSupportsImplicitEntityDeletion ? 0 : 4, cleaningResponse.EmptyEntitiesRemoved); Assert.Equal(0, cleaningResponse.OrphanedLocksReleased); } } \ No newline at end of file From 179f7e103fce8e92ab92e591f5744316e453ea26 Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Thu, 29 Feb 2024 11:42:55 -0800 Subject: [PATCH 29/30] add non-entity tests for failure propagation by activities and suborchestrators. --- test/IsolatedEntities/Tests/All.cs | 2 + .../Tests/CallFaultyActivity.cs | 78 +++++++++++++++++++ .../Tests/CallFaultySuborchestration.cs | 78 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 test/IsolatedEntities/Tests/CallFaultyActivity.cs create mode 100644 test/IsolatedEntities/Tests/CallFaultySuborchestration.cs diff --git a/test/IsolatedEntities/Tests/All.cs b/test/IsolatedEntities/Tests/All.cs index e9edf0c0f..5f54f11d5 100644 --- a/test/IsolatedEntities/Tests/All.cs +++ b/test/IsolatedEntities/Tests/All.cs @@ -57,6 +57,8 @@ public static IEnumerable GetAllTests() yield return new InvalidEntityId(InvalidEntityId.Location.ClientSignal); yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationCall); yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationSignal); + yield return new CallFaultyActivity(); + yield return new CallFaultySuborchestration(); } } diff --git a/test/IsolatedEntities/Tests/CallFaultyActivity.cs b/test/IsolatedEntities/Tests/CallFaultyActivity.cs new file mode 100644 index 000000000..b440f0491 --- /dev/null +++ b/test/IsolatedEntities/Tests/CallFaultyActivity.cs @@ -0,0 +1,78 @@ +// 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.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class CallFaultyActivity : Test +{ + // this is not an entity test... but it's a good place to put this test + + public override async Task RunAsync(TestContext context) + { + string orchestrationName = nameof(CallFaultyActivityOrchestration); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } +} + +class CallFaultyActivityOrchestration +{ + readonly ILogger logger; + + public CallFaultyActivityOrchestration(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(FaultyActivity))] + public void FaultyActivity([ActivityTrigger] TaskActivityContext context) + { + this.MethodThatThrows(); + } + + void MethodThatThrows() + { + throw new Exception("KABOOM"); + } + + [Function(nameof(CallFaultyActivityOrchestration))] + public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + try + { + await context.CallActivityAsync(nameof(FaultyActivity)); + throw new Exception("expected activity to throw exception, but none was thrown"); + } + catch (TaskFailedException taskFailedException) + { + Assert.NotNull(taskFailedException.FailureDetails); + Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrows), taskFailedException.FailureDetails.StackTrace); + } + catch (Exception e) + { + throw new Exception($"wrong exception thrown", e); + } + + return "ok"; + } +} diff --git a/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs b/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs new file mode 100644 index 000000000..4bff45ed2 --- /dev/null +++ b/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs @@ -0,0 +1,78 @@ +// 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.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using DurableTask.Core.Entities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.DurableTask; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace IsolatedEntities; + +class CallFaultySuborchestration : Test +{ + // this is not an entity test... but it's a good place to put this test + + public override async Task RunAsync(TestContext context) + { + string orchestrationName = nameof(CallFaultySuborchestrationOrchestration); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName); + var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); + + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("ok", metadata.ReadOutputAs()); + } +} + +class CallFaultySuborchestrationOrchestration +{ + readonly ILogger logger; + + public CallFaultySuborchestrationOrchestration(ILogger logger) + { + this.logger = logger; + } + + [Function(nameof(FaultySuborchestration))] + public void FaultySuborchestration([OrchestrationTrigger] TaskOrchestrationContext context) + { + this.MethodThatThrows(); + } + + void MethodThatThrows() + { + throw new Exception("KABOOM"); + } + + [Function(nameof(CallFaultySuborchestrationOrchestration))] + public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) + { + try + { + await context.CallSubOrchestratorAsync(nameof(FaultySuborchestration)); + throw new Exception("expected suborchestrator to throw exception, but none was thrown"); + } + catch (TaskFailedException taskFailedException) + { + Assert.NotNull(taskFailedException.FailureDetails); + Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrows), taskFailedException.FailureDetails.StackTrace); + } + catch (Exception e) + { + throw new Exception($"wrong exception thrown", e); + } + + return "ok"; + } +} From 8123d174a265062f9ae3018aec642652f8e7078f Mon Sep 17 00:00:00 2001 From: sebastianburckhardt Date: Thu, 29 Feb 2024 16:07:07 -0800 Subject: [PATCH 30/30] refine the entity error tests to check for nested failure details (inner exceptions), and similarly for activity and orchestration error checking. --- .../IsolatedEntities/Entities/FaultyEntity.cs | 12 ++++ test/IsolatedEntities/Tests/All.cs | 10 ++- .../Tests/CallFaultyActivity.cs | 56 ++++++++++++++--- .../Tests/CallFaultyEntity.cs | 63 ++++++++++++++++--- .../Tests/CallFaultySuborchestration.cs | 57 +++++++++++++++-- 5 files changed, 174 insertions(+), 24 deletions(-) diff --git a/test/IsolatedEntities/Entities/FaultyEntity.cs b/test/IsolatedEntities/Entities/FaultyEntity.cs index eba7bb250..854f6c8eb 100644 --- a/test/IsolatedEntities/Entities/FaultyEntity.cs +++ b/test/IsolatedEntities/Entities/FaultyEntity.cs @@ -116,6 +116,18 @@ State GetOrCreate() ThrowTestException(); return default; } + case "ThrowNested": + { + try + { + ThrowTestException(); + } + catch (Exception e) + { + throw new Exception("KABOOOOOM", e); + } + return default; + } case "Get": { return GetOrCreate().Value; diff --git a/test/IsolatedEntities/Tests/All.cs b/test/IsolatedEntities/Tests/All.cs index 5f54f11d5..c6aa8d1ba 100644 --- a/test/IsolatedEntities/Tests/All.cs +++ b/test/IsolatedEntities/Tests/All.cs @@ -57,8 +57,14 @@ public static IEnumerable GetAllTests() yield return new InvalidEntityId(InvalidEntityId.Location.ClientSignal); yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationCall); yield return new InvalidEntityId(InvalidEntityId.Location.OrchestrationSignal); - yield return new CallFaultyActivity(); - yield return new CallFaultySuborchestration(); + yield return new CallFaultyActivity(nested: false); + + // requires https://github.com/Azure/azure-functions-durable-extension/pull/2748 + yield return new CallFaultySuborchestration(nested: false); + + // these tests require us to implement better propagation of FailureDetails for activities and orchestrations + // yield return new CallFaultyActivity(nested: true); + //yield return new CallFaultySuborchestration(nested: true); } } diff --git a/test/IsolatedEntities/Tests/CallFaultyActivity.cs b/test/IsolatedEntities/Tests/CallFaultyActivity.cs index b440f0491..6ea40393b 100644 --- a/test/IsolatedEntities/Tests/CallFaultyActivity.cs +++ b/test/IsolatedEntities/Tests/CallFaultyActivity.cs @@ -23,10 +23,18 @@ class CallFaultyActivity : Test { // this is not an entity test... but it's a good place to put this test + private readonly bool nested; + + public CallFaultyActivity(bool nested) + { + this.nested = nested; + } + public override string Name => $"{base.Name}.{(this.nested ? "Nested" : "NotNested")}"; + public override async Task RunAsync(TestContext context) { string orchestrationName = nameof(CallFaultyActivityOrchestration); - string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, this.nested); var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); @@ -44,12 +52,31 @@ public CallFaultyActivityOrchestration(ILogger lo } [Function(nameof(FaultyActivity))] - public void FaultyActivity([ActivityTrigger] TaskActivityContext context) + public void FaultyActivity([ActivityTrigger] bool nested) { - this.MethodThatThrows(); + if (!nested) + { + this.MethodThatThrowsException(); + } + else + { + this.MethodThatThrowsNestedException(); + } } - void MethodThatThrows() + void MethodThatThrowsNestedException() + { + try + { + this.MethodThatThrowsException(); + } + catch (Exception e) + { + throw new Exception("KABOOOOOM", e); + } + } + + void MethodThatThrowsException() { throw new Exception("KABOOM"); } @@ -57,16 +84,31 @@ void MethodThatThrows() [Function(nameof(CallFaultyActivityOrchestration))] public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) { + bool nested = context.GetInput(); + try { - await context.CallActivityAsync(nameof(FaultyActivity)); + await context.CallActivityAsync(nameof(FaultyActivity), nested); throw new Exception("expected activity to throw exception, but none was thrown"); } catch (TaskFailedException taskFailedException) { Assert.NotNull(taskFailedException.FailureDetails); - Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); - Assert.Contains(nameof(MethodThatThrows), taskFailedException.FailureDetails.StackTrace); + + if (!nested) + { + Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsException), taskFailedException.FailureDetails.StackTrace); + } + else + { + Assert.Equal("KABOOOOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsNestedException), taskFailedException.FailureDetails.StackTrace); + + Assert.NotNull(taskFailedException.FailureDetails.InnerFailure); + Assert.Equal("KABOOM", taskFailedException.FailureDetails.InnerFailure!.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsException), taskFailedException.FailureDetails.InnerFailure.StackTrace); + } } catch (Exception e) { diff --git a/test/IsolatedEntities/Tests/CallFaultyEntity.cs b/test/IsolatedEntities/Tests/CallFaultyEntity.cs index 43380cf55..9d3e08fe4 100644 --- a/test/IsolatedEntities/Tests/CallFaultyEntity.cs +++ b/test/IsolatedEntities/Tests/CallFaultyEntity.cs @@ -48,19 +48,42 @@ public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationConte // read entity id from input var entityId = context.GetInput(); - async Task ExpectOperationExceptionAsync(Task t, EntityInstanceId entityId, string operationName, string errorText) + async Task ExpectOperationExceptionAsync(Task t, EntityInstanceId entityId, string operationName, + string errorMessage, string? errorMethod = null, string? innerErrorMessage = null, string innerErrorMethod = "") { try { await t; throw new Exception("expected operation exception, but none was thrown"); } - catch(EntityOperationFailedException entityException) + catch (EntityOperationFailedException entityException) { Assert.Equal(operationName, entityException.OperationName); Assert.Equal(entityId, entityException.EntityId); - //Assert.Contains(errorText, entityException.Message); // requires microsoft/durabletask-dotnet#203 + Assert.Contains(errorMessage, entityException.Message); + Assert.NotNull(entityException.FailureDetails); + Assert.Equal(errorMessage, entityException.FailureDetails.ErrorMessage); + + if (errorMethod != null) + { + Assert.Contains(errorMethod, entityException.FailureDetails.StackTrace); + } + + if (innerErrorMessage != null) + { + Assert.NotNull(entityException.FailureDetails.InnerFailure); + Assert.Equal(innerErrorMessage, entityException.FailureDetails.InnerFailure!.ErrorMessage); + + if (innerErrorMethod != null) + { + Assert.Contains(innerErrorMethod, entityException.FailureDetails.InnerFailure.StackTrace); + } + } + else + { + Assert.Null(entityException.FailureDetails.InnerFailure); + } } catch (Exception e) { @@ -72,13 +95,30 @@ async Task ExpectOperationExceptionAsync(Task t, EntityInstanceId entityId, stri { Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "Throw"), + entityId, + "Throw", + "KABOOM", + "ThrowTestException"); + + await ExpectOperationExceptionAsync( + context.Entities.CallEntityAsync(entityId, "ThrowNested"), + entityId, + "ThrowNested", + "KABOOOOOM", + "FaultyEntity.RunAsync", + "KABOOM", + "ThrowTestException"); + await ExpectOperationExceptionAsync( context.Entities.CallEntityAsync(entityId, "SetToUnserializable"), entityId, "SetToUnserializable", - "problematic object: is not serializable"); + "problematic object: is not serializable", + "ProblematicObjectJsonConverter.Write"); - // since the operation failed, the entity state is unchanged, meaning the entity still does not exist + // since the operations failed, the entity state is unchanged, meaning the entity still does not exist Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); await context.Entities.CallEntityAsync(entityId, "SetToUndeserializable"); @@ -89,7 +129,8 @@ await ExpectOperationExceptionAsync( context.Entities.CallEntityAsync(entityId, "Get"), entityId, "Get", - "problematic object: is not deserializable"); + "problematic object: is not deserializable", + "ProblematicObjectJsonConverter.Read"); await context.Entities.CallEntityAsync(entityId, "DeleteWithoutReading"); @@ -103,7 +144,9 @@ await ExpectOperationExceptionAsync( context.Entities.CallEntityAsync(entityId, "SetThenThrow", 333), entityId, "SetThenThrow", - "KABOOM"); + "KABOOM", + "FaultyEntity.RunAsync"); + // value should be unchanged Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); @@ -112,7 +155,8 @@ await ExpectOperationExceptionAsync( context.Entities.CallEntityAsync(entityId, "DeleteThenThrow"), entityId, "DeleteThenThrow", - "KABOOM"); + "KABOOM", + "FaultyEntity.RunAsync"); // value should be unchanged Assert.Equal(3, await context.Entities.CallEntityAsync(entityId, "Get")); @@ -126,7 +170,8 @@ await ExpectOperationExceptionAsync( context.Entities.CallEntityAsync(entityId, "SetThenThrow", 333), entityId, "SetThenThrow", - "KABOOM"); + "KABOOM", + "FaultyEntity.RunAsync"); // must have rolled back to non-existing state Assert.False(await context.Entities.CallEntityAsync(entityId, "Exists")); diff --git a/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs b/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs index 4bff45ed2..e2ec11069 100644 --- a/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs +++ b/test/IsolatedEntities/Tests/CallFaultySuborchestration.cs @@ -23,10 +23,19 @@ class CallFaultySuborchestration : Test { // this is not an entity test... but it's a good place to put this test + private readonly bool nested; + + public CallFaultySuborchestration(bool nested) + { + this.nested = nested; + } + + public override string Name => $"{base.Name}.{(this.nested ? "Nested" : "NotNested")}"; + public override async Task RunAsync(TestContext context) { string orchestrationName = nameof(CallFaultySuborchestrationOrchestration); - string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName); + string instanceId = await context.Client.ScheduleNewOrchestrationInstanceAsync(orchestrationName, this.nested); var metadata = await context.Client.WaitForInstanceCompletionAsync(instanceId, true); Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); @@ -46,10 +55,31 @@ public CallFaultySuborchestrationOrchestration(ILogger(); + + if (!nested) + { + this.MethodThatThrowsException(); + } + else + { + this.MethodThatThrowsNestedException(); + } + } + + void MethodThatThrowsNestedException() + { + try + { + this.MethodThatThrowsException(); + } + catch (Exception e) + { + throw new Exception("KABOOOOOM", e); + } } - void MethodThatThrows() + void MethodThatThrowsException() { throw new Exception("KABOOM"); } @@ -57,16 +87,31 @@ void MethodThatThrows() [Function(nameof(CallFaultySuborchestrationOrchestration))] public async Task RunAsync([OrchestrationTrigger] TaskOrchestrationContext context) { + bool nested = context.GetInput(); + try { - await context.CallSubOrchestratorAsync(nameof(FaultySuborchestration)); + await context.CallSubOrchestratorAsync(nameof(FaultySuborchestration), nested); throw new Exception("expected suborchestrator to throw exception, but none was thrown"); } catch (TaskFailedException taskFailedException) { Assert.NotNull(taskFailedException.FailureDetails); - Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); - Assert.Contains(nameof(MethodThatThrows), taskFailedException.FailureDetails.StackTrace); + + if (!nested) + { + Assert.Equal("KABOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsException), taskFailedException.FailureDetails.StackTrace); + } + else + { + Assert.Equal("KABOOOOOM", taskFailedException.FailureDetails.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsNestedException), taskFailedException.FailureDetails.StackTrace); + + Assert.NotNull(taskFailedException.FailureDetails.InnerFailure); + Assert.Equal("KABOOM", taskFailedException.FailureDetails.InnerFailure!.ErrorMessage); + Assert.Contains(nameof(MethodThatThrowsException), taskFailedException.FailureDetails.InnerFailure.StackTrace); + } } catch (Exception e) {