diff --git a/src/Client/Core/DependencyInjection/DefaultDurableTaskClientBuilder.cs b/src/Client/Core/DependencyInjection/DefaultDurableTaskClientBuilder.cs index c08b2003..10316c80 100644 --- a/src/Client/Core/DependencyInjection/DefaultDurableTaskClientBuilder.cs +++ b/src/Client/Core/DependencyInjection/DefaultDurableTaskClientBuilder.cs @@ -9,26 +9,20 @@ namespace Microsoft.DurableTask.Client; /// /// Default builder for . /// -public class DefaultDurableTaskClientBuilder : IDurableTaskClientBuilder +/// +/// Initializes a new instance of the class. +/// +/// The name of the builder. +/// The service collection. +public class DefaultDurableTaskClientBuilder(string? name, IServiceCollection services) : IDurableTaskClientBuilder { Type? buildTarget; - /// - /// Initializes a new instance of the class. - /// - /// The name of the builder. - /// The service collection. - public DefaultDurableTaskClientBuilder(string? name, IServiceCollection services) - { - this.Name = name ?? Options.DefaultName; - this.Services = services; - } - /// - public string Name { get; } + public string Name { get; } = name ?? Options.DefaultName; /// - public IServiceCollection Services { get; } + public IServiceCollection Services { get; } = services; /// public Type? BuildTarget diff --git a/src/Client/Core/DurableTaskClientOptions.cs b/src/Client/Core/DurableTaskClientOptions.cs index 142be7e5..296b750d 100644 --- a/src/Client/Core/DurableTaskClientOptions.cs +++ b/src/Client/Core/DurableTaskClientOptions.cs @@ -11,6 +11,7 @@ namespace Microsoft.DurableTask.Client; public class DurableTaskClientOptions { DataConverter dataConverter = JsonDataConverter.Default; + bool enableEntitySupport; /// /// Gets or sets the data converter. Default value is . @@ -50,7 +51,15 @@ public DataConverter DataConverter /// Gets or sets a value indicating whether this client should support entities. If true, all instance ids starting with '@' are reserved for entities, /// and validation checks are performed where appropriate. /// - public bool EnableEntitySupport { get; set; } + public bool EnableEntitySupport + { + get => this.enableEntitySupport; + set + { + this.enableEntitySupport = value; + this.EntitySupportExplicitlySet = true; + } + } /// /// Gets a value indicating whether was explicitly set or not. @@ -63,6 +72,11 @@ public DataConverter DataConverter /// internal bool DataConverterExplicitlySet { get; private set; } + /// + /// Gets a value indicating whether was explicitly set or not. + /// + internal bool EntitySupportExplicitlySet { get; private set; } + /// /// Applies these option values to another. /// @@ -72,8 +86,15 @@ internal void ApplyTo(DurableTaskClientOptions other) if (other is not null) { // Make sure to keep this up to date as values are added. - other.DataConverter = this.DataConverter; - other.EnableEntitySupport = this.EnableEntitySupport; + if (!other.DataConverterExplicitlySet) + { + other.DataConverter = this.DataConverter; + } + + if (!other.EntitySupportExplicitlySet) + { + other.EnableEntitySupport = this.EnableEntitySupport; + } } } } diff --git a/src/Client/Core/Entities/EntityMetadata.cs b/src/Client/Core/Entities/EntityMetadata.cs index 9eaf5671..f0064853 100644 --- a/src/Client/Core/Entities/EntityMetadata.cs +++ b/src/Client/Core/Entities/EntityMetadata.cs @@ -11,21 +11,15 @@ namespace Microsoft.DurableTask.Client.Entities; /// Represents entity metadata. /// /// The type of state held by the metadata. +/// +/// Initializes a new instance of the class. +/// +/// The ID of the entity. [JsonConverter(typeof(EntityMetadataConverter))] -public class EntityMetadata +public class EntityMetadata(EntityInstanceId id) { readonly TState? state; - /// - /// Initializes a new instance of the class. - /// - /// The ID of the entity. - public EntityMetadata(EntityInstanceId id) - { - this.Id = Check.NotDefault(id); - this.IncludesState = false; - } - /// /// Initializes a new instance of the class. /// @@ -41,7 +35,7 @@ public EntityMetadata(EntityInstanceId id, TState? state) /// /// Gets the ID for this entity. /// - public EntityInstanceId Id { get; } + public EntityInstanceId Id { get; } = Check.NotDefault(id); /// /// Gets the time the entity was last modified. @@ -64,9 +58,9 @@ public EntityMetadata(EntityInstanceId id, TState? state) /// /// Queries can exclude the state of the entity from the metadata that is retrieved. /// - [MemberNotNullWhen(true, "State")] - [MemberNotNullWhen(true, "state")] - public bool IncludesState { get; } + [MemberNotNullWhen(true, nameof(State))] + [MemberNotNullWhen(true, nameof(state))] + public bool IncludesState { get; } = false; /// /// Gets the state for this entity. @@ -96,16 +90,13 @@ public TState State /// /// Represents the metadata for a durable entity instance. /// +/// +/// Initializes a new instance of the class. +/// +/// The ID of the entity. +/// The state of this entity. [JsonConverter(typeof(EntityMetadataConverter))] -public sealed class EntityMetadata : EntityMetadata +public sealed class EntityMetadata(EntityInstanceId id, SerializedData? state = null) + : EntityMetadata(id, state) { - /// - /// Initializes a new instance of the class. - /// - /// The ID of the entity. - /// The state of this entity. - public EntityMetadata(EntityInstanceId id, SerializedData? state = null) - : base(id, state) - { - } } diff --git a/src/Client/Core/SerializedData.cs b/src/Client/Core/SerializedData.cs index 6a777680..7bd2c127 100644 --- a/src/Client/Core/SerializedData.cs +++ b/src/Client/Core/SerializedData.cs @@ -8,28 +8,22 @@ namespace Microsoft.DurableTask.Client; /// /// Gets a type representing serialized data. /// -public sealed class SerializedData +/// +/// Initializes a new instance of the class. +/// +/// The serialized data. +/// The data converter. +public sealed class SerializedData(string data, DataConverter? converter = null) { - /// - /// Initializes a new instance of the class. - /// - /// The serialized data. - /// The data converter. - public SerializedData(string data, DataConverter? converter = null) - { - this.Value = Check.NotNull(data); - this.Converter = converter ?? JsonDataConverter.Default; - } - /// /// Gets the serialized value. /// - public string Value { get; } + public string Value { get; } = Check.NotNull(data); /// /// Gets the data converter. /// - public DataConverter Converter { get; } + public DataConverter Converter { get; } = converter ?? JsonDataConverter.Default; /// /// Deserializes the data into . diff --git a/src/Client/OrchestrationServiceClientShim/Client.OrchestrationServiceClientShim.csproj b/src/Client/OrchestrationServiceClientShim/Client.OrchestrationServiceClientShim.csproj index b5389866..aa687a11 100644 --- a/src/Client/OrchestrationServiceClientShim/Client.OrchestrationServiceClientShim.csproj +++ b/src/Client/OrchestrationServiceClientShim/Client.OrchestrationServiceClientShim.csproj @@ -8,13 +8,6 @@ The client is responsible for interacting with orchestrations from outside the w true - - - 1.0.5 - - - - diff --git a/src/Client/OrchestrationServiceClientShim/DependencyInjection/DurableTaskClientBuilderExtensions.cs b/src/Client/OrchestrationServiceClientShim/DependencyInjection/DurableTaskClientBuilderExtensions.cs index ba69a58e..7d2cf12b 100644 --- a/src/Client/OrchestrationServiceClientShim/DependencyInjection/DurableTaskClientBuilderExtensions.cs +++ b/src/Client/OrchestrationServiceClientShim/DependencyInjection/DurableTaskClientBuilderExtensions.cs @@ -2,8 +2,11 @@ // Licensed under the MIT License. using DurableTask.Core; +using DurableTask.Core.Entities; using Microsoft.DurableTask.Client.OrchestrationServiceClientShim; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace Microsoft.DurableTask.Client; @@ -54,21 +57,74 @@ public static IDurableTaskClientBuilder UseOrchestrationService( { Check.NotNull(builder); Check.NotNull(configure); - builder.Services.Configure(builder.Name, configure); - builder.Services.AddOptions(builder.Name) - .PostConfigure((opt, sp) => + builder.Services.AddOptions(builder.Name).Configure(configure); + builder.Services.TryAddSingleton, OptionsConfigure>(); + builder.Services.TryAddSingleton, OptionsValidator>(); + return builder.UseBuildTarget(); + } + + static IEntityOrchestrationService? GetEntityService( + IServiceProvider services, ShimDurableTaskClientOptions options) + { + return options.Client as IEntityOrchestrationService + ?? services.GetService() + ?? services.GetService() as IEntityOrchestrationService + ?? services.GetService() as IEntityOrchestrationService; + } + + class OptionsConfigure(IServiceProvider services) : IPostConfigureOptions + { + public void PostConfigure(string name, ShimDurableTaskClientOptions options) + { + ConfigureClient(services, options); + ConfigureEntities(name, services, options); // Must be called after ConfigureClient. + } + + static void ConfigureClient(IServiceProvider services, ShimDurableTaskClientOptions options) + { + if (options.Client is not null) { - if (opt.Client is not null) - { - return; - } + return; + } - // Try to resolve client from service container. - opt.Client = sp.GetService() - ?? sp.GetService() as IOrchestrationServiceClient; - }) - .Validate(x => x.Client is not null, "ShimDurableTaskClientOptions.Client must not be null."); + // Try to resolve client from service container. + options.Client = services.GetService() + ?? services.GetService() as IOrchestrationServiceClient; + } - return builder.UseBuildTarget(); + static void ConfigureEntities(string name, IServiceProvider services, ShimDurableTaskClientOptions options) + { + if (options.Entities.Queries is null) + { + options.Entities.Queries = services.GetService() + ?? GetEntityService(services, options)?.EntityBackendQueries; + } + + if (options.Entities.MaxSignalDelayTime is null) + { + EntityBackendProperties? properties = services.GetService>()?.Get(name) + ?? GetEntityService(services, options)?.EntityBackendProperties; + options.Entities.MaxSignalDelayTime = properties?.MaximumSignalDelayTime; + } + } + } + + class OptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string name, ShimDurableTaskClientOptions options) + { + if (options.Client is null) + { + return ValidateOptionsResult.Fail("ShimDurableTaskClientOptions.Client must not be null."); + } + + if (options.EnableEntitySupport && options.Entities.Queries is null) + { + return ValidateOptionsResult.Fail( + "ShimDurableTaskClientOptions.Entities.Queries must not be null when entity support is enabled."); + } + + return ValidateOptionsResult.Success; + } } } diff --git a/src/Client/OrchestrationServiceClientShim/ShimDurableEntityClient.cs b/src/Client/OrchestrationServiceClientShim/ShimDurableEntityClient.cs new file mode 100644 index 00000000..10571e37 --- /dev/null +++ b/src/Client/OrchestrationServiceClientShim/ShimDurableEntityClient.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DurableTask.Core; +using DurableTask.Core.Entities; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; + +namespace Microsoft.DurableTask.Client.OrchestrationServiceClientShim; + +/// +/// A shim client for interacting with entities backend via . +/// +/// +/// Initializes a new instance of the class. +/// +/// The name of this client. +/// The client options.. +class ShimDurableEntityClient(string name, ShimDurableTaskClientOptions options) : DurableEntityClient(name) +{ + readonly ShimDurableTaskClientOptions options = Check.NotNull(options); + + EntityBackendQueries Queries => this.options.Entities.Queries!; + + DataConverter Converter => this.options.DataConverter; + + /// + public override async Task CleanEntityStorageAsync( + CleanEntityStorageRequest? request = null, + bool continueUntilComplete = true, + CancellationToken cancellation = default) + { + CleanEntityStorageRequest r = request ?? CleanEntityStorageRequest.Default; + EntityBackendQueries.CleanEntityStorageResult result = await this.Queries.CleanEntityStorageAsync( + new EntityBackendQueries.CleanEntityStorageRequest() + { + RemoveEmptyEntities = r.RemoveEmptyEntities, + ReleaseOrphanedLocks = r.ReleaseOrphanedLocks, + ContinuationToken = r.ContinuationToken, + }, + cancellation); + + return new() + { + EmptyEntitiesRemoved = result.EmptyEntitiesRemoved, + OrphanedLocksReleased = result.OrphanedLocksReleased, + ContinuationToken = result.ContinuationToken, + }; + } + + /// + public override AsyncPageable GetAllEntitiesAsync(EntityQuery? filter = null) + => this.GetAllEntitiesAsync(this.Convert, filter); + + /// + public override AsyncPageable> GetAllEntitiesAsync(EntityQuery? filter = null) + => this.GetAllEntitiesAsync(this.Convert, filter); + + /// + public override async Task GetEntityAsync( + EntityInstanceId id, bool includeState = true, CancellationToken cancellation = default) + => this.Convert(await this.Queries.GetEntityAsync( + id.ConvertToCore(), includeState, false, cancellation)); + + /// + public override async Task?> GetEntityAsync( + EntityInstanceId id, bool includeState = true, CancellationToken cancellation = default) + => this.Convert(await this.Queries.GetEntityAsync( + id.ConvertToCore(), includeState, false, cancellation)); + + /// + public override async Task SignalEntityAsync( + EntityInstanceId id, + string operationName, + object? input = null, + SignalEntityOptions? options = null, + CancellationToken cancellation = default) + { + Check.NotNullOrEmpty(id.Name); + Check.NotNull(id.Key); + + DateTimeOffset? scheduledTime = options?.SignalTime; + string? serializedInput = this.Converter.Serialize(input); + + EntityMessageEvent eventToSend = ClientEntityHelpers.EmitOperationSignal( + new OrchestrationInstance() { InstanceId = id.ToString() }, + Guid.NewGuid(), + operationName, + serializedInput, + EntityMessageEvent.GetCappedScheduledTime( + DateTime.UtcNow, + this.options.Entities.MaxSignalDelayTimeOrDefault, + scheduledTime?.UtcDateTime)); + + await this.options.Client!.SendTaskOrchestrationMessageAsync(eventToSend.AsTaskMessage()); + } + + AsyncPageable GetAllEntitiesAsync( + Func select, + EntityQuery? filter) + where TMetadata : notnull + { + bool includeState = filter?.IncludeState ?? true; + bool includeTransient = filter?.IncludeTransient ?? false; + string startsWith = filter?.InstanceIdStartsWith ?? string.Empty; + DateTime? lastModifiedFrom = filter?.LastModifiedFrom?.UtcDateTime; + DateTime? lastModifiedTo = filter?.LastModifiedTo?.UtcDateTime; + + return Pageable.Create(async (continuation, size, cancellation) => + { + continuation ??= filter?.ContinuationToken; + size ??= filter?.PageSize; + EntityBackendQueries.EntityQueryResult result = await this.Queries.QueryEntitiesAsync( + new EntityBackendQueries.EntityQuery() + { + InstanceIdStartsWith = startsWith, + LastModifiedFrom = lastModifiedFrom, + LastModifiedTo = lastModifiedTo, + IncludeTransient = includeTransient, + IncludeState = includeState, + ContinuationToken = continuation, + PageSize = size, + }, + cancellation); + + return new Page([.. result.Results.Select(select)], result.ContinuationToken); + }); + } + + EntityMetadata Convert(EntityBackendQueries.EntityMetadata metadata) + { + return new( + metadata.EntityId.ConvertFromCore(), + this.Converter.Deserialize(metadata.SerializedState)) + { + LastModifiedTime = metadata.LastModifiedTime, + BacklogQueueSize = metadata.BacklogQueueSize, + LockedBy = metadata.LockedBy, + }; + } + + EntityMetadata? Convert(EntityBackendQueries.EntityMetadata? metadata) + { + if (metadata is null) + { + return null; + } + + return this.Convert(metadata.Value); + } + + EntityMetadata Convert(EntityBackendQueries.EntityMetadata metadata) + { + SerializedData? data = metadata.SerializedState is null ? null : new(metadata.SerializedState, this.Converter); + return new(new EntityInstanceId(metadata.EntityId.Name, metadata.EntityId.Key), data) + { + LastModifiedTime = metadata.LastModifiedTime, + BacklogQueueSize = metadata.BacklogQueueSize, + LockedBy = metadata.LockedBy, + }; + } + + EntityMetadata? Convert(EntityBackendQueries.EntityMetadata? metadata) + { + if (metadata is null) + { + return null; + } + + return this.Convert(metadata.Value); + } +} diff --git a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs index 3f3c5211..ce510fd0 100644 --- a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs +++ b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs @@ -3,8 +3,10 @@ using System.Diagnostics.CodeAnalysis; using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.History; using DurableTask.Core.Query; +using Microsoft.DurableTask.Client.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Core = DurableTask.Core; @@ -15,9 +17,15 @@ namespace Microsoft.DurableTask.Client.OrchestrationServiceClientShim; /// /// A shim client for interacting with the backend via . /// -class ShimDurableTaskClient : DurableTaskClient +/// +/// Initializes a new instance of the class. +/// +/// The name of the client. +/// The client options. +class ShimDurableTaskClient(string name, ShimDurableTaskClientOptions options) : DurableTaskClient(name) { - readonly ShimDurableTaskClientOptions options; + readonly ShimDurableTaskClientOptions options = Check.NotNull(options); + ShimDurableEntityClient? entities; /// /// Initializes a new instance of the class. @@ -31,15 +39,29 @@ public ShimDurableTaskClient( { } - /// - /// Initializes a new instance of the class. - /// - /// The name of the client. - /// The client options. - public ShimDurableTaskClient(string name, ShimDurableTaskClientOptions options) - : base(name) + /// + public override DurableEntityClient Entities { - this.options = Check.NotNull(options); + get + { + if (!this.options.EnableEntitySupport) + { + throw new InvalidOperationException("Entity support is not enabled."); + } + + if (this.entities is null) + { + if (this.options.Entities.Queries is null) + { + throw new NotSupportedException( + "The configured IOrchestrationServiceClient does not support entities."); + } + + this.entities = new(this.Name, this.options); + } + + return this.entities; + } } DataConverter DataConverter => this.options.DataConverter; diff --git a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClientOptions.cs b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClientOptions.cs index 361694b3..8d535483 100644 --- a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClientOptions.cs +++ b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClientOptions.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using DurableTask.Core; +using DurableTask.Core.Entities; +using Microsoft.DurableTask.Client.Entities; namespace Microsoft.DurableTask.Client.OrchestrationServiceClientShim; @@ -15,4 +17,33 @@ public sealed class ShimDurableTaskClientOptions : DurableTaskClientOptions /// If not manually set, this will be resolved from the , if available. /// public IOrchestrationServiceClient? Client { get; set; } + + /// + /// Gets the to configure entity support. + /// + public ShimDurableTaskEntityOptions Entities { get; } = new(); +} + +/// +/// Options for entities. +/// +public class ShimDurableTaskEntityOptions +{ + /// + /// Gets or sets the to use in the . + /// If not set manually, this will attempt to be resolved automatically by looking for + /// in the . + /// + public EntityBackendQueries? Queries { get; set; } + + /// + /// Gets or sets the maximum time span to use for signal delay. If not set manually, will attempt to be resolved + /// through the service container. This will finally default to 3 days if it cannot be any other means. + /// + public TimeSpan? MaxSignalDelayTime { get; set; } + + /// + /// Gets the max signal delay time. + /// + internal TimeSpan MaxSignalDelayTimeOrDefault => this.MaxSignalDelayTime ?? TimeSpan.FromDays(3); } diff --git a/src/Client/OrchestrationServiceClientShim/ShimExtensions.cs b/src/Client/OrchestrationServiceClientShim/ShimExtensions.cs index 5ab3b96e..50191e37 100644 --- a/src/Client/OrchestrationServiceClientShim/ShimExtensions.cs +++ b/src/Client/OrchestrationServiceClientShim/ShimExtensions.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using DurableTask.Core.Entities; +using Microsoft.DurableTask.Entities; using Core = DurableTask.Core; namespace Microsoft.DurableTask.Client; @@ -109,4 +111,20 @@ public static Core.OrchestrationStatus ConvertToCore(this OrchestrationRuntimeSt return new Core.PurgeInstanceFilter( (filter.CreatedFrom ?? default).UtcDateTime, filter.CreatedTo?.UtcDateTime, statuses); } + + /// + /// Convert to . + /// + /// The entity ID to convert. + /// The converted entity instance ID. + public static EntityInstanceId ConvertFromCore(this EntityId entityId) + => new(entityId.Name, entityId.Key); + + /// + /// Convert to . + /// + /// The entity instance ID to convert. + /// The converted entity ID. + public static EntityId ConvertToCore(this EntityInstanceId entityId) + => new(entityId.Name, entityId.Key); } diff --git a/test/Client/OrchestrationServiceClientShim.Tests/DependencyInjection/DurableTaskClientBuilderExtensionsTests.cs b/test/Client/OrchestrationServiceClientShim.Tests/DependencyInjection/DurableTaskClientBuilderExtensionsTests.cs index ccbaeaba..37db1647 100644 --- a/test/Client/OrchestrationServiceClientShim.Tests/DependencyInjection/DurableTaskClientBuilderExtensionsTests.cs +++ b/test/Client/OrchestrationServiceClientShim.Tests/DependencyInjection/DurableTaskClientBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using DurableTask.Core; +using DurableTask.Core.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -85,4 +86,139 @@ public void UseOrchestrationService_Callback_Sets() options.Client.Should().Be(client); } + + [Fact] + public void EnableEntities_NoBackendSupport_Throws() + { + ServiceCollection services = new(); + IOrchestrationServiceClient client = Mock.Of(); + services.AddSingleton(client); + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => o.EnableEntitySupport = true); + + IServiceProvider provider = services.BuildServiceProvider(); + Action act = () => provider.GetOptions(); + act.Should().ThrowExactly() + .WithMessage("ShimDurableTaskClientOptions.Entities.Queries must not be null when entity support is enabled."); + } + + [Fact] + public void EnableEntities_BackendSupport_ExplicitProvider() + { + ServiceCollection services = new(); + IOrchestrationServiceClient client = Mock.Of(); + services.AddSingleton(client); + Mock mock = new(); + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => + { + o.EnableEntitySupport = true; + o.Entities.Queries = mock.Object; + }); + + IServiceProvider provider = services.BuildServiceProvider(); + provider.GetOptions(); // no-throw + } + + [Fact] + public void EnableEntities_BackendSupport_RegisteredService1() + { + ServiceCollection services = new(); + IOrchestrationServiceClient client = Mock.Of(); + services.AddSingleton(client); + EntityBackendQueries queries = Mock.Of(); + services.AddSingleton(queries); + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => o.EnableEntitySupport = true ); + + IServiceProvider provider = services.BuildServiceProvider(); + ShimDurableTaskClientOptions options = provider.GetOptions(); + options.Entities.Queries.Should().Be(queries); + } + + [Fact] + public void EnableEntities_BackendSupport_RegisteredService2() + { + ServiceCollection services = new(); + Mock client = new(); + EntityBackendQueries queries = Mock.Of(); + Mock entities = client.As(); + entities.Setup(m => m.EntityBackendQueries).Returns(queries); + services.AddSingleton(client.Object); + + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => o.EnableEntitySupport = true); + + IServiceProvider provider = services.BuildServiceProvider(); + ShimDurableTaskClientOptions options = provider.GetOptions(); + options.Entities.Queries.Should().Be(queries); + } + + [Fact] + public void EnableEntities_BackendSupport_RegisteredService3() + { + ServiceCollection services = new(); + Mock client = new(); + EntityBackendQueries queries = Mock.Of(); + Mock entities = client.As(); + entities.Setup(m => m.EntityBackendQueries).Returns(queries); + + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => + { + o.Client = client.Object; + o.EnableEntitySupport = true; + }); + + IServiceProvider provider = services.BuildServiceProvider(); + ShimDurableTaskClientOptions options = provider.GetOptions(); + options.Entities.Queries.Should().Be(queries); + } + + [Fact] + public void EnableEntities_BackendSupport_RegisteredService4() + { + ServiceCollection services = new(); + IOrchestrationServiceClient client = Mock.Of(); + services.AddSingleton(client); + + EntityBackendQueries queries = Mock.Of(); + IEntityOrchestrationService entities = Mock.Of(m => m.EntityBackendQueries == queries); + services.AddSingleton(entities); + + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => o.EnableEntitySupport = true); + + IServiceProvider provider = services.BuildServiceProvider(); + ShimDurableTaskClientOptions options = provider.GetOptions(); + options.Entities.Queries.Should().Be(queries); + } + + [Fact] + public void EnableEntities_BackendSupport_RegisteredService5() + { + ServiceCollection services = new(); + IOrchestrationServiceClient client = Mock.Of(); + services.AddSingleton(client); + + EntityBackendQueries queries = Mock.Of(); + Mock orchestration = new(); + Mock entities = orchestration.As(); + entities.Setup(m => m.EntityBackendQueries).Returns(queries); + services.AddSingleton(orchestration.Object); + + DefaultDurableTaskClientBuilder builder = new(null, services); + + builder.UseOrchestrationService(o => o.EnableEntitySupport = true); + + IServiceProvider provider = services.BuildServiceProvider(); + ShimDurableTaskClientOptions options = provider.GetOptions(); + options.Entities.Queries.Should().Be(queries); + } } diff --git a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableEntityClientTests.cs b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableEntityClientTests.cs new file mode 100644 index 00000000..b3c4ac06 --- /dev/null +++ b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableEntityClientTests.cs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq.Expressions; +using DotNext; +using DurableTask.Core; +using DurableTask.Core.Entities; +using DurableTask.Core.History; +using FluentAssertions.Execution; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Converters; +using Microsoft.DurableTask.Entities; + +namespace Microsoft.DurableTask.Client.OrchestrationServiceClientShim.Tests; + +public class ShimDurableEntityClientTests +{ + static readonly DataConverter Converter = new JsonDataConverter(); + readonly Mock query = new(MockBehavior.Strict); + readonly Mock client = new(MockBehavior.Strict); + + [Fact] + public void Ctor_NullOptions_Throws() + { + Func act = static () => new ShimDurableEntityClient("test", null!); + act.Should().Throw().WithParameterName("options"); + } + + [Fact] + public async Task CleanEntityStorageAsync_NullRequest_ReturnsExpectedResult() + { + EntityBackendQueries.CleanEntityStorageResult result = new() + { + EmptyEntitiesRemoved = Random.Shared.Next(0, 100), + OrphanedLocksReleased = Random.Shared.Next(0, 100), + ContinuationToken = Guid.NewGuid().ToString(), + }; + + Expression> verifyRequest = r => + r.RemoveEmptyEntities == true + && r.ReleaseOrphanedLocks == true + && r.ContinuationToken == null; + this.query.Setup(x => x.CleanEntityStorageAsync(It.Is(verifyRequest), default)).ReturnsAsync(result); + + ShimDurableEntityClient client = this.CreateEntityClient(); + CleanEntityStorageResult actual = await client.CleanEntityStorageAsync(); + + actual.Should().BeEquivalentTo(new CleanEntityStorageResult + { + EmptyEntitiesRemoved = result.EmptyEntitiesRemoved, + OrphanedLocksReleased = result.OrphanedLocksReleased, + ContinuationToken = result.ContinuationToken, + }); + } + + [Theory, CombinatorialData] + public async Task CleanEntityStorageAsync_SuppliedRequest_ReturnsExpectedResult( + bool removeEmptyEntities, bool releaseOrphanedLocks, bool continuationToken) + { + CleanEntityStorageRequest request = new() + { + RemoveEmptyEntities = removeEmptyEntities, + ReleaseOrphanedLocks = releaseOrphanedLocks, + ContinuationToken = continuationToken ? Guid.NewGuid().ToString() : null, + }; + + EntityBackendQueries.CleanEntityStorageResult result = new() + { + EmptyEntitiesRemoved = Random.Shared.Next(0, 100), + OrphanedLocksReleased = Random.Shared.Next(0, 100), + ContinuationToken = Guid.NewGuid().ToString(), + }; + + Expression> verifyRequest = r => + r.RemoveEmptyEntities == request.RemoveEmptyEntities + && r.ReleaseOrphanedLocks == request.ReleaseOrphanedLocks + && r.ContinuationToken == request.ContinuationToken; + this.query.Setup(x => x.CleanEntityStorageAsync(It.Is(verifyRequest), default)).ReturnsAsync(result); + + ShimDurableEntityClient client = this.CreateEntityClient(); + CleanEntityStorageResult actual = await client.CleanEntityStorageAsync(request); + + actual.Should().BeEquivalentTo(new CleanEntityStorageResult + { + EmptyEntitiesRemoved = result.EmptyEntitiesRemoved, + OrphanedLocksReleased = result.OrphanedLocksReleased, + ContinuationToken = result.ContinuationToken, + }); + } + + [Fact] + public async Task GetAllEntitiesAsync_NoFilter_ReturnsExpectedResult() + { + List entities = [.. + Enumerable.Range(0, 25).Select(i => CreateCoreMetadata(i))]; + + string? continuationToken = null; + foreach (IEnumerable batch in entities.Batch(10)) + { + EntityBackendQueries.EntityQuery filter = new() + { + PageSize = null, + IncludeState = true, + IncludeTransient = false, + InstanceIdStartsWith = string.Empty, + LastModifiedFrom = null, + LastModifiedTo = null, + ContinuationToken = continuationToken, + }; + + List values = [.. batch]; + continuationToken = values.Count == 10 ? Guid.NewGuid().ToString() : null; + EntityBackendQueries.EntityQueryResult result = new() + { + Results = values, + ContinuationToken = continuationToken, + }; + + this.query.Setup(x => x.QueryEntitiesAsync(filter, default)).ReturnsAsync(result); + } + + ShimDurableEntityClient client = this.CreateEntityClient(); + List actualEntities = await client.GetAllEntitiesAsync().ToListAsync(); + + using AssertionScope scope = new(); + actualEntities.Should().HaveCount(entities.Count); + + foreach ((EntityMetadata actual, EntityBackendQueries.EntityMetadata expected) in actualEntities.Zip(entities)) + { + VerifyEntity(actual, expected); + } + } + + [Fact] + public async Task GetAllEntitiesAsync_WithFilter_ReturnsExpectedResult() + { + List entities = [.. + Enumerable.Range(0, 25).Select(i => CreateCoreMetadata(i))]; + + string? continuationToken = Guid.NewGuid().ToString(); + EntityQuery query = new() { IncludeState = false, PageSize = 10, ContinuationToken = continuationToken }; + foreach (IEnumerable batch in entities.Batch(10)) + { + EntityBackendQueries.EntityQuery filter = new() + { + PageSize = 10, + IncludeState = false, + IncludeTransient = false, + InstanceIdStartsWith = string.Empty, + LastModifiedFrom = null, + LastModifiedTo = null, + ContinuationToken = continuationToken, + }; + + List values = [.. batch]; + continuationToken = values.Count == 10 ? Guid.NewGuid().ToString() : null; + EntityBackendQueries.EntityQueryResult result = new() + { + Results = values, + ContinuationToken = continuationToken, + }; + + this.query.Setup(x => x.QueryEntitiesAsync(filter, default)).ReturnsAsync(result); + } + + ShimDurableEntityClient client = this.CreateEntityClient(); + List actualEntities = await client.GetAllEntitiesAsync(query).ToListAsync(); + + using AssertionScope scope = new(); + actualEntities.Should().HaveCount(entities.Count); + + foreach ((EntityMetadata actual, EntityBackendQueries.EntityMetadata expected) in actualEntities.Zip(entities)) + { + VerifyEntity(actual, expected); + } + } + + [Fact] + public async Task GetAllEntitiesAsyncOfT_NoFilter_ReturnsExpectedResult() + { + List entities = [.. + Enumerable.Range(0, 25).Select(i => CreateCoreMetadata(i, $"state-{i}"))]; + + string? continuationToken = null; + foreach (IEnumerable batch in entities.Batch(10)) + { + EntityBackendQueries.EntityQuery filter = new() + { + PageSize = null, + IncludeState = true, + IncludeTransient = false, + InstanceIdStartsWith = string.Empty, + LastModifiedFrom = null, + LastModifiedTo = null, + ContinuationToken = continuationToken, + }; + + List values = [.. batch]; + continuationToken = values.Count == 10 ? Guid.NewGuid().ToString() : null; + EntityBackendQueries.EntityQueryResult result = new() + { + Results = values, + ContinuationToken = continuationToken, + }; + + this.query.Setup(x => x.QueryEntitiesAsync(filter, default)).ReturnsAsync(result); + } + + ShimDurableEntityClient client = this.CreateEntityClient(); + List> actualEntities = await client.GetAllEntitiesAsync().ToListAsync(); + + using AssertionScope scope = new(); + actualEntities.Should().HaveCount(entities.Count); + + foreach ((EntityMetadata actual, EntityBackendQueries.EntityMetadata expected) in actualEntities.Zip(entities)) + { + VerifyEntity(actual, expected, $"state-{actual.Id.Key}"); + } + } + + [Fact] + public async Task GetAllEntitiesAsyncOfT_WithFilter_ReturnsExpectedResult() + { + List entities = [.. + Enumerable.Range(0, 25).Select(i => CreateCoreMetadata(i, $"state-{i}"))]; + + string? continuationToken = Guid.NewGuid().ToString(); + EntityQuery query = new() { IncludeState = true, PageSize = 10, ContinuationToken = continuationToken }; + foreach (IEnumerable batch in entities.Batch(10)) + { + EntityBackendQueries.EntityQuery filter = new() + { + PageSize = 10, + IncludeState = true, + IncludeTransient = false, + InstanceIdStartsWith = string.Empty, + LastModifiedFrom = null, + LastModifiedTo = null, + ContinuationToken = continuationToken, + }; + + List values = [.. batch]; + continuationToken = values.Count == 10 ? Guid.NewGuid().ToString() : null; + EntityBackendQueries.EntityQueryResult result = new() + { + Results = values, + ContinuationToken = continuationToken, + }; + + this.query.Setup(x => x.QueryEntitiesAsync(filter, default)).ReturnsAsync(result); + } + + ShimDurableEntityClient client = this.CreateEntityClient(); + List> actualEntities = await client.GetAllEntitiesAsync(query).ToListAsync(); + + using AssertionScope scope = new(); + actualEntities.Should().HaveCount(entities.Count); + + foreach ((EntityMetadata actual, EntityBackendQueries.EntityMetadata expected) in actualEntities.Zip(entities)) + { + VerifyEntity(actual, expected, $"state-{actual.Id.Key}"); + } + } + + [Theory, CombinatorialData] + public async Task GetEntityAsync_Success(bool includeState) + { + EntityBackendQueries.EntityMetadata expected = CreateCoreMetadata(0, includeState ? "state" : null); + this.query.Setup(x => x.GetEntityAsync(expected.EntityId, includeState, false, default)).ReturnsAsync(expected); + + ShimDurableEntityClient client = this.CreateEntityClient(); + EntityMetadata? entity = await client.GetEntityAsync(expected.EntityId.ConvertFromCore(), includeState); + + entity.Should().NotBeNull(); + VerifyEntity(entity!, expected); + entity!.IncludesState.Should().Be(includeState); + + if (includeState) + { + entity!.State.Value.Should().Be("\"state\""); + } + } + + [Theory, CombinatorialData] + public async Task GetEntityAsyncOfT_Success(bool includeState) + { + EntityBackendQueries.EntityMetadata expected = CreateCoreMetadata(0, includeState ? "state" : null); + this.query.Setup(x => x.GetEntityAsync(expected.EntityId, includeState, false, default)).ReturnsAsync(expected); + + ShimDurableEntityClient client = this.CreateEntityClient(); + EntityMetadata? entity = await client.GetEntityAsync( + expected.EntityId.ConvertFromCore(), includeState); + + entity.Should().NotBeNull(); + VerifyEntity(entity!, expected); + entity!.IncludesState.Should().Be(includeState); + + if (includeState) + { + entity!.State.Should().Be("state"); + } + } + + [Fact] + public async Task SignalEntityAsync_Success() + { + EntityInstanceId id = new("test", "0"); + string operationName = "op"; + object input = new { Value = 42 }; + + Func isExpectedMessage = m => + m.OrchestrationInstance.InstanceId == id.ToString() + && m.Event is EventRaisedEvent e + && e.Name == operationName + && !string.IsNullOrEmpty(e.Input); + this.client.Setup(x => x.SendTaskOrchestrationMessageAsync(It.Is(m => isExpectedMessage(m)))) + .Returns(Task.CompletedTask); + + ShimDurableEntityClient client = this.CreateEntityClient(); + await client.SignalEntityAsync(id, operationName, input); + + this.client.Verify(x => x.SendTaskOrchestrationMessageAsync(It.IsNotNull()), Times.Once()); + } + + static EntityBackendQueries.EntityMetadata CreateCoreMetadata(int i, object? state = null) + { + return new() + { + EntityId = new("test", i.ToString()), + BacklogQueueSize = Random.Shared.Next(0, 10), + LastModifiedTime = Random.Shared.NextDateTime(TimeSpan.FromDays(-10)), + LockedBy = Random.Shared.NextBoolean() ? Guid.NewGuid().ToString() : null, + SerializedState = state is null ? null : Converter.Serialize(state), + }; + } + + static void VerifyEntity( + EntityMetadata actual, EntityBackendQueries.EntityMetadata expected) + { + actual.Id.Should().Be(expected.EntityId.ConvertFromCore()); + actual.BacklogQueueSize.Should().Be(expected.BacklogQueueSize); + actual.LastModifiedTime.Should().Be(new DateTimeOffset(expected.LastModifiedTime)); + actual.LockedBy.Should().Be(expected.LockedBy); + } + + static void VerifyEntity( + EntityMetadata actual, EntityBackendQueries.EntityMetadata expected, TState? state) + { + VerifyEntity(actual, expected); + actual.State.Should().Be(state); + } + + ShimDurableEntityClient CreateEntityClient() + { + ShimDurableTaskClientOptions options = new() + { + DataConverter = Converter, + Client = this.client.Object, + Entities = { Queries = this.query.Object, }, + }; + + return new ShimDurableEntityClient("test", options); + } +} diff --git a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs index 3f1d5d2e..e8db1b57 100644 --- a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs +++ b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs @@ -2,8 +2,11 @@ // Licensed under the MIT License. using DurableTask.Core; +using DurableTask.Core.Entities; using DurableTask.Core.History; using DurableTask.Core.Query; +using FluentAssertions.Specialized; +using Microsoft.DurableTask.Client.Entities; using Microsoft.DurableTask.Converters; using Microsoft.Extensions.Options; using Core = DurableTask.Core; @@ -47,6 +50,47 @@ public void Ctor_NullOptions_Throws2() act.Should().ThrowExactly().WithParameterName("options"); } + [Fact] + public void Ctor_NoEntitySupport_GetClientThrows() + { + IOrchestrationServiceClient client = Mock.Of(); + ShimDurableTaskClientOptions options = new() { Client = client }; + ShimDurableTaskClient shimClient = new("test", options); + + Func act = () => shimClient.Entities; + act.Should().ThrowExactly().WithMessage("Entity support is not enabled."); + } + + [Fact] + public void Ctor_InvalidEntityOptions_GetClientThrows() + { + IOrchestrationServiceClient client = Mock.Of(); + ShimDurableTaskClientOptions options = new() { Client = client, EnableEntitySupport = true }; + ShimDurableTaskClient shimClient = new("test", options); + + Func act = () => shimClient.Entities; + act.Should().ThrowExactly() + .WithMessage("The configured IOrchestrationServiceClient does not support entities."); + } + + [Fact] + public void Ctor_EntitiesConfigured_GetClientSuccess() + { + IOrchestrationServiceClient client = Mock.Of(); + EntityBackendQueries queries = Mock.Of(); + ShimDurableTaskClientOptions options = new() + { + Client = client, + EnableEntitySupport = true, + Entities = { Queries = queries }, + }; + + ShimDurableTaskClient shimClient = new("test", options); + DurableEntityClient entityClient = shimClient.Entities; + + entityClient.Should().BeOfType(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -88,11 +132,11 @@ public async Task GetInstances_Results(bool getInputs) { // arrange DateTimeOffset utcNow = DateTimeOffset.UtcNow; - List states = new() - { + List states = + [ CreateState("input", start: utcNow.AddMinutes(-1)), CreateState(10, "output", utcNow.AddMinutes(-5)), - }; + ]; OrchestrationQueryResult queryResult = new(states, null); string instanceId = states.First().OrchestrationInstance.InstanceId; @@ -240,8 +284,8 @@ public async Task WaitForInstanceStart() Core.OrchestrationState state2 = CreateState("input", start: start); state1.OrchestrationInstance = instance; this.orchestrationClient.SetupSequence(m => m.GetOrchestrationStateAsync(instance.InstanceId, false)) - .ReturnsAsync(new[] { state1 }) - .ReturnsAsync(new[] { state2 }); + .ReturnsAsync([state1]) + .ReturnsAsync([state2]); // act OrchestrationMetadata metadata = await this.client.WaitForInstanceStartAsync( diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets index c0558551..1bd854ed 100644 --- a/test/Directory.Build.targets +++ b/test/Directory.Build.targets @@ -14,7 +14,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/TestHelpers/EnumerableExtensions.cs b/test/TestHelpers/EnumerableExtensions.cs new file mode 100644 index 00000000..758b2eeb --- /dev/null +++ b/test/TestHelpers/EnumerableExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +public static class EnumerableExtensions +{ + public static IEnumerable> Batch(this IEnumerable source, int batchSize) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (batchSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be greater than zero."); + } + + static IEnumerable YieldBatchElements(IEnumerator enumerator, int batchSize) + { + int i = 0; + do + { + yield return enumerator.Current; + } + while (++i < batchSize && enumerator.MoveNext()); + } + + using IEnumerator enumerator = source.GetEnumerator(); + while (enumerator.MoveNext()) + { + yield return YieldBatchElements(enumerator, batchSize); + } + } +} diff --git a/test/TestHelpers/RandomExtensions.cs b/test/TestHelpers/RandomExtensions.cs new file mode 100644 index 00000000..38cdecb7 --- /dev/null +++ b/test/TestHelpers/RandomExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +public static class RandomExtensions +{ +#if NET6_0_OR_GREATER + public static DateTimeOffset NextDateTimeOffset(this Random random, DateTimeOffset min, TimeSpan max) + { + ArgumentNullException.ThrowIfNull(random); + bool negative = max.Ticks < 0; + TimeSpan range = TimeSpan.FromTicks(Random.Shared.NextInt64(0, Math.Abs(max.Ticks))); + return negative ? min - range : min + range; + } + + public static DateTimeOffset NextDateTimeOffset(this Random random, TimeSpan max) + => random.NextDateTimeOffset(DateTimeOffset.UtcNow, max); + + public static DateTime NextDateTime(this Random random, DateTime min, TimeSpan max) + { + ArgumentNullException.ThrowIfNull(random); + bool negative = max.Ticks < 0; + TimeSpan range = TimeSpan.FromTicks(Random.Shared.NextInt64(0, Math.Abs(max.Ticks))); + return negative ? min - range : min + range; + } + + public static DateTime NextDateTime(this Random random, TimeSpan max) + => random.NextDateTime(DateTime.UtcNow, max); +#endif +} \ No newline at end of file diff --git a/test/TestHelpers/TestHelpers.csproj b/test/TestHelpers/TestHelpers.csproj index abf0b875..6ad0bad4 100644 --- a/test/TestHelpers/TestHelpers.csproj +++ b/test/TestHelpers/TestHelpers.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + net6.0;netstandard2.0