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
19 changes: 19 additions & 0 deletions .claude/features-done/gettypes-safe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Feature: gettypes-safe

## Goal
Make `AddCache()` resilient to `ReflectionTypeLoadException` thrown by `Assembly.GetTypes()` when an unrelated assembly has unresolvable metadata references. PlutusWave hit a fatal startup crash from a Quilt4Net/ApplicationInsights mismatch in a third-party dll.

## Scope
- `Tharga.Cache/CacheRegistrationExtensions.cs` — add `GetTypesSafe(assembly, logger)` helper, replace `assembly.GetTypes()` calls in `RegisterIPersistFromAssembly` and `InvokeAllPersistRegistrations`.
- Add a test verifying `AddCache` does not throw when a loaded assembly has an unresolvable type.

## Acceptance Criteria
- `AddCache` completes when an in-process assembly throws `ReflectionTypeLoadException` from `GetTypes()`.
- A warning is logged (per affected assembly) so the root cause isn't silently swallowed.
- Existing tests still pass.

## Done Condition
User confirms the fix is satisfactory.

## Originating Branch
develop
9 changes: 5 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@
"Bash(cat:*)",
"Skill(update-config)",
"Skill(update-config:*)",
"Bash(grep -r [Inject] --include=*.razor.cs --include=*.cs .)",
"Bash(rm .claude/feature.md)",
"Bash(rm .claude/plan.md)",
"Bash(xargs grep:*)",
"Bash(git checkout:*)",
"Bash(dotnet test:*)",
"Bash(gh pr:*)",
"Bash(gh run:*)",
"Bash(git checkout:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git merge:*)",
"Bash(git rm:*)",
"Bash(git branch:*)",
"Bash(git log:*)"
],
"additionalDirectories": [
Expand Down
42 changes: 36 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,43 @@ jobs:
echo "previous_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
echo "Computed version: $VERSION (previous: $LATEST_TAG)"

- name: Pack
- name: Compute pre-release version
id: preversion
if: github.event_name == 'pull_request'
run: |
dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }}
VERSION="${{ steps.version.outputs.version }}"
PRE_BASE="${VERSION}-pre"

LATEST_PRE=$(git tag -l "${PRE_BASE}.*" --sort=-v:refname | head -1)

if [ -z "$LATEST_PRE" ]; then
COUNTER=1
else
COUNTER=$(echo "$LATEST_PRE" | sed "s/.*-pre\.\([0-9]*\)/\1/")
COUNTER=$((COUNTER + 1))
fi

PRE_VERSION="${PRE_BASE}.${COUNTER}"
echo "version=$PRE_VERSION" >> "$GITHUB_OUTPUT"
echo "Computed pre-release version: $PRE_VERSION"

- name: Pack (stable — push to master)
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
run: |
dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }}
dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }}

- name: Pack (pre-release — pull request)
if: github.event_name == 'pull_request'
run: |
dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }}
dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }}
dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }}
dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }}
dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }}

