diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c895cf7..ba94e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,9 @@ on: - '**' jobs: + # ── Modern .NET (net8 / net9 / net10) ──────────────────────────────────────── test: - name: Build & Test + name: Build & Test (modern .NET) runs-on: ubuntu-latest steps: @@ -28,10 +29,68 @@ jobs: 10.x - name: Restore dependencies - run: dotnet restore CacheWeave.slnx + run: dotnet restore CacheWeave.slnx --ignore-failed-sources - - name: Build - run: dotnet build CacheWeave.slnx --configuration Release --no-restore + - name: Build (modern .NET projects only) + run: | + for proj in \ + src/CacheWeave.Core/CacheWeave.Core.csproj \ + src/CacheWeave.Redis/CacheWeave.Redis.csproj \ + src/CacheWeave.InMemory/CacheWeave.InMemory.csproj \ + src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj \ + src/CacheWeave.SQLite/CacheWeave.SQLite.csproj \ + src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj \ + src/CacheWeave.Memcached/CacheWeave.Memcached.csproj \ + src/CacheWeave.NCache/CacheWeave.NCache.csproj \ + src/CacheWeave.Faster/CacheWeave.Faster.csproj \ + tests/CacheWeave.Tests/CacheWeave.Tests.csproj; do + dotnet build "$proj" --configuration Release --no-restore + done - name: Test - run: dotnet test CacheWeave.slnx --configuration Release --no-build --verbosity normal + run: > + dotnet test tests/CacheWeave.Tests/CacheWeave.Tests.csproj + --configuration Release + --no-build + --verbosity normal + + # ── .NET Framework 4.8 (CacheWeave.Legacy) ─────────────────────────────────── + test-net48: + name: Build & Test (net48 / CacheWeave.Legacy) + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + 10.x + + - name: Restore dependencies + shell: pwsh + run: dotnet restore src/CacheWeave.Legacy/CacheWeave.Legacy.csproj + + - name: Restore test dependencies + shell: pwsh + run: dotnet restore tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj + + - name: Build CacheWeave.Legacy + shell: pwsh + run: dotnet build src/CacheWeave.Legacy/CacheWeave.Legacy.csproj --configuration Release --no-restore + + - name: Build CacheWeave.Legacy.Tests + shell: pwsh + run: dotnet build tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj --configuration Release --no-restore + + - name: Test (net48) + shell: pwsh + run: > + dotnet test tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj + --configuration Release + --no-build + --verbosity normal diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 5185bc9..d33d97f 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -18,7 +18,8 @@ on: jobs: publish: name: Build, Test & Publish - runs-on: ubuntu-latest + # Windows is required to build and test the net48 CacheWeave.Legacy project + runs-on: windows-latest permissions: contents: write # required to create GitHub Releases @@ -39,20 +40,20 @@ jobs: - name: Resolve version and release notes id: meta + shell: pwsh run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ inputs.version }}" - NOTES="${{ inputs.release_notes }}" - else + if ("${{ github.event_name }}" -eq "workflow_dispatch") { + $version = "${{ inputs.version }}" + $notes = "${{ inputs.release_notes }}" + } else { # Strip leading 'v' from tag name (v1.0.1 → 1.0.1) - VERSION="${GITHUB_REF_NAME#v}" - # Read the body of the annotated tag (everything after the first blank line) - NOTES="$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}")" - fi - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - # Write notes to a file to avoid shell quoting issues with multiline text - printf '%s' "$NOTES" > /tmp/release_notes.md - echo "Publishing version $VERSION" + $version = "$env:GITHUB_REF_NAME" -replace '^v', '' + # Read the body of the annotated tag + $notes = git tag -l --format='%(contents)' "$env:GITHUB_REF_NAME" + } + "VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + $notes | Out-File -FilePath "$env:TEMP\release_notes.md" -Encoding utf8 + Write-Host "Publishing version $version" - name: Restore dependencies run: dotnet restore CacheWeave.slnx @@ -64,35 +65,45 @@ jobs: --no-restore -p:Version=${{ steps.meta.outputs.VERSION }} - - name: Test + - name: Test (modern .NET — ubuntu-compatible projects) run: > dotnet test CacheWeave.slnx --configuration Release --no-build --verbosity normal + --filter "FullyQualifiedName!~CacheWeave.Legacy.Tests" + + - name: Test (net48 — CacheWeave.Legacy.Tests) + run: > + dotnet test tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj + --configuration Release + --no-build + --verbosity normal - name: Pack all packages + shell: pwsh run: | - NOTES="$(cat /tmp/release_notes.md)" - PROJECTS=( - src/CacheWeave.Core/CacheWeave.Core.csproj - src/CacheWeave.Redis/CacheWeave.Redis.csproj - src/CacheWeave.InMemory/CacheWeave.InMemory.csproj - src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj - src/CacheWeave.SQLite/CacheWeave.SQLite.csproj - src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj - src/CacheWeave.Memcached/CacheWeave.Memcached.csproj - src/CacheWeave.NCache/CacheWeave.NCache.csproj - src/CacheWeave.Faster/CacheWeave.Faster.csproj + $notes = Get-Content "$env:TEMP\release_notes.md" -Raw + $projects = @( + "src/CacheWeave.Core/CacheWeave.Core.csproj" + "src/CacheWeave.Redis/CacheWeave.Redis.csproj" + "src/CacheWeave.InMemory/CacheWeave.InMemory.csproj" + "src/CacheWeave.DistributedCache/CacheWeave.DistributedCache.csproj" + "src/CacheWeave.SQLite/CacheWeave.SQLite.csproj" + "src/CacheWeave.DynamoDB/CacheWeave.DynamoDB.csproj" + "src/CacheWeave.Memcached/CacheWeave.Memcached.csproj" + "src/CacheWeave.NCache/CacheWeave.NCache.csproj" + "src/CacheWeave.Faster/CacheWeave.Faster.csproj" + "src/CacheWeave.Legacy/CacheWeave.Legacy.csproj" ) - for PROJECT in "${PROJECTS[@]}"; do - dotnet pack "$PROJECT" \ - --configuration Release \ - --no-build \ - --output ./nupkg \ - -p:Version=${{ steps.meta.outputs.VERSION }} \ - -p:PackageReleaseNotes="$NOTES" - done + foreach ($project in $projects) { + dotnet pack $project ` + --configuration Release ` + --no-build ` + --output ./nupkg ` + -p:Version=${{ steps.meta.outputs.VERSION }} ` + "-p:PackageReleaseNotes=$notes" + } - name: Publish to NuGet run: > @@ -104,8 +115,9 @@ jobs: - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh run: > - gh release create "$GITHUB_REF_NAME" - --title "$GITHUB_REF_NAME" - --notes-file /tmp/release_notes.md + gh release create "$env:GITHUB_REF_NAME" + --title "$env:GITHUB_REF_NAME" + --notes-file "$env:TEMP\release_notes.md" --verify-tag diff --git a/CacheWeave.slnx b/CacheWeave.slnx index 9e0e58e..6e5c1c6 100644 --- a/CacheWeave.slnx +++ b/CacheWeave.slnx @@ -5,12 +5,14 @@ + + diff --git a/README.md b/README.md index 337f1f7..8da6a4f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ public async Task GetProducts([FromQuery] int page = 1) | **Fault-tolerant** | All cache I/O wrapped in try/catch — Redis outage degrades to cache-miss behaviour, never a 500 | | **7 providers** | Redis, InMemory, SQLite, NCache, DynamoDB, Memcached, FASTER KV | | **Multi-target** | `net8.0`, `net9.0`, `net10.0` | +| **.NET Framework 4.8** | `CacheWeave.Legacy` — programmatic caching for net48 without ASP.NET Core | --- @@ -171,15 +172,76 @@ Multiple `[CacheWeaveEvict]` attributes are allowed on a single action. ## Providers -| Package | Backing store | `RemoveByPrefix` | +| Package | Backing store | `RemoveByPrefix` | Target frameworks | +|---|---|---|---| +| `CacheWeave.Redis` | StackExchange.Redis | Yes (SCAN + DEL) | net8/9/10 | +| `CacheWeave.InMemory` | `IMemoryCache` | Yes (prefix scan) | net8/9/10 | +| `CacheWeave.SQLite` | Microsoft.Data.Sqlite | Yes (SQL LIKE) | net8/9/10 | +| `CacheWeave.NCache` | Alachisoft NCache | No | net8/9/10 | +| `CacheWeave.DynamoDB` | AWS DynamoDB | No | net8/9/10 | +| `CacheWeave.Memcached` | EnyimMemcachedCore | No | net8/9/10 | +| `CacheWeave.Faster` | Microsoft FASTER KV | No | net8/9/10 | +| `CacheWeave.Legacy` | Redis, InMemory, SQLite, DynamoDB, NCache | Depends on provider | **net48** | + +--- + +## .NET Framework 4.8 Support (`CacheWeave.Legacy`) + +`CacheWeave.Legacy` brings provider-agnostic caching to .NET Framework 4.8 applications that cannot migrate to modern .NET. It exposes the same `ICacheWeaveService` programmatic API but **does not include ASP.NET Core filters** — there is no attribute-based caching on net48. + +### Install + +```bash +dotnet add package CacheWeave.Legacy +``` + +### Register + +```csharp +// Works with any DI container that supports Microsoft.Extensions.DependencyInjection +services + .AddCacheWeave(options => + { + options.GlobalKeyPrefix = "my-app"; + options.DefaultExpiry = TimeSpan.FromMinutes(5); + options.Serializer = CacheWeaveSerializerType.SystemTextJson; + }) + .AddCacheWeaveRedis("localhost:6379"); + // or: .AddCacheWeaveInMemory() + // or: .AddCacheWeaveSQLite(o => o.DatabasePath = "cache.db") + // or: .AddCacheWeaveDynamoDb(dynamoClient) + // or: .AddCacheWeaveNCache("my-cache") +``` + +### Use + +```csharp +public class ProductService +{ + private readonly ICacheWeaveService _cache; + + public ProductService(ICacheWeaveService cache) => _cache = cache; + + public Task GetAsync(int id) => + _cache.GetOrSetAsync( + $"products:{id}", + ct => _repo.FindAsync(id, ct), + expiry: TimeSpan.FromMinutes(10)); + + public Task InvalidateAsync(int id) => + _cache.InvalidateAsync($"products:{id}"); +} +``` + +### Provider capability on net48 + +| Provider | `RemoveByPrefix` | Notes | |---|---|---| -| `CacheWeave.Redis` | StackExchange.Redis | Yes (SCAN + DEL) | -| `CacheWeave.InMemory` | `IMemoryCache` | Yes (prefix scan) | -| `CacheWeave.SQLite` | Microsoft.Data.Sqlite | Yes (SQL LIKE) | -| `CacheWeave.NCache` | Alachisoft NCache | No | -| `CacheWeave.DynamoDB` | AWS DynamoDB | No | -| `CacheWeave.Memcached` | EnyimMemcachedCore | No | -| `CacheWeave.Faster` | Microsoft FASTER KV | No | +| Redis | Yes (SCAN + DEL) | | +| InMemory | No | Uses `System.Runtime.Caching.MemoryCache` (in-box on net48) | +| SQLite | Yes (SQL LIKE) | Uses `System.Data.SQLite.Core` | +| DynamoDB | No | Client-side TTL enforcement included | +| NCache | No | | --- diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs b/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs new file mode 100644 index 0000000..ae20e60 --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheCompressor.cs @@ -0,0 +1,8 @@ +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheCompressor + { + string Compress(string value); + string Decompress(string value); + } +} diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs b/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs new file mode 100644 index 0000000..7e6949e --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheProvider.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheProvider + { + Task GetAsync(string key, CancellationToken cancellationToken = default); + Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default); + } +} diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs b/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs new file mode 100644 index 0000000..0115f0d --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheProviderInner.cs @@ -0,0 +1,6 @@ +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheProviderInner : ICacheProvider + { + } +} diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs b/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs new file mode 100644 index 0000000..f7adb3f --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheSerializer.cs @@ -0,0 +1,16 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheSerializer + { + string Serialize(T value); + + [return: MaybeNull] + T Deserialize(string value); + + string Serialize(object value, Type type); + object? Deserialize(string value, Type type); + } +} diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs b/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs new file mode 100644 index 0000000..e3c289f --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheStampedeProtector.cs @@ -0,0 +1,16 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheStampedeProtector + { + [return: MaybeNull] + Task ExecuteAsync( + string key, + Func> factory, + CancellationToken cancellationToken = default); + } +} diff --git a/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs b/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs new file mode 100644 index 0000000..2a249b7 --- /dev/null +++ b/src/CacheWeave.Legacy/Abstractions/ICacheWeaveService.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace CacheWeave.Legacy.Abstractions +{ + public interface ICacheWeaveService + { + [return: MaybeNull] + Task GetOrSetAsync(string key, Func> factory, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + + [return: MaybeNull] + Task GetAsync(string key, CancellationToken cancellationToken = default); + + Task SetAsync(string key, T value, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + Task InvalidateAsync(string key, CancellationToken cancellationToken = default); + Task InvalidateByPrefixAsync(string prefix, CancellationToken cancellationToken = default); + Task InvalidateByPrefixesAsync(IEnumerable prefixes, CancellationToken cancellationToken = default); + } +} diff --git a/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj b/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj new file mode 100644 index 0000000..20bcfae --- /dev/null +++ b/src/CacheWeave.Legacy/CacheWeave.Legacy.csproj @@ -0,0 +1,38 @@ + + + + net48 + 8.0 + enable + CacheWeave.Legacy + CacheWeave.Legacy + MIT + README.md + CacheWeave provider-agnostic caching for .NET Framework 4.8. Includes Redis, InMemory, DynamoDB, SQLite, and NCache providers without ASP.NET Core dependencies. + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CacheWeave.Legacy/CacheWeaveOptions.cs b/src/CacheWeave.Legacy/CacheWeaveOptions.cs new file mode 100644 index 0000000..5ae279b --- /dev/null +++ b/src/CacheWeave.Legacy/CacheWeaveOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace CacheWeave.Legacy +{ + public sealed class CacheWeaveOptions + { + /// When false, all cache operations are no-ops. + public bool Enabled { get; set; } = true; + + /// Separator used between cache key segments. Default is ":". + public string KeySeparator { get; set; } = ":"; + + /// Optional prefix prepended to every cache key. + public string? GlobalKeyPrefix { get; set; } + + /// Optional version segment injected into every cache key. + public string? KeyVersion { get; set; } + + /// Default TTL applied when no expiry is specified. Default is 300 seconds. + public TimeSpan? DefaultExpiry { get; set; } = TimeSpan.FromSeconds(300); + + /// Serializer to use for cache values. Default is System.Text.Json. + public CacheWeaveSerializerType Serializer { get; set; } = CacheWeaveSerializerType.SystemTextJson; + + /// When true, wraps the provider with GZip compression. + public bool EnableCompression { get; set; } = false; + } +} diff --git a/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs b/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs new file mode 100644 index 0000000..5ba9ba1 --- /dev/null +++ b/src/CacheWeave.Legacy/CacheWeaveSerializerType.cs @@ -0,0 +1,8 @@ +namespace CacheWeave.Legacy +{ + public enum CacheWeaveSerializerType + { + SystemTextJson, + NewtonsoftJson + } +} diff --git a/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs b/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs new file mode 100644 index 0000000..6d8b3ae --- /dev/null +++ b/src/CacheWeave.Legacy/Compression/CompressingCacheProvider.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Compression +{ + /// + /// Decorator that wraps an with GZip compression. + /// + public sealed class CompressingCacheProvider : ICacheProvider + { + private readonly ICacheProviderInner _inner; + private readonly ICacheCompressor _compressor; + + public CompressingCacheProvider(ICacheProviderInner inner, ICacheCompressor compressor) + { + _inner = inner; + _compressor = compressor; + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = await _inner.GetAsync(key, cancellationToken).ConfigureAwait(false); + return value is null ? null : _compressor.Decompress(value); + } + + public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var compressed = _compressor.Compress(value); + return _inner.SetAsync(key, compressed, expiry, cancellationToken); + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + _inner.RemoveAsync(key, cancellationToken); + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => + _inner.RemoveByPrefixAsync(prefix, cancellationToken); + } +} diff --git a/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs b/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs new file mode 100644 index 0000000..3c9924c --- /dev/null +++ b/src/CacheWeave.Legacy/Compression/GZipCacheCompressor.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Compression +{ + public sealed class GZipCacheCompressor : ICacheCompressor + { + private const CompressionLevel Level = CompressionLevel.Fastest; + + public string Compress(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, Level, leaveOpen: true)) + { + gzip.Write(bytes, 0, bytes.Length); + } + return Convert.ToBase64String(output.ToArray()); + } + + public string Decompress(string value) + { + var bytes = Convert.FromBase64String(value); + using var input = new MemoryStream(bytes); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return Encoding.UTF8.GetString(output.ToArray()); + } + } +} diff --git a/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs b/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3b2e958 --- /dev/null +++ b/src/CacheWeave.Legacy/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,151 @@ +using System; +using Amazon.DynamoDBv2; +using Alachisoft.NCache.Client; +using CacheWeave.Legacy.Abstractions; +using CacheWeave.Legacy.Compression; +using CacheWeave.Legacy.Providers; +using CacheWeave.Legacy.Serialization; +using CacheWeave.Legacy.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; + +namespace CacheWeave.Legacy.Extensions +{ + public static class ServiceCollectionExtensions + { + /// + /// Registers CacheWeave core services. Call one of the provider extension methods + /// (e.g. ) after this to register a backing store. + /// + public static IServiceCollection AddCacheWeave( + this IServiceCollection services, + Action? configure = null) + { + var opts = new CacheWeaveOptions(); + configure?.Invoke(opts); + + services.Configure(o => + { + o.Enabled = opts.Enabled; + o.KeySeparator = opts.KeySeparator; + o.GlobalKeyPrefix = opts.GlobalKeyPrefix; + o.KeyVersion = opts.KeyVersion; + o.DefaultExpiry = opts.DefaultExpiry; + o.Serializer = opts.Serializer; + o.EnableCompression = opts.EnableCompression; + }); + + // Serializer + services.TryAddSingleton(_ => + opts.Serializer == CacheWeaveSerializerType.NewtonsoftJson + ? (ICacheSerializer)new NewtonsoftJsonCacheSerializer() + : new SystemTextJsonCacheSerializer()); + + // Stampede protector + services.TryAddSingleton(); + + // Compressor + services.TryAddSingleton(); + + // ICacheProvider factory — wraps inner provider with compression if enabled + services.AddSingleton(sp => + { + if (!opts.Enabled) + return DisabledCacheProvider.Instance; + + var inner = sp.GetService(); + if (inner is null) + return DisabledCacheProvider.Instance; + + if (!opts.EnableCompression) + return inner; + + var compressor = sp.GetRequiredService(); + return new CompressingCacheProvider(inner, compressor); + }); + + // High-level service + services.TryAddSingleton(); + + return services; + } + + // ── Redis ──────────────────────────────────────────────────────────── + + /// Registers the Redis provider using a connection string. + public static IServiceCollection AddCacheWeaveRedis( + this IServiceCollection services, + string connectionString) + { + var configOpts = ConfigurationOptions.Parse(connectionString); + configOpts.AbortOnConnectFail = false; + services.TryAddSingleton(_ => + ConnectionMultiplexer.Connect(configOpts)); + services.TryAddSingleton(); + return services; + } + + /// Registers the Redis provider using an existing . + public static IServiceCollection AddCacheWeaveRedis( + this IServiceCollection services, + IConnectionMultiplexer multiplexer) + { + services.TryAddSingleton(multiplexer); + services.TryAddSingleton(); + return services; + } + + // ── InMemory ───────────────────────────────────────────────────────── + + /// + /// Registers the in-memory provider backed by . + /// Note: prefix-based eviction is not supported. + /// + public static IServiceCollection AddCacheWeaveInMemory(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + // ── DynamoDB ───────────────────────────────────────────────────────── + + /// Registers the DynamoDB provider. + public static IServiceCollection AddCacheWeaveDynamoDb( + this IServiceCollection services, + IAmazonDynamoDB dynamoDbClient, + Action? configure = null) + { + services.TryAddSingleton(dynamoDbClient); + if (configure != null) + services.Configure(configure); + services.TryAddSingleton(); + return services; + } + + // ── SQLite ─────────────────────────────────────────────────────────── + + /// Registers the SQLite provider. + public static IServiceCollection AddCacheWeaveSQLite( + this IServiceCollection services, + Action? configure = null) + { + if (configure != null) + services.Configure(configure); + services.TryAddSingleton(); + return services; + } + + // ── NCache ─────────────────────────────────────────────────────────── + + /// Registers the NCache provider. + public static IServiceCollection AddCacheWeaveNCache( + this IServiceCollection services, + string cacheName) + { + services.TryAddSingleton(_ => CacheManager.GetCache(cacheName)); + services.TryAddSingleton(); + return services; + } + } +} diff --git a/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs b/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs new file mode 100644 index 0000000..dfb2ad3 --- /dev/null +++ b/src/CacheWeave.Legacy/Polyfills/NullableAttributes.cs @@ -0,0 +1,23 @@ +// Polyfill for nullable flow attributes that are internal in .NET Framework 4.8's BCL +// but required by C# 8 nullable reference type annotations. +// These are only compiled when targeting net48; on net5+ the BCL provides them. + +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | + AttributeTargets.Property | AttributeTargets.ReturnValue, + Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, + Inherited = false)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that an output will not be null even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | + AttributeTargets.Property | AttributeTargets.ReturnValue, + Inherited = false)] + internal sealed class NotNullAttribute : Attribute { } +} diff --git a/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs b/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs new file mode 100644 index 0000000..e5f3040 --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/DisabledCacheProvider.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Providers +{ + /// + /// No-op provider used when caching is globally disabled via . + /// + public sealed class DisabledCacheProvider : ICacheProvider + { + public static readonly DisabledCacheProvider Instance = new DisabledCacheProvider(); + + private DisabledCacheProvider() { } + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs b/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs new file mode 100644 index 0000000..7462a1e --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/DynamoDbCacheOptions.cs @@ -0,0 +1,10 @@ +namespace CacheWeave.Legacy.Providers +{ + public sealed class DynamoDbCacheOptions + { + public string TableName { get; set; } = "CacheWeaveCache"; + public string KeyAttribute { get; set; } = "CacheKey"; + public string ValueAttribute { get; set; } = "CacheValue"; + public string TtlAttribute { get; set; } = "ExpiresAt"; + } +} diff --git a/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs b/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs new file mode 100644 index 0000000..ee40052 --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/DynamoDbCacheProvider.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using CacheWeave.Legacy.Abstractions; +using Microsoft.Extensions.Options; + +namespace CacheWeave.Legacy.Providers +{ + public sealed class DynamoDbCacheProvider : ICacheProviderInner + { + private readonly IAmazonDynamoDB _dynamoDb; + private readonly DynamoDbCacheOptions _options; + + public DynamoDbCacheProvider(IAmazonDynamoDB dynamoDb, IOptions options) + { + _dynamoDb = dynamoDb; + _options = options.Value; + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var request = new GetItemRequest + { + TableName = _options.TableName, + Key = new Dictionary + { + [_options.KeyAttribute] = new AttributeValue { S = key } + } + }; + + var response = await _dynamoDb.GetItemAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsItemSet) + return null; + + // Client-side TTL check — DynamoDB TTL deletion can lag up to 48h + if (response.Item.TryGetValue(_options.TtlAttribute, out var ttlAttr) && + long.TryParse(ttlAttr.N, out var ttlUnix)) + { + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > ttlUnix) + { + await RemoveAsync(key, cancellationToken).ConfigureAwait(false); + return null; + } + } + + return response.Item.TryGetValue(_options.ValueAttribute, out var valueAttr) + ? valueAttr.S + : null; + } + + public async Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var item = new Dictionary + { + [_options.KeyAttribute] = new AttributeValue { S = key }, + [_options.ValueAttribute] = new AttributeValue { S = value } + }; + + if (expiry.HasValue) + { + var ttl = DateTimeOffset.UtcNow.Add(expiry.Value).ToUnixTimeSeconds(); + item[_options.TtlAttribute] = new AttributeValue { N = ttl.ToString() }; + } + + await _dynamoDb.PutItemAsync(new PutItemRequest + { + TableName = _options.TableName, + Item = item + }, cancellationToken).ConfigureAwait(false); + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + _dynamoDb.DeleteItemAsync(new DeleteItemRequest + { + TableName = _options.TableName, + Key = new Dictionary + { + [_options.KeyAttribute] = new AttributeValue { S = key } + } + }, cancellationToken); + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "DynamoDB does not support prefix scans on partition keys. " + + "Use Redis or SQLite if you need prefix eviction."); + } +} diff --git a/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs b/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs new file mode 100644 index 0000000..6497263 --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/InMemoryCacheProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Runtime.Caching; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Providers +{ + /// + /// In-memory cache provider using , + /// which is available in-box on .NET Framework 4.8. + /// Note: prefix-based removal is not supported. + /// + public sealed class InMemoryCacheProvider : ICacheProviderInner, IDisposable + { + private readonly MemoryCache _cache; + + public InMemoryCacheProvider() : this(new MemoryCache("CacheWeave")) { } + + public InMemoryCacheProvider(MemoryCache cache) + { + _cache = cache; + } + + public Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = _cache.Get(key) as string; + return Task.FromResult(value); + } + + public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var policy = new CacheItemPolicy(); + if (expiry.HasValue) + policy.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(expiry.Value); + + _cache.Set(key, value, policy); + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + _cache.Remove(key); + return Task.CompletedTask; + } + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Prefix-based removal is not supported by the InMemory provider on .NET Framework 4.8. " + + "Use Redis or SQLite if you need prefix eviction."); + + public void Dispose() => _cache.Dispose(); + } +} diff --git a/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs b/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs new file mode 100644 index 0000000..b43858e --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/NCacheCacheProvider.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Alachisoft.NCache.Client; +using Alachisoft.NCache.Runtime.Caching; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Providers +{ + public sealed class NCacheCacheProvider : ICacheProviderInner, IDisposable + { + private readonly ICache _cache; + + public NCacheCacheProvider(ICache cache) + { + _cache = cache; + } + + public Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = _cache.Get(key); + return Task.FromResult(value); + } + + public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var item = new CacheItem(value); + if (expiry.HasValue) + item.Expiration = new Expiration(ExpirationType.Absolute, expiry.Value); + + _cache.Insert(key, item); + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + _cache.Remove(key); + return Task.CompletedTask; + } + + public Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "NCache does not support prefix-based removal in this provider. " + + "Use Redis or SQLite if you need prefix eviction."); + + public void Dispose() => _cache.Dispose(); + } +} diff --git a/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs b/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs new file mode 100644 index 0000000..cbbb767 --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/RedisCacheProvider.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; +using StackExchange.Redis; + +namespace CacheWeave.Legacy.Providers +{ + public sealed class RedisCacheProvider : ICacheProviderInner + { + private readonly IConnectionMultiplexer _multiplexer; + + public RedisCacheProvider(IConnectionMultiplexer multiplexer) + { + _multiplexer = multiplexer; + } + + private IDatabase Db => _multiplexer.GetDatabase(); + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = await Db.StringGetAsync(key).ConfigureAwait(false); + return value.HasValue ? value.ToString() : null; + } + + public Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) => + Db.StringSetAsync(key, value, expiry); + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + Db.KeyDeleteAsync(key); + + public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) + { + var db = Db; + foreach (var server in _multiplexer.GetServers()) + { + if (server.IsReplica) + continue; + + var batch = new List(250); + await foreach (var key in server.KeysAsync(pattern: $"{prefix}*", pageSize: 250).ConfigureAwait(false)) + { + batch.Add(key); + if (batch.Count == 250) + { + await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); + batch.Clear(); + } + } + + if (batch.Count > 0) + await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); + } + } + } +} diff --git a/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs b/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs new file mode 100644 index 0000000..c95ecd9 --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/SQLiteCacheOptions.cs @@ -0,0 +1,10 @@ +using System.IO; + +namespace CacheWeave.Legacy.Providers +{ + public sealed class SQLiteCacheOptions + { + public string DatabasePath { get; set; } = "cacheweave.db"; + public string TableName { get; set; } = "CacheEntries"; + } +} diff --git a/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs b/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs new file mode 100644 index 0000000..1490aee --- /dev/null +++ b/src/CacheWeave.Legacy/Providers/SQLiteCacheProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; +using Microsoft.Extensions.Options; +using System.Data.SQLite; + +namespace CacheWeave.Legacy.Providers +{ + public sealed class SQLiteCacheProvider : ICacheProviderInner, IDisposable + { + private readonly SQLiteConnection _connection; + private readonly SQLiteCacheOptions _options; + + public SQLiteCacheProvider(IOptions options) + { + _options = options.Value; + _connection = new SQLiteConnection($"Data Source={_options.DatabasePath};Version=3;"); + _connection.Open(); + EnsureTable(); + } + + private void EnsureTable() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $@" + CREATE TABLE IF NOT EXISTS {_options.TableName} ( + Key TEXT PRIMARY KEY NOT NULL, + Value TEXT NOT NULL, + ExpiresAt INTEGER + )"; + cmd.ExecuteNonQuery(); + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $"SELECT Value, ExpiresAt FROM {_options.TableName} WHERE Key = $key"; + cmd.Parameters.AddWithValue("$key", key); + + using var reader = await Task.Run(() => cmd.ExecuteReader(), cancellationToken).ConfigureAwait(false); + if (!reader.Read()) + return null; + + var value = reader.GetString(0); + if (!reader.IsDBNull(1)) + { + var expiresAt = reader.GetInt64(1); + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expiresAt) + { + await RemoveAsync(key, cancellationToken).ConfigureAwait(false); + return null; + } + } + + return value; + } + + public async Task SetAsync(string key, string value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $@" + INSERT INTO {_options.TableName} (Key, Value, ExpiresAt) + VALUES ($key, $value, $expiresAt) + ON CONFLICT(Key) DO UPDATE SET Value = $value, ExpiresAt = $expiresAt"; + cmd.Parameters.AddWithValue("$key", key); + cmd.Parameters.AddWithValue("$value", value); + cmd.Parameters.AddWithValue("$expiresAt", expiry.HasValue + ? (object)DateTimeOffset.UtcNow.Add(expiry.Value).ToUnixTimeSeconds() + : DBNull.Value); + + await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false); + } + + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $"DELETE FROM {_options.TableName} WHERE Key = $key"; + cmd.Parameters.AddWithValue("$key", key); + await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false); + } + + public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default) + { + using var cmd = _connection.CreateCommand(); + // Escape LIKE special characters in the prefix before appending the wildcard + var escapedPrefix = prefix.Replace("%", "\\%").Replace("_", "\\_"); + cmd.CommandText = $"DELETE FROM {_options.TableName} WHERE Key LIKE $prefix ESCAPE '\\'"; + cmd.Parameters.AddWithValue("$prefix", escapedPrefix + "%"); + await Task.Run(() => cmd.ExecuteNonQuery(), cancellationToken).ConfigureAwait(false); + } + + public void Dispose() => _connection.Dispose(); + } +} diff --git a/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs b/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs new file mode 100644 index 0000000..e8275cd --- /dev/null +++ b/src/CacheWeave.Legacy/Serialization/NewtonsoftJsonCacheSerializer.cs @@ -0,0 +1,31 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using CacheWeave.Legacy.Abstractions; +using Newtonsoft.Json; + +namespace CacheWeave.Legacy.Serialization +{ + public sealed class NewtonsoftJsonCacheSerializer : ICacheSerializer + { + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + DateParseHandling = DateParseHandling.DateTimeOffset, + Formatting = Formatting.None, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + + public string Serialize(T value) => + JsonConvert.SerializeObject(value, Settings); + + [return: MaybeNull] + public T Deserialize(string value) => + JsonConvert.DeserializeObject(value, Settings); + + public string Serialize(object value, Type type) => + JsonConvert.SerializeObject(value, type, Settings); + + public object? Deserialize(string value, Type type) => + JsonConvert.DeserializeObject(value, type, Settings); + } +} diff --git a/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs b/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs new file mode 100644 index 0000000..41ba91b --- /dev/null +++ b/src/CacheWeave.Legacy/Serialization/SystemTextJsonCacheSerializer.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Serialization +{ + public sealed class SystemTextJsonCacheSerializer : ICacheSerializer + { + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public string Serialize(T value) => + JsonSerializer.Serialize(value, Options); + + [return: MaybeNull] + public T Deserialize(string value) => + JsonSerializer.Deserialize(value, Options); + + public string Serialize(object value, Type type) => + JsonSerializer.Serialize(value, type, Options); + + public object? Deserialize(string value, Type type) => + JsonSerializer.Deserialize(value, type, Options); + } +} diff --git a/src/CacheWeave.Legacy/Services/CacheWeaveService.cs b/src/CacheWeave.Legacy/Services/CacheWeaveService.cs new file mode 100644 index 0000000..fdfe2ee --- /dev/null +++ b/src/CacheWeave.Legacy/Services/CacheWeaveService.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; +using Microsoft.Extensions.Options; + +namespace CacheWeave.Legacy.Services +{ + public sealed class CacheWeaveService : ICacheWeaveService + { + private readonly ICacheProvider _provider; + private readonly ICacheSerializer _serializer; + private readonly ICacheStampedeProtector _stampedeProtector; + private readonly CacheWeaveOptions _options; + + public CacheWeaveService( + ICacheProvider provider, + ICacheSerializer serializer, + ICacheStampedeProtector stampedeProtector, + IOptions options) + { + _provider = provider; + _serializer = serializer; + _stampedeProtector = stampedeProtector; + _options = options.Value; + } + + [return: MaybeNull] + public async Task GetOrSetAsync( + string key, + Func> factory, + TimeSpan? expiry = null, + CancellationToken cancellationToken = default) + { + var prefixedKey = PrefixKey(key); + + var cached = await _provider.GetAsync(prefixedKey, cancellationToken).ConfigureAwait(false); + if (cached != null) +#pragma warning disable CS8603 + return _serializer.Deserialize(cached); +#pragma warning restore CS8603 + +#pragma warning disable CS8602 + return await _stampedeProtector.ExecuteAsync(prefixedKey, async ct => + { +#pragma warning restore CS8602 + // Double-checked inside the lock + var recheck = await _provider.GetAsync(prefixedKey, ct).ConfigureAwait(false); + if (recheck != null) +#pragma warning disable CS8603 + return _serializer.Deserialize(recheck); +#pragma warning restore CS8603 + + var result = await factory(ct).ConfigureAwait(false); + if (result != null) + { + var serialized = _serializer.Serialize(result); + var resolvedExpiry = expiry ?? _options.DefaultExpiry; + await _provider.SetAsync(prefixedKey, serialized, resolvedExpiry, ct).ConfigureAwait(false); + } + return result; + }, cancellationToken).ConfigureAwait(false); + } + + [return: MaybeNull] + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var prefixedKey = PrefixKey(key); + var cached = await _provider.GetAsync(prefixedKey, cancellationToken).ConfigureAwait(false); +#pragma warning disable CS8603 + return cached is null ? default : _serializer.Deserialize(cached); +#pragma warning restore CS8603 + } + + public async Task SetAsync(string key, T value, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var prefixedKey = PrefixKey(key); + var serialized = _serializer.Serialize(value); + var resolvedExpiry = expiry ?? _options.DefaultExpiry; + await _provider.SetAsync(prefixedKey, serialized, resolvedExpiry, cancellationToken).ConfigureAwait(false); + } + + public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) + { + var prefixedKey = PrefixKey(key); + return _provider.RemoveAsync(prefixedKey, cancellationToken); + } + + public Task InvalidateByPrefixAsync(string prefix, CancellationToken cancellationToken = default) + { + var prefixedPrefix = PrefixKey(prefix); + return _provider.RemoveByPrefixAsync(prefixedPrefix, cancellationToken); + } + + public async Task InvalidateByPrefixesAsync(IEnumerable prefixes, CancellationToken cancellationToken = default) + { + foreach (var prefix in prefixes) + await InvalidateByPrefixAsync(prefix, cancellationToken).ConfigureAwait(false); + } + + private string PrefixKey(string key) + { + if (string.IsNullOrEmpty(_options.GlobalKeyPrefix)) + return key; + + var sep = _options.KeySeparator; + var prefix = _options.GlobalKeyPrefix + sep; + return key.StartsWith(prefix, StringComparison.Ordinal) ? key : prefix + key; + } + } +} diff --git a/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs b/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs new file mode 100644 index 0000000..992846b --- /dev/null +++ b/src/CacheWeave.Legacy/Services/InProcessStampedeProtector.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; + +namespace CacheWeave.Legacy.Services +{ + public sealed class InProcessStampedeProtector : ICacheStampedeProtector + { + private readonly ConcurrentDictionary _locks = + new ConcurrentDictionary(); + + [return: MaybeNull] + public async Task ExecuteAsync( + string key, + Func> factory, + CancellationToken cancellationToken = default) + { + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await factory(cancellationToken).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + _locks.TryRemove(key, out _); + } + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj b/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj new file mode 100644 index 0000000..5cf111d --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/CacheWeave.Legacy.Tests.csproj @@ -0,0 +1,27 @@ + + + + net48 + 8.0 + enable + CS8602;CS8603;CS8604 + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs new file mode 100644 index 0000000..e2a4f39 --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Compression/CompressingCacheProviderTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; +using CacheWeave.Legacy.Compression; +using FluentAssertions; +using Moq; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Compression +{ + public class CompressingCacheProviderTests + { + private readonly Mock _inner = new Mock(); + private readonly GZipCacheCompressor _compressor = new GZipCacheCompressor(); + + private CompressingCacheProvider MakeSut() => + new CompressingCacheProvider(_inner.Object, _compressor); + + [Fact] + public async Task SetAsync_CompressesValueBeforeStoringInInner() + { + string? stored = null; + _inner.Setup(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, v, __, ___) => stored = v) + .Returns(Task.CompletedTask); + + var sut = MakeSut(); + await sut.SetAsync("k", "hello"); + + stored.Should().NotBe("hello"); + _compressor.Decompress(stored!).Should().Be("hello"); + } + + [Fact] + public async Task GetAsync_DecompressesValueFromInner() + { + var compressed = _compressor.Compress("world"); + _inner.Setup(p => p.GetAsync("k", It.IsAny())) + .ReturnsAsync(compressed); + + var sut = MakeSut(); + var result = await sut.GetAsync("k"); + + result.Should().Be("world"); + } + + [Fact] + public async Task GetAsync_ReturnsNull_WhenInnerReturnsNull() + { + _inner.Setup(p => p.GetAsync("k", It.IsAny())) + .ReturnsAsync((string?)null); + + var sut = MakeSut(); + var result = await sut.GetAsync("k"); + + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_DelegatesToInner() + { + _inner.Setup(p => p.RemoveAsync("k", It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = MakeSut(); + await sut.RemoveAsync("k"); + + _inner.Verify(p => p.RemoveAsync("k", It.IsAny()), Times.Once); + } + + [Fact] + public async Task RemoveByPrefixAsync_DelegatesToInner() + { + _inner.Setup(p => p.RemoveByPrefixAsync("prefix:", It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = MakeSut(); + await sut.RemoveByPrefixAsync("prefix:"); + + _inner.Verify(p => p.RemoveByPrefixAsync("prefix:", It.IsAny()), Times.Once); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs b/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs new file mode 100644 index 0000000..f2cb87d --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Compression/GZipCacheCompressorTests.cs @@ -0,0 +1,63 @@ +using System; +using CacheWeave.Legacy.Compression; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Compression +{ + public class GZipCacheCompressorTests + { + private readonly GZipCacheCompressor _sut = new GZipCacheCompressor(); + + [Fact] + public void Compress_ThenDecompress_ReturnsOriginalValue() + { + const string original = "Hello, CacheWeave Legacy!"; + var compressed = _sut.Compress(original); + var decompressed = _sut.Decompress(compressed); + decompressed.Should().Be(original); + } + + [Fact] + public void Compress_ProducesBase64String() + { + var compressed = _sut.Compress("test"); + var bytes = Convert.FromBase64String(compressed); // should not throw + bytes.Should().NotBeEmpty(); + } + + [Fact] + public void Compress_LargePayload_RoundTrips() + { + var large = new string('x', 100_000); + var compressed = _sut.Compress(large); + var decompressed = _sut.Decompress(compressed); + decompressed.Should().Be(large); + } + + [Fact] + public void Compress_EmptyString_RoundTrips() + { + var compressed = _sut.Compress(string.Empty); + var decompressed = _sut.Decompress(compressed); + decompressed.Should().Be(string.Empty); + } + + [Fact] + public void Compress_JsonPayload_RoundTrips() + { + const string json = "{\"id\":1,\"name\":\"product\",\"price\":9.99}"; + var compressed = _sut.Compress(json); + var decompressed = _sut.Decompress(compressed); + decompressed.Should().Be(json); + } + + [Fact] + public void Compress_OutputIsSmallerThanInput_ForRepetitiveContent() + { + var repetitive = new string('a', 10_000); + var compressed = _sut.Compress(repetitive); + compressed.Length.Should().BeLessThan(repetitive.Length); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs new file mode 100644 index 0000000..dcf9dfc --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Providers/DisabledCacheProviderTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using CacheWeave.Legacy.Providers; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Providers +{ + public class DisabledCacheProviderTests + { + private readonly DisabledCacheProvider _sut = DisabledCacheProvider.Instance; + + [Fact] + public async Task GetAsync_AlwaysReturnsNull() + { + var result = await _sut.GetAsync("any-key"); + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_DoesNotThrow() + { + Func act = () => _sut.SetAsync("k", "v", TimeSpan.FromMinutes(1)); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RemoveAsync_DoesNotThrow() + { + Func act = () => _sut.RemoveAsync("k"); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RemoveByPrefixAsync_DoesNotThrow() + { + Func act = () => _sut.RemoveByPrefixAsync("prefix:"); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SetThenGet_ReturnsNull_BecauseCachingIsDisabled() + { + await _sut.SetAsync("k", "v"); + var result = await _sut.GetAsync("k"); + result.Should().BeNull(); + } + + [Fact] + public void Instance_IsSingleton() + { + DisabledCacheProvider.Instance.Should().BeSameAs(DisabledCacheProvider.Instance); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs b/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs new file mode 100644 index 0000000..81505e8 --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Providers/InMemoryCacheProviderTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Providers; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Providers +{ + public class InMemoryCacheProviderTests : IDisposable + { + private readonly InMemoryCacheProvider _sut = new InMemoryCacheProvider(); + + public void Dispose() => _sut.Dispose(); + + // ------------------------------------------------------------------------- + // Get / Set + // ------------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_ReturnsNull_WhenKeyNotPresent() + { + var result = await _sut.GetAsync("missing"); + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_ThenGetAsync_ReturnsValue() + { + await _sut.SetAsync("k", "v"); + var result = await _sut.GetAsync("k"); + result.Should().Be("v"); + } + + [Fact] + public async Task SetAsync_WithExpiry_StoresValue() + { + await _sut.SetAsync("k", "v", TimeSpan.FromMinutes(5)); + var result = await _sut.GetAsync("k"); + result.Should().Be("v"); + } + + [Fact] + public async Task SetAsync_WithNullExpiry_StoresValue() + { + await _sut.SetAsync("k", "v", expiry: null); + var result = await _sut.GetAsync("k"); + result.Should().Be("v"); + } + + [Fact] + public async Task SetAsync_OverwritesExistingValue() + { + await _sut.SetAsync("k", "first"); + await _sut.SetAsync("k", "second"); + var result = await _sut.GetAsync("k"); + result.Should().Be("second"); + } + + // ------------------------------------------------------------------------- + // Remove + // ------------------------------------------------------------------------- + + [Fact] + public async Task RemoveAsync_RemovesExistingKey() + { + await _sut.SetAsync("k", "v"); + await _sut.RemoveAsync("k"); + var result = await _sut.GetAsync("k"); + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_DoesNotThrow_WhenKeyMissing() + { + Func act = () => _sut.RemoveAsync("nonexistent"); + await act.Should().NotThrowAsync(); + } + + // ------------------------------------------------------------------------- + // RemoveByPrefix — not supported + // ------------------------------------------------------------------------- + + [Fact] + public async Task RemoveByPrefixAsync_ThrowsNotSupportedException() + { + Func act = () => _sut.RemoveByPrefixAsync("prefix:"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RemoveByPrefixAsync_ExceptionMessage_MentionsInMemory() + { + var ex = await Assert.ThrowsAsync( + () => _sut.RemoveByPrefixAsync("prefix:")); + ex.Message.Should().Contain("InMemory"); + } + + // ------------------------------------------------------------------------- + // CancellationToken is accepted + // ------------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_AcceptsCancellationToken() + { + using var cts = new CancellationTokenSource(); + var result = await _sut.GetAsync("k", cts.Token); + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_AcceptsCancellationToken() + { + using var cts = new CancellationTokenSource(); + Func act = () => _sut.SetAsync("k", "v", null, cts.Token); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RemoveAsync_AcceptsCancellationToken() + { + using var cts = new CancellationTokenSource(); + Func act = () => _sut.RemoveAsync("k", cts.Token); + await act.Should().NotThrowAsync(); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs b/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs new file mode 100644 index 0000000..f5868c9 --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Serialization/NewtonsoftJsonCacheSerializerTests.cs @@ -0,0 +1,67 @@ +using System; +using CacheWeave.Legacy.Serialization; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Serialization +{ + public class NewtonsoftJsonCacheSerializerTests + { + private readonly NewtonsoftJsonCacheSerializer _sut = new NewtonsoftJsonCacheSerializer(); + + private class Item + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void Serialize_Generic_ThenDeserialize_ReturnsEquivalentObject() + { + var item = new Item { Id = 1, Name = "Widget" }; + var json = _sut.Serialize(item); + var result = _sut.Deserialize(json); + result.Should().BeEquivalentTo(item); + } + + [Fact] + public void Serialize_WithType_ThenDeserialize_ReturnsEquivalentObject() + { + var item = new Item { Id = 2, Name = "Gadget" }; + var json = _sut.Serialize(item, typeof(Item)); + var result = _sut.Deserialize(json, typeof(Item)) as Item; + result.Should().BeEquivalentTo(item); + } + + [Fact] + public void Serialize_ProducesValidJson() + { + var json = _sut.Serialize(new Item { Id = 3, Name = "Thing" }); + // Newtonsoft preserves PascalCase by default + json.Should().ContainAny("\"Id\"", "\"id\""); + } + + [Fact] + public void Deserialize_ReturnsDefault_ForNullJson() + { + var result = _sut.Deserialize("null"); + result.Should().BeNull(); + } + + [Fact] + public void Serialize_String_RoundTrips() + { + var json = _sut.Serialize("hello"); + var result = _sut.Deserialize(json); + result.Should().Be("hello"); + } + + [Fact] + public void Serialize_Int_RoundTrips() + { + var json = _sut.Serialize(42); + var result = _sut.Deserialize(json); + result.Should().Be(42); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs b/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs new file mode 100644 index 0000000..84fc44a --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Serialization/SystemTextJsonCacheSerializerTests.cs @@ -0,0 +1,66 @@ +using System; +using CacheWeave.Legacy.Serialization; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Serialization +{ + public class SystemTextJsonCacheSerializerTests + { + private readonly SystemTextJsonCacheSerializer _sut = new SystemTextJsonCacheSerializer(); + + private class Item + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void Serialize_Generic_ThenDeserialize_ReturnsEquivalentObject() + { + var item = new Item { Id = 1, Name = "Widget" }; + var json = _sut.Serialize(item); + var result = _sut.Deserialize(json); + result.Should().BeEquivalentTo(item); + } + + [Fact] + public void Serialize_WithType_ThenDeserialize_ReturnsEquivalentObject() + { + var item = new Item { Id = 2, Name = "Gadget" }; + var json = _sut.Serialize(item, typeof(Item)); + var result = _sut.Deserialize(json, typeof(Item)) as Item; + result.Should().BeEquivalentTo(item); + } + + [Fact] + public void Serialize_UsesCamelCase() + { + var json = _sut.Serialize(new Item { Id = 3, Name = "Thing" }); + json.Should().Contain("\"id\"").And.Contain("\"name\""); + } + + [Fact] + public void Deserialize_ReturnsDefault_ForNullJson() + { + var result = _sut.Deserialize("null"); + result.Should().BeNull(); + } + + [Fact] + public void Serialize_String_RoundTrips() + { + var json = _sut.Serialize("hello"); + var result = _sut.Deserialize(json); + result.Should().Be("hello"); + } + + [Fact] + public void Serialize_Int_RoundTrips() + { + var json = _sut.Serialize(42); + var result = _sut.Deserialize(json); + result.Should().Be(42); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs b/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs new file mode 100644 index 0000000..71b2857 --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Services/CacheWeaveServiceTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Abstractions; +using CacheWeave.Legacy.Serialization; +using CacheWeave.Legacy.Services; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Services +{ + public class CacheWeaveServiceTests + { + private readonly Mock _provider = new Mock(); + private readonly SystemTextJsonCacheSerializer _serializer = new SystemTextJsonCacheSerializer(); + private readonly InProcessStampedeProtector _stampede = new InProcessStampedeProtector(); + private readonly CacheWeaveOptions _opts = new CacheWeaveOptions { DefaultExpiry = TimeSpan.FromMinutes(5) }; + + private CacheWeaveService MakeSut() => new CacheWeaveService( + _provider.Object, + _serializer, + _stampede, + Options.Create(_opts)); + + private class Item + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + // ------------------------------------------------------------------------- + // GetOrSetAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task GetOrSetAsync_ReturnsCachedValue_OnHit() + { + var item = new Item { Id = 1, Name = "Steel" }; + _provider.Setup(p => p.GetAsync("k", default)) + .ReturnsAsync(_serializer.Serialize(item)); + + var sut = MakeSut(); + var result = await sut.GetOrSetAsync("k", _ => Task.FromResult(new Item { Id = 99, Name = "Other" })); + + result.Should().BeEquivalentTo(item); + _provider.Verify(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), default), Times.Never); + } + + [Fact] + public async Task GetOrSetAsync_InvokesFactory_OnMiss_AndStores() + { + _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null); + + var sut = MakeSut(); + var result = await sut.GetOrSetAsync("k", _ => Task.FromResult(new Item { Id = 1, Name = "Steel" })); + + result.Should().BeEquivalentTo(new Item { Id = 1, Name = "Steel" }); + _provider.Verify(p => p.SetAsync("k", It.IsAny(), _opts.DefaultExpiry, default), Times.Once); + } + + [Fact] + public async Task GetOrSetAsync_UsesExplicitExpiry_WhenProvided() + { + _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null); + var sut = MakeSut(); + + await sut.GetOrSetAsync("k", _ => Task.FromResult(new Item { Id = 1, Name = "X" }), TimeSpan.FromSeconds(30)); + + _provider.Verify(p => p.SetAsync("k", It.IsAny(), TimeSpan.FromSeconds(30), default), Times.Once); + } + + [Fact] + public async Task GetOrSetAsync_DoesNotStore_WhenFactoryReturnsNull() + { + _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null); + var sut = MakeSut(); + + var result = await sut.GetOrSetAsync("k", _ => Task.FromResult(null!)); + + result.Should().BeNull(); + _provider.Verify(p => p.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), default), Times.Never); + } + + // ------------------------------------------------------------------------- + // GetAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_ReturnsDeserializedValue_OnHit() + { + var item = new Item { Id = 5, Name = "Copper" }; + _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync(_serializer.Serialize(item)); + var sut = MakeSut(); + + var result = await sut.GetAsync("k"); + + result.Should().BeEquivalentTo(item); + } + + [Fact] + public async Task GetAsync_ReturnsDefault_OnMiss() + { + _provider.Setup(p => p.GetAsync("k", default)).ReturnsAsync((string?)null); + var sut = MakeSut(); + + var result = await sut.GetAsync("k"); + + result.Should().BeNull(); + } + + // ------------------------------------------------------------------------- + // SetAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task SetAsync_SerializesAndStores() + { + var sut = MakeSut(); + var item = new Item { Id = 3, Name = "Iron" }; + + await sut.SetAsync("k", item); + + _provider.Verify(p => p.SetAsync("k", It.IsAny(), _opts.DefaultExpiry, default), Times.Once); + } + + [Fact] + public async Task SetAsync_UsesExplicitExpiry() + { + var sut = MakeSut(); + + await sut.SetAsync("k", new Item { Id = 1, Name = "X" }, TimeSpan.FromSeconds(10)); + + _provider.Verify(p => p.SetAsync("k", It.IsAny(), TimeSpan.FromSeconds(10), default), Times.Once); + } + + // ------------------------------------------------------------------------- + // InvalidateAsync / InvalidateByPrefixAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task InvalidateAsync_CallsRemove() + { + var sut = MakeSut(); + await sut.InvalidateAsync("k"); + _provider.Verify(p => p.RemoveAsync("k", default), Times.Once); + } + + [Fact] + public async Task InvalidateByPrefixAsync_CallsRemoveByPrefix() + { + var sut = MakeSut(); + await sut.InvalidateByPrefixAsync("prefix:"); + _provider.Verify(p => p.RemoveByPrefixAsync("prefix:", default), Times.Once); + } + + [Fact] + public async Task InvalidateByPrefixesAsync_CallsRemoveByPrefix_ForEachPrefix() + { + var sut = MakeSut(); + await sut.InvalidateByPrefixesAsync(new[] { "products:", "dashboard:", "stats:" }); + + _provider.Verify(p => p.RemoveByPrefixAsync("products:", default), Times.Once); + _provider.Verify(p => p.RemoveByPrefixAsync("dashboard:", default), Times.Once); + _provider.Verify(p => p.RemoveByPrefixAsync("stats:", default), Times.Once); + } + + [Fact] + public async Task InvalidateByPrefixesAsync_EmptyList_DoesNotCallProvider() + { + var sut = MakeSut(); + await sut.InvalidateByPrefixesAsync(new string[0]); + _provider.Verify(p => p.RemoveByPrefixAsync(It.IsAny(), default), Times.Never); + } + + // ------------------------------------------------------------------------- + // GlobalKeyPrefix + // ------------------------------------------------------------------------- + + [Fact] + public async Task SetAsync_PrependGlobalKeyPrefix_WhenConfigured() + { + _opts.GlobalKeyPrefix = "myapp"; + var sut = MakeSut(); + + await sut.SetAsync("products", "value"); + + _provider.Verify(p => p.SetAsync("myapp:products", It.IsAny(), It.IsAny(), default), Times.Once); + } + + [Fact] + public async Task SetAsync_DoesNotDoublePrefix_WhenKeyAlreadyPrefixed() + { + _opts.GlobalKeyPrefix = "myapp"; + var sut = MakeSut(); + + await sut.SetAsync("myapp:products", "value"); + + _provider.Verify(p => p.SetAsync("myapp:products", It.IsAny(), It.IsAny(), default), Times.Once); + } + } +} diff --git a/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs b/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs new file mode 100644 index 0000000..6a897fd --- /dev/null +++ b/tests/CacheWeave.Legacy.Tests/Services/InProcessStampedeProtectorTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CacheWeave.Legacy.Services; +using FluentAssertions; +using Xunit; + +namespace CacheWeave.Legacy.Tests.Services +{ + public class InProcessStampedeProtectorTests + { + private readonly InProcessStampedeProtector _sut = new InProcessStampedeProtector(); + + [Fact] + public async Task ExecuteAsync_ReturnsFactoryResult() + { + var result = await _sut.ExecuteAsync("k", _ => Task.FromResult("value")); + result.Should().Be("value"); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenFactoryReturnsNull() + { + var result = await _sut.ExecuteAsync("k", _ => Task.FromResult(null!)); + result.Should().BeNull(); + } + + [Fact] + public async Task ExecuteAsync_SerializesCallsForSameKey() + { + var callOrder = new List(); + var tcs = new TaskCompletionSource(); + + var t1 = _sut.ExecuteAsync("k", async _ => + { + callOrder.Add(1); + await tcs.Task; + return 1; + }); + + // Give t1 time to acquire the lock + await Task.Delay(50); + + var t2 = _sut.ExecuteAsync("k", _ => + { + callOrder.Add(2); + return Task.FromResult(2); + }); + + tcs.SetResult(true); + await Task.WhenAll(t1, t2); + + // t1 must complete before t2 starts + callOrder[0].Should().Be(1); + callOrder[1].Should().Be(2); + } + + [Fact] + public async Task ExecuteAsync_AllowsConcurrentCallsForDifferentKeys() + { + var barrier = new SemaphoreSlim(0, 2); + var reached = 0; + + var t1 = _sut.ExecuteAsync("key1", async _ => + { + Interlocked.Increment(ref reached); + barrier.Release(); + await Task.Delay(50); + return 1; + }); + + var t2 = _sut.ExecuteAsync("key2", async _ => + { + Interlocked.Increment(ref reached); + barrier.Release(); + await Task.Delay(50); + return 2; + }); + + // Both should reach the barrier concurrently + var bothReached = await barrier.WaitAsync(TimeSpan.FromSeconds(2)) && + await barrier.WaitAsync(TimeSpan.FromSeconds(2)); + + bothReached.Should().BeTrue(); + await Task.WhenAll(t1, t2); + } + + [Fact] + public async Task ExecuteAsync_PropagatesException_FromFactory() + { + Func act = () => _sut.ExecuteAsync("k", _ => throw new InvalidOperationException("boom")); + await act.Should().ThrowAsync().WithMessage("boom"); + } + } +}