diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8a32cd..6f61ee9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,7 +105,9 @@ jobs: - name: Test with coverage # Skip Integration (needs Mongo/Redis) and TimeCritical (wall-clock asserts) tests on shared CI runners. - run: dotnet test -c Release --no-build --verbosity normal --filter "(Category!=Integration)&(Category!=TimeCritical)" --collect:"XPlat Code Coverage" --results-directory ./coverage + # Microsoft.Testing.Platform (see global.json) — xunit.v3 4.x dropped the VSTest bridge, so the + # VSTest --filter expression and --collect:"XPlat Code Coverage" no longer apply here. + run: dotnet test -c Release --no-build --filter-not-trait "Category=Integration" --filter-not-trait "Category=TimeCritical" --coverage --coverage-output-format cobertura --results-directory ./coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.gitignore b/.gitignore index 82e7f0e..7cbae9a 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,9 @@ coverage*.json coverage*.xml coverage*.info +# Microsoft.Testing.Platform coverage output directory (--results-directory ./coverage) +coverage/ + # Visual Studio code coverage results *.coverage *.coveragexml diff --git a/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj b/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj index 32e14f6..b99bdf0 100644 --- a/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj +++ b/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj @@ -37,7 +37,7 @@ - + diff --git a/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj b/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj index 652a6b7..edf4b36 100644 --- a/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj +++ b/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj @@ -2,27 +2,16 @@ net10.0 + Exe enable - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj b/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj index bbb27c9..878e8dd 100644 --- a/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj +++ b/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj @@ -2,27 +2,16 @@ net10.0 + Exe enable - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj b/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj index 37fbf75..1c14811 100644 --- a/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj +++ b/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + Exe enable $(NoWarn);xUnit1051 @@ -10,21 +11,9 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Tharga.Cache.Tests/AddCacheConcurrencyTests.cs b/Tharga.Cache.Tests/AddCacheConcurrencyTests.cs new file mode 100644 index 0000000..617626c --- /dev/null +++ b/Tharga.Cache.Tests/AddCacheConcurrencyTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tharga.Cache.Persist; +using Xunit; + +namespace Tharga.Cache.Tests; + +public class AddCacheConcurrencyTests +{ + private const int HostCount = 64; + + private sealed record Marker; + + // Nesting a generic in itself yields as many distinct cache types as needed without + // declaring one class per host. + private static Type MarkerType(int depth) + { + var type = typeof(object); + for (var i = 0; i < depth; i++) + { + type = typeof(Marker<>).MakeGenericType(type); + } + + return type; + } + + private static void RegisterMarker(CacheOptions options, int depth) + { + typeof(CacheOptions) + .GetMethod(nameof(CacheOptions.RegisterType))! + .MakeGenericMethod(MarkerType(depth), typeof(IMemory)) + .Invoke(options, [null]); + } + + private static IServiceCollection BuildHost(int depth) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCache(o => RegisterMarker(o, depth)); + return services; + } + + private static IReadOnlyDictionary RegisteredTypes(IServiceCollection services) + { + var options = services + .Last(x => x.ServiceType == typeof(IOptions)) + .ImplementationInstance as IOptions; + + return options!.Value.GetRegistered(); + } + + [Fact] + public void AddCache_CalledConcurrentlyOnIndependentCollections_DoesNotThrow() + { + //Arrange + var depths = Enumerable.Range(1, HostCount).ToArray(); + + //Act + var act = () => Parallel.ForEach(depths, depth => BuildHost(depth)); + + //Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddCache_OnIndependentCollections_DoesNotShareRegistrations() + { + //Arrange + var first = BuildHost(1); + + //Act + var second = BuildHost(2); + + //Assert + RegisteredTypes(first).Keys.Should().BeEquivalentTo([MarkerType(1)]); + RegisteredTypes(second).Keys.Should().BeEquivalentTo([MarkerType(2)]); + } + + [Fact] + public void AddCache_CalledConcurrently_EachCollectionKeepsOnlyItsOwnType() + { + //Arrange + var depths = Enumerable.Range(1, HostCount).ToArray(); + var hosts = new IServiceCollection[HostCount]; + + //Act + Parallel.ForEach(depths, depth => hosts[depth - 1] = BuildHost(depth)); + + //Assert + foreach (var depth in depths) + { + RegisteredTypes(hosts[depth - 1]).Keys.Should().BeEquivalentTo([MarkerType(depth)]); + } + } +} diff --git a/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs b/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs index edd5df1..a4b80dc 100644 --- a/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs +++ b/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs @@ -1,22 +1,13 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Tharga.Cache.Persist; using Xunit; namespace Tharga.Cache.Tests; -public class AddCacheIdempotencyTests : IDisposable +public class AddCacheIdempotencyTests { - public AddCacheIdempotencyTests() - { - CacheRegistrationExtensions.ResetRegistrations(); - } - - public void Dispose() - { - CacheRegistrationExtensions.ResetRegistrations(); - } - [Fact] public void AddCache_CalledTwice_WithDifferentTypes_DoesNotThrow() { @@ -54,7 +45,7 @@ public void AddCache_CalledTwice_WithSameType_DoesNotThrow() } [Fact] - public void AddCache_CalledTwice_WithSameType_FirstRegistrationWins() + public void AddCache_CalledTwice_WithSameType_LatestRegistrationWins() { //Arrange var services = new ServiceCollection(); @@ -66,8 +57,8 @@ public void AddCache_CalledTwice_WithSameType_FirstRegistrationWins() //Assert var provider = services.BuildServiceProvider(); - var cache = provider.GetRequiredService(); - cache.Should().NotBeNull(); + var options = provider.GetRequiredService>().Value; + options.GetRegistered()[typeof(string)].DefaultFreshSpan.Should().Be(TimeSpan.FromMinutes(99)); } [Fact] diff --git a/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj b/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj index c244ecf..e3d1edf 100644 --- a/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj +++ b/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + Exe enable $(NoWarn);xUnit1051 @@ -10,21 +11,9 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Tharga.Cache/CacheRegistrationExtensions.cs b/Tharga.Cache/CacheRegistrationExtensions.cs index 97a242e..4bb62aa 100644 --- a/Tharga.Cache/CacheRegistrationExtensions.cs +++ b/Tharga.Cache/CacheRegistrationExtensions.cs @@ -10,13 +10,6 @@ namespace Tharga.Cache; public static class CacheRegistrationExtensions { - private static readonly Dictionary _configuredPersistTypes = new(); - - internal static void ResetRegistrations() - { - _configuredPersistTypes.Clear(); - } - public static void AddCache(this IServiceCollection serviceCollection, Action options = null) { var o = new CacheOptions @@ -25,7 +18,7 @@ public static void AddCache(this IServiceCollection serviceCollection, Action on each call so it carries the merged type registrations. serviceCollection.RemoveAll>(); @@ -104,17 +97,23 @@ public static void AddCache(this IServiceCollection serviceCollection, Action - /// If AddCache is called several times, this method merges all registrations so they can be used in the end. - /// First registration wins — duplicate types are silently skipped. + /// If AddCache is called several times on the same service collection, this method merges all registrations + /// so they can be used in the end. The type registered by this call wins — duplicates from earlier calls are + /// silently skipped. /// - private static void AppendPreviousRegistrations(CacheOptions o) + /// + /// The accumulated registrations are read back from the service collection rather than from process-wide + /// state, so hosts built concurrently in one process neither race nor inherit each other's registrations. + /// + private static void AppendPreviousRegistrations(IServiceCollection serviceCollection, CacheOptions o) { - var previouslyRegisteredTypes = _configuredPersistTypes.ToArray(); - foreach (var item in o.GetRegistered()) - { - _configuredPersistTypes.TryAdd(item.Key, item.Value); - } - foreach (var previouslyRegisteredType in previouslyRegisteredTypes) + var previous = serviceCollection + .LastOrDefault(x => x.ServiceType == typeof(IOptions))? + .ImplementationInstance as IOptions; + + if (previous == null) return; + + foreach (var previouslyRegisteredType in previous.Value.GetRegistered()) { o.TryAddType(previouslyRegisteredType.Key, previouslyRegisteredType.Value); } diff --git a/Tharga.Cache/Tharga.Cache.csproj b/Tharga.Cache/Tharga.Cache.csproj index cd368ee..23355f5 100644 --- a/Tharga.Cache/Tharga.Cache.csproj +++ b/Tharga.Cache/Tharga.Cache.csproj @@ -53,7 +53,6 @@ - all diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index 5696f34..14af694 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -16,6 +16,8 @@ builder.Services.AddCache(); `AddCache` is idempotent — calling it more than once (for example when several libraries each register cache types) merges the registrations instead of throwing. +The merge is scoped to the service collection it is called on, so it is safe to build several hosts concurrently in one process — parallel integration tests each constructing a `WebApplicationFactory`, or a multi-tenant host spinning up isolated containers. Registrations made on one service collection never appear in another. + ## The get-or-load pattern Inject one of the four cache interfaces and call `GetAsync` with a key and a fetch delegate. The first call runs the delegate and stores the result; subsequent calls within the fresh span return the cached value without invoking the delegate. diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}