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"
+ }
+}