diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs b/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs index 1726a89f84..bc95a16167 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -73,7 +73,7 @@ public class SimpleStateCheckerManager : ISimpleStateCheckerManager : ISimpleStateCheckerManager(scope.ServiceProvider.GetRequiredService(), state); + return await EvaluateCheckersAsync(state, useBatchChecker, scope); + } + } + + protected virtual async Task EvaluateCheckersAsync(TState state, bool useBatchChecker, IServiceScope scope) + { + var context = new SimpleStateCheckerContext( + !useBatchChecker + ? scope.ServiceProvider.GetRequiredService() + : scope.ServiceProvider.GetRequiredService(), + state); - foreach (var provider in state.StateCheckers.WhereIf(!useBatchChecker, x => x is not ISimpleBatchStateChecker)) + foreach (var provider in state.StateCheckers.WhereIf(!useBatchChecker, x => x is not ISimpleBatchStateChecker)) + { + if (!await provider.IsEnabledAsync(context)) { - if (!await provider.IsEnabledAsync(context)) - { - return false; - } + return false; } + } - foreach (ISimpleStateChecker provider in Options.GlobalStateCheckers - .WhereIf(!useBatchChecker, x => !typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)) - .Select(x => ServiceProvider.GetRequiredService(x))) + foreach (ISimpleStateChecker provider in Options.GlobalStateCheckers + .WhereIf(!useBatchChecker, x => !typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)) + .Select(x => ServiceProvider.GetRequiredService(x))) + { + if (!await provider.IsEnabledAsync(context)) { - if (!await provider.IsEnabledAsync(context)) - { - return false; - } + return false; } - - return true; } + + return true; } } diff --git a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs new file mode 100644 index 0000000000..1edf00230d --- /dev/null +++ b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs @@ -0,0 +1,111 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.SimpleStateChecking; + +/// +/// Tests that batch IsEnabledAsync evaluates non-batch state checkers correctly +/// when reusing a single DI scope (instead of creating N scopes via InternalIsEnabledAsync). +/// +public class SimpleStateChecker_BatchSingleScope_Tests : SimpleStateCheckerTestBase +{ + [Fact] + public async Task Batch_Should_Evaluate_NonBatch_Checkers_Correctly() + { + var enabled = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + enabled.AddSimpleStateChecker(new MySimpleStateChecker()); + + var disabled = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + disabled.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { enabled, disabled }); + + result[enabled].ShouldBeTrue(); + result[disabled].ShouldBeFalse(); + enabled.CheckCount.ShouldBe(1); + disabled.CheckCount.ShouldBe(1); + } + + [Fact] + public async Task Batch_Should_Skip_NonBatch_Check_When_BatchChecker_Already_Disabled() + { + // Entity disabled by batch checker should not have non-batch checker invoked + var entity = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) // fails batch checker + }; + entity.AddSimpleStateChecker(new MySimpleBatchStateChecker()); + entity.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { entity }); + + result[entity].ShouldBeFalse(); + entity.MultipleCheckCount.ShouldBe(1); // batch checker was called + entity.CheckCount.ShouldBe(0); // non-batch checker was NOT called (skipped because batch disabled it) + } + + [Fact] + public async Task Batch_Should_Handle_Mix_Of_Entities_With_And_Without_Checkers() + { + var noChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + + var withChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + withChecker.AddSimpleStateChecker(new MySimpleStateChecker()); + + var failingChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + failingChecker.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync( + new[] { noChecker, withChecker, failingChecker }); + + result[noChecker].ShouldBeTrue(); + result[withChecker].ShouldBeTrue(); + result[failingChecker].ShouldBeFalse(); + + noChecker.CheckCount.ShouldBe(0); + withChecker.CheckCount.ShouldBe(1); + failingChecker.CheckCount.ShouldBe(1); + } + + [Fact] + public async Task Batch_Should_Handle_Large_Number_Of_Entities() + { + var entities = new MyStateEntity[1000]; + for (int i = 0; i < 1000; i++) + { + entities[i] = new MyStateEntity + { + CreationTime = i % 2 == 0 + ? DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + : DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + entities[i].AddSimpleStateChecker(new MySimpleStateChecker()); + } + + var result = await SimpleStateCheckerManager.IsEnabledAsync(entities); + + for (int i = 0; i < 1000; i++) + { + result[entities[i]].ShouldBe(i % 2 == 0); + entities[i].CheckCount.ShouldBe(1); + } + } +} diff --git a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs new file mode 100644 index 0000000000..0606dec6e8 --- /dev/null +++ b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.SimpleStateChecking; + +/// +/// Probe-based tests that verify: +/// 1. Batch path shares a single DI scope but isolates CachedServiceProvider per state. +/// 2. Single-state path uses separate scopes (original behavior). +/// 3. Nested single-state calls from within batch checkers get their own scope. +/// +public class SimpleStateChecker_ScopeIsolation_Tests : SimpleStateCheckerTestBase +{ + protected override void AfterAddApplication(IServiceCollection services) + { + services.AddScoped(); + services.AddTransient(); + base.AfterAddApplication(services); + } + + [Fact] + public async Task Batch_Should_Share_Scope_But_Isolate_CachedProvider_Across_States() + { + var observations = new ConcurrentDictionary(); + var checker = new ScopeProbeStateChecker(observations); + + var stateA = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateA.AddSimpleStateChecker(checker); + + var stateB = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateB.AddSimpleStateChecker(checker); + + var stateC = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateC.AddSimpleStateChecker(checker); + + await SimpleStateCheckerManager.IsEnabledAsync(new[] { stateA, stateB, stateC }); + + observations.Count.ShouldBe(3); + + // All states in the batch should see the same scoped service (shared DI scope) + observations[stateA].ScopeId.ShouldBe(observations[stateB].ScopeId); + observations[stateB].ScopeId.ShouldBe(observations[stateC].ScopeId); + + // Each state should get its own transient instance (isolated cached provider) + observations[stateA].TransientId.ShouldNotBe(observations[stateB].TransientId); + observations[stateB].TransientId.ShouldNotBe(observations[stateC].TransientId); + } + + [Fact] + public async Task Single_State_Calls_Should_Use_Separate_Scopes() + { + var observations = new ConcurrentDictionary(); + var checker = new ScopeProbeStateChecker(observations); + + var stateA = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateA.AddSimpleStateChecker(checker); + + var stateB = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateB.AddSimpleStateChecker(checker); + + await SimpleStateCheckerManager.IsEnabledAsync(stateA); + await SimpleStateCheckerManager.IsEnabledAsync(stateB); + + observations.Count.ShouldBe(2); + + // Single-state calls should each get their own scope + observations[stateA].ScopeId.ShouldNotBe(observations[stateB].ScopeId); + } + + [Fact] + public async Task Nested_Single_State_Call_From_Batch_Checker_Should_Get_Own_Scope() + { + var observations = new ConcurrentDictionary(); + + // This state will be evaluated via a nested single-state call during batch evaluation + var nestedState = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + nestedState.AddSimpleStateChecker(new ScopeProbeStateChecker(observations)); + + // This checker triggers a nested IsEnabledAsync(state) during batch evaluation + var nestedCallChecker = new NestedSingleCallChecker( + SimpleStateCheckerManager, nestedState, observations); + + var batchState = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + batchState.AddSimpleStateChecker(nestedCallChecker); + + await SimpleStateCheckerManager.IsEnabledAsync(new[] { batchState }); + + observations.Count.ShouldBe(2); + + // The nested single-state call should get its own scope, not the batch scope + observations[batchState].ScopeId.ShouldNotBe(observations[nestedState].ScopeId); + } + + public record Observation(Guid ScopeId, Guid TransientId); + + public class ScopeIdProbe + { + public Guid Id { get; } = Guid.NewGuid(); + } + + public class TransientIdProbe + { + public Guid Id { get; } = Guid.NewGuid(); + } + + public class ScopeProbeStateChecker : ISimpleStateChecker + { + private readonly ConcurrentDictionary _observations; + + public ScopeProbeStateChecker( + ConcurrentDictionary observations) + { + _observations = observations; + } + + public Task IsEnabledAsync(SimpleStateCheckerContext context) + { + var scopeProbe = context.ServiceProvider.GetRequiredService(); + var transientProbe = context.ServiceProvider.GetRequiredService(); + _observations[context.State] = new Observation(scopeProbe.Id, transientProbe.Id); + return Task.FromResult(true); + } + } + + public class NestedSingleCallChecker : ISimpleStateChecker + { + private readonly ISimpleStateCheckerManager _manager; + private readonly MyStateEntity _nestedState; + private readonly ConcurrentDictionary _observations; + + public NestedSingleCallChecker( + ISimpleStateCheckerManager manager, + MyStateEntity nestedState, + ConcurrentDictionary observations) + { + _manager = manager; + _nestedState = nestedState; + _observations = observations; + } + + public async Task IsEnabledAsync(SimpleStateCheckerContext context) + { + var scopeProbe = context.ServiceProvider.GetRequiredService(); + var transientProbe = context.ServiceProvider.GetRequiredService(); + _observations[context.State] = new Observation(scopeProbe.Id, transientProbe.Id); + + // Trigger a nested single-state call during batch evaluation + await _manager.IsEnabledAsync(_nestedState); + + return true; + } + } +}