Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Tharga.Blazor" Version="2.3.0" />
<PackageReference Include="Tharga.Blazor" Version="2.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tharga.Cache\Tharga.Cache.csproj" />
Expand Down
17 changes: 3 additions & 14 deletions Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,16 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
17 changes: 3 additions & 14 deletions Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,16 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
17 changes: 3 additions & 14 deletions Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<!-- xUnit1051: TestContext.Current.CancellationToken — stylistic, our tests are short-lived. -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
Expand All @@ -10,21 +11,9 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
96 changes: 96 additions & 0 deletions Tharga.Cache.Tests/AddCacheConcurrencyTests.cs
Original file line number Diff line number Diff line change
@@ -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<T>;

// 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<Type, CacheTypeOptions> RegisteredTypes(IServiceCollection services)
{
var options = services
.Last(x => x.ServiceType == typeof(IOptions<CacheOptions>))
.ImplementationInstance as IOptions<CacheOptions>;

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)]);
}
}
}
19 changes: 5 additions & 14 deletions Tharga.Cache.Tests/AddCacheIdempotencyTests.cs
Original file line number Diff line number Diff line change
@@ -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()
{
Expand Down Expand Up @@ -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();
Expand All @@ -66,8 +57,8 @@ public void AddCache_CalledTwice_WithSameType_FirstRegistrationWins()

//Assert
var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<ITimeToLiveCache>();
cache.Should().NotBeNull();
var options = provider.GetRequiredService<IOptions<CacheOptions>>().Value;
options.GetRegistered()[typeof(string)].DefaultFreshSpan.Should().Be(TimeSpan.FromMinutes(99));
}

[Fact]
Expand Down
17 changes: 3 additions & 14 deletions Tharga.Cache.Tests/Tharga.Cache.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<!-- xUnit1051: TestContext.Current.CancellationToken — stylistic, our tests are short-lived. -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
Expand All @@ -10,21 +11,9 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
33 changes: 16 additions & 17 deletions Tharga.Cache/CacheRegistrationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ namespace Tharga.Cache;

public static class CacheRegistrationExtensions
{
private static readonly Dictionary<Type, CacheTypeOptions> _configuredPersistTypes = new();

internal static void ResetRegistrations()
{
_configuredPersistTypes.Clear();
}

public static void AddCache(this IServiceCollection serviceCollection, Action<CacheOptions> options = null)
{
var o = new CacheOptions
Expand All @@ -25,7 +18,7 @@ public static void AddCache(this IServiceCollection serviceCollection, Action<Ca
};
options?.Invoke(o);

AppendPreviousRegistrations(o);
AppendPreviousRegistrations(serviceCollection, o);

// Replace IOptions<CacheOptions> on each call so it carries the merged type registrations.
serviceCollection.RemoveAll<IOptions<CacheOptions>>();
Expand Down Expand Up @@ -104,17 +97,23 @@ public static void AddCache(this IServiceCollection serviceCollection, Action<Ca
}

/// <summary>
/// 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.
/// </summary>
private static void AppendPreviousRegistrations(CacheOptions o)
/// <remarks>
/// 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.
/// </remarks>
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<CacheOptions>))?
.ImplementationInstance as IOptions<CacheOptions>;

if (previous == null) return;

foreach (var previouslyRegisteredType in previous.Value.GetRegistered())
{
o.TryAddType(previouslyRegisteredType.Key, previouslyRegisteredType.Value);
}
Expand Down
1 change: 0 additions & 1 deletion Tharga.Cache/Tharga.Cache.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.400">
<PrivateAssets>all</PrivateAssets>
Expand Down
2 changes: 2 additions & 0 deletions docs/articles/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Loading