- name: Upload artifacts
uses: actions/upload-artifact@v4
Expand Down
10 changes: 3 additions & 7 deletions Sample/Tharga.Cache.WebApi/Controllers/CacheMonitorController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,10 @@ public Task<IActionResult> GetCache()
[HttpGet("type/{type}")]
public Task<IActionResult> GetType(string type)
{
//TODO: Make it possible to provide a type.
//var t = Type.GetType(type);
//if (t == null) return BadRequest($"Cannot find type {type}.");
var t1 = Type.GetType(type);
var t = typeof(WeatherForecast[]);
var info = _cacheMonitor.GetInfos().FirstOrDefault(x => x.Type.Name == type);
if (info == null) return Task.FromResult<IActionResult>(NotFound($"Cannot find cache type '{type}'."));

var datas = _cacheMonitor.GetByType(t);
var response = datas.Select(x => new
var response = info.Items.Select(x => new
{
x.Key,
x.Value.AccessCount,
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 @@ -27,7 +27,7 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Tharga.Blazor" Version="2.1.3" />
<PackageReference Include="Tharga.Blazor" Version="2.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tharga.Cache\Tharga.Cache.csproj" />
Expand Down
6 changes: 3 additions & 3 deletions Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -23,6 +22,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion Tharga.Cache.File/Tharga.Cache.File.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.202">
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
6 changes: 3 additions & 3 deletions Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -23,6 +22,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions Tharga.Cache.MongoDB/MongoDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ public async Task<CacheItem<T>> GetAsync<T>(Key key)
{
var collection = GetCollection();

var item = await collection.GetOneAsync(x => x.Id == key.Value, OneOption<CacheEntity>.SingleOrDefault); //TODO: Should be possible to provide option with ID (not predicate).
var item = await collection.GetOneAsync(key.Value);
if (item != null)
{
if (!item.StaleWhileRevalidate && item.FreshSpan.HasValue && item.FreshSpan.Value != TimeSpan.MaxValue && item.CreateTime.Add(item.FreshSpan.Value) < DateTime.UtcNow)
{
await collection.DeleteOneAsync(x => x.Id == item.Id, OneOption<CacheEntity>.SingleOrDefault); //TODO: Here we should not need a predicate
await collection.DeleteOneAsync(item.Id);
return null;
}

Expand Down Expand Up @@ -104,7 +104,7 @@ public Task<bool> Invalidate<T>(Key key)
public async Task<bool> DropAsync<T>(Key key)
{
var collection = GetCollection();
var item = await collection.DeleteOneAsync(x => x.Id == key.Value, OneOption<CacheEntity>.SingleOrDefault); //TODO: Here we should not need a predicate
var item = await collection.DeleteOneAsync(key.Value);
return item != null;
}

Expand Down
4 changes: 2 additions & 2 deletions Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.202">
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Tharga.MongoDB" Version="2.10.3" />
<PackageReference Include="Tharga.MongoDB" Version="2.10.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tharga.Cache\Tharga.Cache.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions Tharga.Cache.Redis.Tests/ObsoleteTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// These tests deliberately reference an obsolete type to verify the [Obsolete] attribute is present.
#pragma warning disable CS0618

using FluentAssertions;
using Xunit;

Expand Down
8 changes: 5 additions & 3 deletions Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<!-- xUnit1051: TestContext.Current.CancellationToken — stylistic, our tests are short-lived. -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -23,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion Tharga.Cache.Redis/Tharga.Cache.Redis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<ItemGroup>
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="StackExchange.Redis" Version="2.12.14" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.202">
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
79 changes: 79 additions & 0 deletions Tharga.Cache.Tests/GetTypesSafeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Reflection;
using FluentAssertions;
using Xunit;

namespace Tharga.Cache.Tests;

public class GetTypesSafeTests
{
[Fact]
public void NormalAssembly_ReturnsAllTypes()
{
//Arrange
var assembly = typeof(GetTypesSafeTests).Assembly;

//Act
var types = CacheRegistrationExtensions.GetTypesSafe(assembly);

//Assert
types.Should().NotBeEmpty();
types.Should().Contain(typeof(GetTypesSafeTests));
}

[Fact]
public void AssemblyThrowingReflectionTypeLoadException_ReturnsLoadedTypesOnly()
{
//Arrange
var loadable = new[] { typeof(string), typeof(int) };
var assembly = new ThrowingAssembly(
loadedTypes: loadable,
unresolvedCount: 2);

//Act
var types = CacheRegistrationExtensions.GetTypesSafe(assembly);

//Assert
types.Should().BeEquivalentTo(loadable);
}

[Fact]
public void AssemblyThrowingReflectionTypeLoadException_DoesNotPropagate()
{
//Arrange
var assembly = new ThrowingAssembly(loadedTypes: [], unresolvedCount: 1);

//Act
var act = () => CacheRegistrationExtensions.GetTypesSafe(assembly);

//Assert
act.Should().NotThrow();
}

private sealed class ThrowingAssembly : Assembly
{
private readonly Type[] _loadedTypes;
private readonly int _unresolvedCount;
private static int _counter;
private readonly string _name = $"ThrowingAssembly{++_counter}, Version=1.0.0.0";

public ThrowingAssembly(Type[] loadedTypes, int unresolvedCount)
{
_loadedTypes = loadedTypes;
_unresolvedCount = unresolvedCount;
}

public override string FullName => _name;

public override Type[] GetTypes()
{
var types = new Type[_loadedTypes.Length + _unresolvedCount];
for (var i = 0; i < _loadedTypes.Length; i++) types[i] = _loadedTypes[i];

var loaderExceptions = new Exception[_unresolvedCount];
for (var i = 0; i < _unresolvedCount; i++)
loaderExceptions[i] = new TypeLoadException("Could not load type 'Missing.Type' for testing.");

throw new ReflectionTypeLoadException(types, loaderExceptions);
}
}
}
5 changes: 0 additions & 5 deletions Tharga.Cache.Tests/Helper/AllTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,10 @@ public IEnumerator<object[]> GetEnumerator()

foreach (var evictionPolicy in evictionPolicies)
{
//TODO: Return all types automatically
//yield return [typeof(GenericCache), evictionPolicy, false];
//yield return [typeof(GenericTimeCache), evictionPolicy, false];
yield return [typeof(EternalCache), evictionPolicy, false];
yield return [typeof(TimeToLiveCache), evictionPolicy, false];
yield return [typeof(TimeToIdleCache), evictionPolicy, false];

//yield return [typeof(GenericCache), evictionPolicy, true];
//yield return [typeof(GenericTimeCache), evictionPolicy, true];
yield return [typeof(EternalCache), evictionPolicy, true];
yield return [typeof(TimeToLiveCache), evictionPolicy, true];
yield return [typeof(TimeToIdleCache), evictionPolicy, true];
Expand Down
8 changes: 5 additions & 3 deletions Tharga.Cache.Tests/Tharga.Cache.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<!-- xUnit1051: TestContext.Current.CancellationToken — stylistic, our tests are short-lived. -->
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -23,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
Expand Down
8 changes: 5 additions & 3 deletions Tharga.Cache.Tests/TimeToIdleCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ public async Task KeepIfUsed(bool keep)
_cacheMonitor.DataDropEvent += (_, _) => { monitorDropEventCount++; };

//Act
// Timing chosen so every assertion has >= 100ms margin from the TTI boundary on slow CI runners.
// TTI=400ms; access at t=250 resets idle to expire t=650, etc.
_ = await sut.GetAsync("a", () => Task.FromResult("a1"), TimeSpan.FromMilliseconds(400));
await Task.Delay(200);
await Task.Delay(250);
_ = await sut.GetAsync("a", () => Task.FromResult("a2"), TimeSpan.FromMilliseconds(400));
await Task.Delay(200);
if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400));
await Task.Delay(250);
if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400));
await Task.Delay(300);
var result = await sut.GetAsync("a", () => Task.FromResult("a4"), TimeSpan.FromMilliseconds(400));

//Assert
Expand Down
Loading
Loading