From 459b76e9796ffd9b14e4893976c631c08a63e2fa Mon Sep 17 00:00:00 2001 From: Caleb Lloyd Date: Sat, 10 May 2025 21:33:07 -0400 Subject: [PATCH 1/4] clear cache between tests Signed-off-by: Caleb Lloyd --- .../NatsIntegrationFixture.cs | 44 +++++++++-- test/IntegrationTests/TestBase.cs | 20 ++++- .../packages.linux-x64.lock.json | 3 +- .../packages.osx-arm64.lock.json | 3 +- .../packages.win-x64.lock.json | 3 +- test/TestUtils/NatsTestExtensions.cs | 2 +- util/NatsAppHost/NatsAppHost.csproj | 2 +- util/NatsAppHost/packages.linux-x64.lock.json | 77 ++++++++++++++++--- util/NatsAppHost/packages.osx-arm64.lock.json | 77 ++++++++++++++++--- util/NatsAppHost/packages.win-x64.lock.json | 77 ++++++++++++++++--- util/PerfTest/Program.cs | 2 +- util/PerfTest/packages.linux-x64.lock.json | 3 +- util/PerfTest/packages.osx-arm64.lock.json | 3 +- util/PerfTest/packages.win-x64.lock.json | 3 +- 14 files changed, 267 insertions(+), 52 deletions(-) diff --git a/test/IntegrationTests/NatsIntegrationFixture.cs b/test/IntegrationTests/NatsIntegrationFixture.cs index 754fe85..728a50c 100644 --- a/test/IntegrationTests/NatsIntegrationFixture.cs +++ b/test/IntegrationTests/NatsIntegrationFixture.cs @@ -1,5 +1,6 @@ using Aspire.Hosting; using CodeCargo.NatsDistributedCache.TestUtils; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.KeyValueStore; @@ -13,6 +14,7 @@ namespace CodeCargo.NatsDistributedCache.IntegrationTests; public class NatsIntegrationFixture : IAsyncLifetime { private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(30); + private readonly Dictionary _registeredServiceTypes = new(); private DistributedApplication? _app; private int _disposed; private ServiceProvider? _serviceProvider; @@ -51,7 +53,14 @@ public async ValueTask InitializeAsync() { builder.AddConsole(); }); - services.AddNatsTestServices(natsConnectionString); + services.AddNatsTestClient(natsConnectionString); + + // Track registered singleton services before building the service provider + foreach (var descriptor in services) + { + _registeredServiceTypes[descriptor.ServiceType] = descriptor.Lifetime; + } + _serviceProvider = services.BuildServiceProvider(); // create the KV store @@ -65,7 +74,28 @@ await kvContext.CreateOrUpdateStoreAsync( /// Configures the services with the NATS connection /// /// The service collection to configure - public void ConfigureServices(IServiceCollection services) => services.AddSingleton(NatsConnection); + public void ConfigureServices(IServiceCollection services) + { + if (_serviceProvider == null) + { + throw new InvalidOperationException("InitializeAsync must be called before ConfigureServices"); + } + + // Register all singleton services from our service provider + // Filter out open generic types only + foreach (var serviceType in _registeredServiceTypes + .Where(t => t.Value == ServiceLifetime.Singleton) + .Select(t => t.Key) + .Where(type => !type.IsGenericTypeDefinition)) + { + // Get the service instance from our provider and register it with the provided services + var instance = _serviceProvider.GetService(serviceType); + if (instance != null) + { + services.AddSingleton(serviceType, instance); + } + } + } /// /// Disposes the fixture by shutting down the NATS server @@ -77,6 +107,11 @@ public async ValueTask DisposeAsync() return; } + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + if (_app != null) { var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); @@ -84,11 +119,6 @@ public async ValueTask DisposeAsync() await _app.DisposeAsync(); } - if (_serviceProvider != null) - { - await _serviceProvider.DisposeAsync(); - } - GC.SuppressFinalize(this); } } diff --git a/test/IntegrationTests/TestBase.cs b/test/IntegrationTests/TestBase.cs index 28c51ca..2394dbf 100644 --- a/test/IntegrationTests/TestBase.cs +++ b/test/IntegrationTests/TestBase.cs @@ -1,13 +1,18 @@ using CodeCargo.NatsDistributedCache.TestUtils.Services.Logging; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NATS.Client.Core; +using NATS.Client.JetStream.Models; +using NATS.Client.KeyValueStore; +using NATS.Net; namespace CodeCargo.NatsDistributedCache.IntegrationTests; /// /// Base class for NATS integration tests that provides test output logging and fixture access /// -public abstract class TestBase : IAsyncDisposable +[Collection(NatsCollection.Name)] +public abstract class TestBase : IAsyncLifetime { private int _disposed; @@ -19,7 +24,8 @@ protected TestBase(NatsIntegrationFixture fixture) { // Get the test output helper from the current test context var testContext = TestContext.Current; - var output = testContext.TestOutputHelper ?? throw new InvalidOperationException("TestOutputHelper was not available in the current test context"); + var output = testContext.TestOutputHelper ?? + throw new InvalidOperationException("TestOutputHelper was not available in the current test context"); // Create a service collection and configure logging var services = new ServiceCollection(); @@ -47,7 +53,15 @@ protected TestBase(NatsIntegrationFixture fixture) protected INatsConnection NatsConnection => ServiceProvider.GetRequiredService(); /// - /// Cleanup after the test + /// Purge stream before test run + /// + public virtual async ValueTask InitializeAsync() => + await NatsConnection + .CreateJetStreamContext() + .PurgeStreamAsync("KV_cache", new StreamPurgeRequest(), TestContext.Current.CancellationToken); + + /// + /// Dispose /// public virtual async ValueTask DisposeAsync() { diff --git a/test/IntegrationTests/packages.linux-x64.lock.json b/test/IntegrationTests/packages.linux-x64.lock.json index 12b8e06..ac4796a 100644 --- a/test/IntegrationTests/packages.linux-x64.lock.json +++ b/test/IntegrationTests/packages.linux-x64.lock.json @@ -1066,7 +1066,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.linux-x64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.linux-x64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.linux-x64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { diff --git a/test/IntegrationTests/packages.osx-arm64.lock.json b/test/IntegrationTests/packages.osx-arm64.lock.json index 7bdc2d8..e536f38 100644 --- a/test/IntegrationTests/packages.osx-arm64.lock.json +++ b/test/IntegrationTests/packages.osx-arm64.lock.json @@ -1066,7 +1066,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.osx-arm64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.osx-arm64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.osx-arm64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { diff --git a/test/IntegrationTests/packages.win-x64.lock.json b/test/IntegrationTests/packages.win-x64.lock.json index 6f377cb..b33a4d2 100644 --- a/test/IntegrationTests/packages.win-x64.lock.json +++ b/test/IntegrationTests/packages.win-x64.lock.json @@ -1066,7 +1066,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.win-x64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.win-x64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.win-x64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { diff --git a/test/TestUtils/NatsTestExtensions.cs b/test/TestUtils/NatsTestExtensions.cs index 58e1bcc..4d3ca3e 100644 --- a/test/TestUtils/NatsTestExtensions.cs +++ b/test/TestUtils/NatsTestExtensions.cs @@ -6,7 +6,7 @@ namespace CodeCargo.NatsDistributedCache.TestUtils; public static class NatsTestExtensions { - public static IServiceCollection AddNatsTestServices(this IServiceCollection services, string natsConnectionString) => + public static IServiceCollection AddNatsTestClient(this IServiceCollection services, string natsConnectionString) => services.AddNats(configureOpts: options => options with { diff --git a/util/NatsAppHost/NatsAppHost.csproj b/util/NatsAppHost/NatsAppHost.csproj index bccee80..0ae9fc0 100644 --- a/util/NatsAppHost/NatsAppHost.csproj +++ b/util/NatsAppHost/NatsAppHost.csproj @@ -9,7 +9,7 @@ - + diff --git a/util/NatsAppHost/packages.linux-x64.lock.json b/util/NatsAppHost/packages.linux-x64.lock.json index 7557327..2d38308 100644 --- a/util/NatsAppHost/packages.linux-x64.lock.json +++ b/util/NatsAppHost/packages.linux-x64.lock.json @@ -236,6 +236,14 @@ "resolved": "8.0.0", "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "imcZ5BGhBw5mNsWLepBbqqumWaFe0GtvyCvne2/2wsDIBRa2+Lhx4cU/pKt/4BwOizzUEOls2k1eOJQXHGMalg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "8.0.0", @@ -323,8 +331,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", @@ -450,10 +458,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -519,11 +528,11 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -540,8 +549,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -590,6 +599,33 @@ "resolved": "17.8.8", "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" }, + "NATS.Client.Core": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "6mh+fSB9Fx6QfbYRjvswbWDgD04ePBYzB2yIY5GqzyUiUboqxpuOUDO2QOTNkwIqi8IpTMKcySAY+V/DvmvtQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.1", + "Microsoft.Extensions.Primitives": "6.0.0", + "System.IO.Pipelines": "8.0.0", + "System.Text.Json": "8.0.5" + } + }, + "NATS.Client.JetStream": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "3t5UJyN6nnsrDxWBxklNK06rnNL9aEjnzcOHCXRcZpcfFGeWFm4dG5UXtt7WiW69uWY4MHUlOcbuBKh5/rAuSQ==", + "dependencies": { + "NATS.Client.Core": "2.6.0" + } + }, + "NATS.Client.KeyValueStore": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "/us915HAGYdGAdblH2857w3xapZupmU4vNDODMfysgcg/tLineOtQ2+FqcSrmCVJ6mILngeuHGTk5vghunTpog==", + "dependencies": { + "NATS.Client.JetStream": "2.6.0" + } + }, "Nerdbank.Streams": { "type": "Transitive", "resolved": "2.11.90", @@ -631,6 +667,11 @@ "resolved": "1.2.0.556", "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "8.0.1", @@ -660,10 +701,24 @@ "resolved": "6.1.0", "contentHash": "5o/HZxx6RVqYlhKSq8/zronDkALJZUT2Vz0hx43f0gwe8mwlM0y2nYlqdBwLMzr262Bwvpikeb/yEwkAa5PADg==" }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, "YamlDotNet": { "type": "Transitive", "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" + }, + "codecargo.natsdistributedcache": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Logging.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Options": "[9.0.4, )", + "NATS.Client.KeyValueStore": "[2.6.0, )" + } } } } diff --git a/util/NatsAppHost/packages.osx-arm64.lock.json b/util/NatsAppHost/packages.osx-arm64.lock.json index 8a9d389..3d56d8f 100644 --- a/util/NatsAppHost/packages.osx-arm64.lock.json +++ b/util/NatsAppHost/packages.osx-arm64.lock.json @@ -236,6 +236,14 @@ "resolved": "8.0.0", "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "imcZ5BGhBw5mNsWLepBbqqumWaFe0GtvyCvne2/2wsDIBRa2+Lhx4cU/pKt/4BwOizzUEOls2k1eOJQXHGMalg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "8.0.0", @@ -323,8 +331,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", @@ -450,10 +458,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -519,11 +528,11 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -540,8 +549,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -590,6 +599,33 @@ "resolved": "17.8.8", "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" }, + "NATS.Client.Core": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "6mh+fSB9Fx6QfbYRjvswbWDgD04ePBYzB2yIY5GqzyUiUboqxpuOUDO2QOTNkwIqi8IpTMKcySAY+V/DvmvtQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.1", + "Microsoft.Extensions.Primitives": "6.0.0", + "System.IO.Pipelines": "8.0.0", + "System.Text.Json": "8.0.5" + } + }, + "NATS.Client.JetStream": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "3t5UJyN6nnsrDxWBxklNK06rnNL9aEjnzcOHCXRcZpcfFGeWFm4dG5UXtt7WiW69uWY4MHUlOcbuBKh5/rAuSQ==", + "dependencies": { + "NATS.Client.Core": "2.6.0" + } + }, + "NATS.Client.KeyValueStore": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "/us915HAGYdGAdblH2857w3xapZupmU4vNDODMfysgcg/tLineOtQ2+FqcSrmCVJ6mILngeuHGTk5vghunTpog==", + "dependencies": { + "NATS.Client.JetStream": "2.6.0" + } + }, "Nerdbank.Streams": { "type": "Transitive", "resolved": "2.11.90", @@ -631,6 +667,11 @@ "resolved": "1.2.0.556", "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "8.0.1", @@ -660,10 +701,24 @@ "resolved": "6.1.0", "contentHash": "5o/HZxx6RVqYlhKSq8/zronDkALJZUT2Vz0hx43f0gwe8mwlM0y2nYlqdBwLMzr262Bwvpikeb/yEwkAa5PADg==" }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, "YamlDotNet": { "type": "Transitive", "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" + }, + "codecargo.natsdistributedcache": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Logging.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Options": "[9.0.4, )", + "NATS.Client.KeyValueStore": "[2.6.0, )" + } } } } diff --git a/util/NatsAppHost/packages.win-x64.lock.json b/util/NatsAppHost/packages.win-x64.lock.json index eb63d59..b6bd3e7 100644 --- a/util/NatsAppHost/packages.win-x64.lock.json +++ b/util/NatsAppHost/packages.win-x64.lock.json @@ -236,6 +236,14 @@ "resolved": "8.0.0", "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "imcZ5BGhBw5mNsWLepBbqqumWaFe0GtvyCvne2/2wsDIBRa2+Lhx4cU/pKt/4BwOizzUEOls2k1eOJQXHGMalg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "8.0.0", @@ -323,8 +331,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", @@ -450,10 +458,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -519,11 +528,11 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -540,8 +549,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -590,6 +599,33 @@ "resolved": "17.8.8", "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" }, + "NATS.Client.Core": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "6mh+fSB9Fx6QfbYRjvswbWDgD04ePBYzB2yIY5GqzyUiUboqxpuOUDO2QOTNkwIqi8IpTMKcySAY+V/DvmvtQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.1", + "Microsoft.Extensions.Primitives": "6.0.0", + "System.IO.Pipelines": "8.0.0", + "System.Text.Json": "8.0.5" + } + }, + "NATS.Client.JetStream": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "3t5UJyN6nnsrDxWBxklNK06rnNL9aEjnzcOHCXRcZpcfFGeWFm4dG5UXtt7WiW69uWY4MHUlOcbuBKh5/rAuSQ==", + "dependencies": { + "NATS.Client.Core": "2.6.0" + } + }, + "NATS.Client.KeyValueStore": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "/us915HAGYdGAdblH2857w3xapZupmU4vNDODMfysgcg/tLineOtQ2+FqcSrmCVJ6mILngeuHGTk5vghunTpog==", + "dependencies": { + "NATS.Client.JetStream": "2.6.0" + } + }, "Nerdbank.Streams": { "type": "Transitive", "resolved": "2.11.90", @@ -631,6 +667,11 @@ "resolved": "1.2.0.556", "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "8.0.1", @@ -660,10 +701,24 @@ "resolved": "6.1.0", "contentHash": "5o/HZxx6RVqYlhKSq8/zronDkALJZUT2Vz0hx43f0gwe8mwlM0y2nYlqdBwLMzr262Bwvpikeb/yEwkAa5PADg==" }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, "YamlDotNet": { "type": "Transitive", "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" + }, + "codecargo.natsdistributedcache": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Logging.Abstractions": "[9.0.4, )", + "Microsoft.Extensions.Options": "[9.0.4, )", + "NATS.Client.KeyValueStore": "[2.6.0, )" + } } } } diff --git a/util/PerfTest/Program.cs b/util/PerfTest/Program.cs index d21fac8..28b5643 100644 --- a/util/PerfTest/Program.cs +++ b/util/PerfTest/Program.cs @@ -39,7 +39,7 @@ // Add services directly to the builder builder.ConfigureServices(services => { - services.AddNatsTestServices(natsConnectionString); + services.AddNatsTestClient(natsConnectionString); services.AddScoped(); }); diff --git a/util/PerfTest/packages.linux-x64.lock.json b/util/PerfTest/packages.linux-x64.lock.json index 1a35dfe..92eb4d9 100644 --- a/util/PerfTest/packages.linux-x64.lock.json +++ b/util/PerfTest/packages.linux-x64.lock.json @@ -958,7 +958,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.linux-x64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.linux-x64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.linux-x64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { diff --git a/util/PerfTest/packages.osx-arm64.lock.json b/util/PerfTest/packages.osx-arm64.lock.json index a2b9378..ef89a8b 100644 --- a/util/PerfTest/packages.osx-arm64.lock.json +++ b/util/PerfTest/packages.osx-arm64.lock.json @@ -958,7 +958,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.osx-arm64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.osx-arm64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.osx-arm64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { diff --git a/util/PerfTest/packages.win-x64.lock.json b/util/PerfTest/packages.win-x64.lock.json index c776a8d..a5570e6 100644 --- a/util/PerfTest/packages.win-x64.lock.json +++ b/util/PerfTest/packages.win-x64.lock.json @@ -958,7 +958,8 @@ "dependencies": { "Aspire.Dashboard.Sdk.win-x64": "[9.2.1, )", "Aspire.Hosting.AppHost": "[9.2.1, )", - "Aspire.Hosting.Orchestration.win-x64": "[9.2.1, )" + "Aspire.Hosting.Orchestration.win-x64": "[9.2.1, )", + "CodeCargo.NatsDistributedCache": "[1.0.0, )" } }, "testutils": { From ae961deb9e47e72eaccf4eb68a360ae096d918e6 Mon Sep 17 00:00:00 2001 From: Caleb Lloyd Date: Sat, 10 May 2025 22:56:49 -0400 Subject: [PATCH 2/4] refine service options Signed-off-by: Caleb Lloyd --- .../NatsCache.Log.cs | 37 ++-- .../NatsCache.cs | 160 ++++++++++-------- .../NatsCacheImpl.cs | 22 +-- .../NatsCacheOptions.cs | 20 ++- .../NatsCacheServiceCollectionExtensions.cs | 20 +-- .../NatsHybridCacheSerializer.cs | 34 ++++ .../NatsCacheSetAndRemoveTests.cs | 7 +- test/IntegrationTests/TestBase.cs | 2 - .../TimeExpirationAsyncTests.cs | 34 +--- test/IntegrationTests/TimeExpirationTests.cs | 20 +-- .../CacheServiceExtensionsUnitTests.cs | 6 +- 11 files changed, 180 insertions(+), 182 deletions(-) create mode 100644 src/CodeCargo.NatsDistributedCache/NatsHybridCacheSerializer.cs diff --git a/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs b/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs index 6560393..cb6ccf3 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs @@ -1,43 +1,32 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - using Microsoft.Extensions.Logging; namespace CodeCargo.NatsDistributedCache; public partial class NatsCache { - private void LogException(Exception exception) - { + private void LogException(Exception exception) => _logger.LogError(EventIds.Exception, exception, "An exception occurred in NATS KV store."); - } - private void LogConnectionError(Exception exception) - { + private void LogConnectionError(Exception exception) => _logger.LogError(EventIds.ConnectionError, exception, "Error connecting to NATS KV store."); - } - private void LogConnectionIssue() - { + private void LogConnectionIssue() => _logger.LogWarning(EventIds.ConnectionIssue, "Connection issue with NATS KV store."); - } - private void LogConnected() - { + private void LogConnected() => _logger.LogInformation(EventIds.Connected, "Connected to NATS KV store."); - } - private void LogUpdateFailed(string key) - { - _logger.LogDebug(EventIds.UpdateFailed, "Sliding expiration update failed for key {Key} due to optimistic concurrency control", key); - } + private void LogUpdateFailed(string key) => _logger.LogDebug( + EventIds.UpdateFailed, + "Sliding expiration update failed for key {Key} due to optimistic concurrency control", + key); private static class EventIds { - public static readonly EventId ConnectionIssue = new EventId(100, nameof(ConnectionIssue)); - public static readonly EventId ConnectionError = new EventId(101, nameof(ConnectionError)); - public static readonly EventId Connected = new EventId(102, nameof(Connected)); - public static readonly EventId UpdateFailed = new EventId(103, nameof(UpdateFailed)); - public static readonly EventId Exception = new EventId(104, nameof(Exception)); + public static readonly EventId ConnectionIssue = new(100, nameof(ConnectionIssue)); + public static readonly EventId ConnectionError = new(101, nameof(ConnectionError)); + public static readonly EventId Connected = new(102, nameof(Connected)); + public static readonly EventId UpdateFailed = new(103, nameof(UpdateFailed)); + public static readonly EventId Exception = new(104, nameof(Exception)); } } diff --git a/src/CodeCargo.NatsDistributedCache/NatsCache.cs b/src/CodeCargo.NatsDistributedCache/NatsCache.cs index 8afbab7..4807ad1 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCache.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCache.cs @@ -1,6 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - using System.Buffers; using System.Text.Json.Serialization; using Microsoft.Extensions.Caching.Distributed; @@ -19,10 +16,10 @@ namespace CodeCargo.NatsDistributedCache; public class CacheEntry { [JsonPropertyName("absexp")] - public string? AbsoluteExpiration { get; set; } + public DateTimeOffset? AbsoluteExpiration { get; set; } [JsonPropertyName("sldexp")] - public string? SlidingExpiration { get; set; } + public long? SlidingExpirationTicks { get; set; } [JsonPropertyName("data")] public byte[]? Data { get; set; } @@ -34,12 +31,12 @@ public class CacheEntry public partial class NatsCache : IBufferDistributedCache, IDisposable { // Static JSON serializer for CacheEntry - private static readonly NatsJsonContextSerializer _cacheEntrySerializer = - new NatsJsonContextSerializer(CacheEntryJsonContext.Default); + private static readonly NatsJsonContextSerializer CacheEntrySerializer = + new(CacheEntryJsonContext.Default); private readonly ILogger _logger; private readonly NatsCacheOptions _options; - private readonly string _instanceName; + private readonly string _keyPrefix; private readonly INatsConnection _natsConnection; private readonly SemaphoreSlim _connectionLock = new(initialCount: 1, maxCount: 1); private NatsKVStore? _kvStore; @@ -56,9 +53,7 @@ public NatsCache( _options = optionsAccessor.Value; _logger = logger; _natsConnection = natsConnection; - _instanceName = _options.InstanceName ?? string.Empty; - - // No need to connect immediately; will connect on-demand + _keyPrefix = string.IsNullOrEmpty(_options.CacheKeyPrefix) ? string.Empty : _options.CacheKeyPrefix.TrimEnd('.'); } public NatsCache(IOptions optionsAccessor, INatsConnection natsConnection) @@ -67,10 +62,15 @@ public NatsCache(IOptions optionsAccessor, INatsConnection nat } /// - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => SetAsync(key, value, options).GetAwaiter().GetResult(); + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => + SetAsync(key, value, options).GetAwaiter().GetResult(); /// - public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + public async Task SetAsync( + string key, + byte[] value, + DistributedCacheEntryOptions options, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(value); @@ -79,11 +79,12 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption var ttl = GetTtl(options); var entry = CreateCacheEntry(value, options); - var kvStore = await GetKVStore().ConfigureAwait(false); + var kvStore = await GetKvStore().ConfigureAwait(false); try { - await kvStore.PutAsync(GetKeyPrefix(key), entry, ttl ?? default, _cacheEntrySerializer, token).ConfigureAwait(false); + await kvStore.PutAsync(GetPrefixedKey(key), entry, ttl ?? TimeSpan.Zero, CacheEntrySerializer, token) + .ConfigureAwait(false); } catch (Exception ex) { @@ -93,10 +94,15 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption } /// - public void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) => SetAsync(key, value, options).GetAwaiter().GetResult(); + public void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) => + SetAsync(key, value, options).AsTask().GetAwaiter().GetResult(); /// - public async ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token = default) + public async ValueTask SetAsync( + string key, + ReadOnlySequence value, + DistributedCacheEntryOptions options, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(options); @@ -107,26 +113,11 @@ public async ValueTask SetAsync(string key, ReadOnlySequence value, Distri } /// - public void Remove(string key) => RemoveAsync(key, null, default).GetAwaiter().GetResult(); + public void Remove(string key) => RemoveAsync(key, null).GetAwaiter().GetResult(); /// - public async Task RemoveAsync(string key, CancellationToken token = default) => await RemoveAsync(key, null, token).ConfigureAwait(false); - - /// - /// Removes the value with the given key. - /// - /// A string identifying the requested value. - /// Nats KV delete options - /// Optional. The used to propagate notifications that the operation should be canceled. - /// The that represents the asynchronous operation. - public async Task RemoveAsync(string key, NatsKVDeleteOpts? natsKVDeleteOpts = null, CancellationToken token = default) - { - ArgumentNullException.ThrowIfNull(key); - token.ThrowIfCancellationRequested(); - - var kvStore = await GetKVStore().ConfigureAwait(false); - await kvStore.DeleteAsync(GetKeyPrefix(key), natsKVDeleteOpts, cancellationToken: token).ConfigureAwait(false); - } + public async Task RemoveAsync(string key, CancellationToken token = default) => + await RemoveAsync(key, null, token).ConfigureAwait(false); /// public void Refresh(string key) => RefreshAsync(key).GetAwaiter().GetResult(); @@ -153,10 +144,14 @@ public async Task RefreshAsync(string key, CancellationToken token = default) } /// - public bool TryGet(string key, IBufferWriter destination) => TryGetAsync(key, destination).GetAwaiter().GetResult(); + public bool TryGet(string key, IBufferWriter destination) => + TryGetAsync(key, destination).AsTask().GetAwaiter().GetResult(); /// - public async ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default) + public async ValueTask TryGetAsync( + string key, + IBufferWriter destination, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(destination); @@ -266,26 +261,21 @@ private static CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOp absoluteExpiration = DateTimeOffset.Now.Add(options.AbsoluteExpirationRelativeToNow.Value); } - var cacheEntry = new CacheEntry { Data = value }; - - if (absoluteExpiration.HasValue) - { - cacheEntry.AbsoluteExpiration = absoluteExpiration.Value.ToUnixTimeMilliseconds().ToString(); - } - - if (options.SlidingExpiration.HasValue) + var cacheEntry = new CacheEntry { - cacheEntry.SlidingExpiration = options.SlidingExpiration.Value.TotalMilliseconds.ToString(); - } + Data = value, + AbsoluteExpiration = absoluteExpiration, + SlidingExpirationTicks = options.SlidingExpiration?.Ticks + }; return cacheEntry; } - private string GetKeyPrefix(string key) => string.IsNullOrEmpty(_instanceName) - ? key - : _instanceName + ":" + key; + private string GetPrefixedKey(string key) => string.IsNullOrEmpty(_keyPrefix) + ? key + : _keyPrefix + "." + key; - private async Task GetKVStore() + private async Task GetKvStore() { if (_kvStore != null && !_disposed) { @@ -327,15 +317,21 @@ private async Task GetKVStore() return _kvStore; } - private async Task GetAndRefreshAsync(string key, bool getData, bool retry, CancellationToken token = default) + private async Task GetAndRefreshAsync( + string key, + bool getData, + bool retry, + CancellationToken token = default) { token.ThrowIfCancellationRequested(); - var kvStore = await GetKVStore().ConfigureAwait(false); - var prefixedKey = GetKeyPrefix(key); + var kvStore = await GetKvStore().ConfigureAwait(false); + var prefixedKey = GetPrefixedKey(key); try { - var natsResult = await kvStore.TryGetEntryAsync(prefixedKey, serializer: _cacheEntrySerializer, cancellationToken: token).ConfigureAwait(false); + var natsResult = await kvStore + .TryGetEntryAsync(prefixedKey, serializer: CacheEntrySerializer, cancellationToken: token) + .ConfigureAwait(false); if (!natsResult.Success) { return null; @@ -348,8 +344,7 @@ private async Task GetKVStore() } // Check absolute expiration - if (kvEntry.Value.AbsoluteExpiration != null - && DateTimeOffset.Now > DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(kvEntry.Value.AbsoluteExpiration))) + if (kvEntry.Value.AbsoluteExpiration != null && DateTimeOffset.Now > kvEntry.Value.AbsoluteExpiration) { // NatsKVWrongLastRevisionException is caught below var natsDeleteOpts = new NatsKVDeleteOpts { Revision = kvEntry.Revision }; @@ -368,11 +363,9 @@ private async Task GetKVStore() { return await GetAndRefreshAsync(key, getData, retry: false, token).ConfigureAwait(false); } - else - { - LogException(ex); - return null; - } + + LogException(ex); + return null; } catch (Exception ex) { @@ -381,22 +374,24 @@ private async Task GetKVStore() } } - private async Task UpdateEntryExpirationAsync(NatsKVStore kvStore, string key, NatsKVEntry kvEntry, CancellationToken token) + private async Task UpdateEntryExpirationAsync( + NatsKVStore kvStore, + string key, + NatsKVEntry kvEntry, + CancellationToken token) { - if (kvEntry.Value?.SlidingExpiration == null) + if (kvEntry.Value?.SlidingExpirationTicks == null) { return; } // If we have a sliding expiration, use it as the TTL - var ttl = TimeSpan.FromMilliseconds(long.Parse(kvEntry.Value.SlidingExpiration)); + var ttl = TimeSpan.FromTicks(kvEntry.Value.SlidingExpirationTicks.Value); // If we also have an absolute expiration, make sure we don't exceed it if (kvEntry.Value.AbsoluteExpiration != null) { - var absoluteExpiration = - DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(kvEntry.Value.AbsoluteExpiration)); - var remainingTime = absoluteExpiration - DateTimeOffset.Now; + var remainingTime = kvEntry.Value.AbsoluteExpiration.Value - DateTimeOffset.Now; // Use the minimum of sliding window or remaining absolute time if (remainingTime > TimeSpan.Zero && remainingTime < ttl) @@ -410,15 +405,40 @@ private async Task UpdateEntryExpirationAsync(NatsKVStore kvStore, string key, N // Use optimistic concurrency control with the last revision try { - await kvStore.UpdateAsync(key, kvEntry.Value, kvEntry.Revision, ttl, serializer: _cacheEntrySerializer, cancellationToken: token).ConfigureAwait(false); + await kvStore.UpdateAsync( + key, + kvEntry.Value, + kvEntry.Revision, + ttl, + serializer: CacheEntrySerializer, + cancellationToken: token).ConfigureAwait(false); } catch (NatsKVWrongLastRevisionException) { // Someone else updated it; that's fine, we'll get the latest version next time - LogUpdateFailed(key.Replace(GetKeyPrefix(string.Empty), string.Empty)); + LogUpdateFailed(key.Replace(GetPrefixedKey(string.Empty), string.Empty)); } } } + + /// + /// Removes the value with the given key. + /// + /// A string identifying the requested value. + /// Nats KV delete options + /// Optional. The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + private async Task RemoveAsync( + string key, + NatsKVDeleteOpts? natsKvDeleteOpts = null, + CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(key); + token.ThrowIfCancellationRequested(); + + var kvStore = await GetKvStore().ConfigureAwait(false); + await kvStore.DeleteAsync(GetPrefixedKey(key), natsKvDeleteOpts, cancellationToken: token).ConfigureAwait(false); + } } [JsonSerializable(typeof(CacheEntry))] diff --git a/src/CodeCargo.NatsDistributedCache/NatsCacheImpl.cs b/src/CodeCargo.NatsDistributedCache/NatsCacheImpl.cs index ec1d3d5..6c8233a 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCacheImpl.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCacheImpl.cs @@ -1,6 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,17 +10,20 @@ internal sealed class NatsCacheImpl : NatsCache { private readonly IServiceProvider _services; - public NatsCacheImpl(IOptions optionsAccessor, ILogger logger, IServiceProvider services, INatsConnection natsConnection) - : base(optionsAccessor, logger, natsConnection) - { + public NatsCacheImpl( + IOptions optionsAccessor, + ILogger logger, + IServiceProvider services, + INatsConnection natsConnection) + : base(optionsAccessor, logger, natsConnection) => _services = services; // important: do not check for HybridCache here due to dependency - creates a cycle - } - public NatsCacheImpl(IOptions optionsAccessor, IServiceProvider services, INatsConnection natsConnection) - : base(optionsAccessor, natsConnection) - { + public NatsCacheImpl( + IOptions optionsAccessor, + IServiceProvider services, + INatsConnection natsConnection) + : base(optionsAccessor, natsConnection) => _services = services; // important: do not check for HybridCache here due to dependency - creates a cycle - } internal override bool IsHybridCacheActive() => _services.GetService() is not null; diff --git a/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs b/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs index d481be9..244112f 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs @@ -1,6 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - using Microsoft.Extensions.Options; namespace CodeCargo.NatsDistributedCache @@ -16,10 +13,21 @@ public class NatsCacheOptions : IOptions public string? BucketName { get; set; } /// - /// The NATS instance name. Allows partitioning a single backend cache for use with multiple apps/services. - /// If set, the cache keys are prefixed with this value. + /// If set, all cache keys are prefixed with this value followed by a period. + /// Allows partitioning a single backend cache for use with multiple apps/services. + /// + public string? CacheKeyPrefix { get; set; } + + /// + /// If set, attempt to retrieve the INatsConnection as a keyed service from the service provider. + /// + public string? ConnectionServiceKey { get; set; } + + /// + /// When true (the default) register a as an + /// IHybridCacheSerializerFactory that uses the NATS Connection's Serializer Registry. /// - public string? InstanceName { get; set; } + public bool RegisterHybridCacheSerializerFactory { get; set; } = true; NatsCacheOptions IOptions.Value => this; } diff --git a/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs b/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs index 0ed59e0..e96fe18 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs @@ -1,6 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -21,7 +18,9 @@ public static class NatsCacheServiceCollectionExtensions /// An to configure the provided /// . /// The so that additional calls can be chained. - public static IServiceCollection AddNatsDistributedCache(this IServiceCollection services, Action setupAction) + public static IServiceCollection AddNatsDistributedCache( + this IServiceCollection services, + Action setupAction) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(setupAction); @@ -32,14 +31,15 @@ public static IServiceCollection AddNatsDistributedCache(this IServiceCollection { var optionsAccessor = serviceProvider.GetRequiredService>(); var logger = serviceProvider.GetService>(); - var natsConnection = serviceProvider.GetRequiredService(); - if (logger != null) - { - return new NatsCacheImpl(optionsAccessor, logger, serviceProvider, natsConnection); - } + var serviceKey = optionsAccessor.Value.ConnectionServiceKey; + var natsConnection = string.IsNullOrEmpty(serviceKey) + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey); - return new NatsCacheImpl(optionsAccessor, serviceProvider, natsConnection); + return logger != null + ? new NatsCacheImpl(optionsAccessor, logger, serviceProvider, natsConnection) + : new NatsCacheImpl(optionsAccessor, serviceProvider, natsConnection); })); return services; diff --git a/src/CodeCargo.NatsDistributedCache/NatsHybridCacheSerializer.cs b/src/CodeCargo.NatsDistributedCache/NatsHybridCacheSerializer.cs new file mode 100644 index 0000000..0bc9010 --- /dev/null +++ b/src/CodeCargo.NatsDistributedCache/NatsHybridCacheSerializer.cs @@ -0,0 +1,34 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Hybrid; +using NATS.Client.Core; + +namespace CodeCargo.NatsDistributedCache; + +public readonly struct NatsHybridCacheSerializer(INatsSerialize serializer, INatsDeserialize deserializer) + : IHybridCacheSerializer +{ + public T Deserialize(ReadOnlySequence source) => deserializer.Deserialize(source)!; + + public void Serialize(T value, IBufferWriter target) => serializer.Serialize(target, value); +} + +public readonly struct NatsHybridCacheSerializerFactory(INatsSerializerRegistry serializerRegistry) + : IHybridCacheSerializerFactory +{ + public bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer) + { + try + { + var natsSerializer = serializerRegistry.GetSerializer(); + var natsDeserializer = serializerRegistry.GetDeserializer(); + serializer = new NatsHybridCacheSerializer(natsSerializer, natsDeserializer); + return true; + } + catch (Exception) + { + serializer = null; + return false; + } + } +} diff --git a/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs b/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs index 3132767..12eb1e9 100644 --- a/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs +++ b/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs @@ -5,13 +5,8 @@ namespace CodeCargo.NatsDistributedCache.IntegrationTests; [Collection(NatsCollection.Name)] -public class NatsCacheSetAndRemoveTests : TestBase +public class NatsCacheSetAndRemoveTests(NatsIntegrationFixture fixture) : TestBase(fixture) { - public NatsCacheSetAndRemoveTests(NatsIntegrationFixture fixture) - : base(fixture) - { - } - [Fact] public void GetMissingKeyReturnsNull() { diff --git a/test/IntegrationTests/TestBase.cs b/test/IntegrationTests/TestBase.cs index 2394dbf..053a9e0 100644 --- a/test/IntegrationTests/TestBase.cs +++ b/test/IntegrationTests/TestBase.cs @@ -1,9 +1,7 @@ using CodeCargo.NatsDistributedCache.TestUtils.Services.Logging; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.JetStream.Models; -using NATS.Client.KeyValueStore; using NATS.Net; namespace CodeCargo.NatsDistributedCache.IntegrationTests; diff --git a/test/IntegrationTests/TimeExpirationAsyncTests.cs b/test/IntegrationTests/TimeExpirationAsyncTests.cs index e6c455f..ac63acb 100644 --- a/test/IntegrationTests/TimeExpirationAsyncTests.cs +++ b/test/IntegrationTests/TimeExpirationAsyncTests.cs @@ -4,14 +4,8 @@ namespace CodeCargo.NatsDistributedCache.IntegrationTests; [Collection(NatsCollection.Name)] -public class TimeExpirationAsyncTests : TestBase +public class TimeExpirationAsyncTests(NatsIntegrationFixture fixture) : TestBase(fixture) { - public TimeExpirationAsyncTests(NatsIntegrationFixture fixture) - : base(fixture) - { - } - - // AbsoluteExpirationInThePastThrowsAsync test moved to UnitTests/TimeExpirationAsyncUnitTests.cs [Fact] public async Task AbsoluteExpirationExpiresAsync() { @@ -33,9 +27,6 @@ public async Task AbsoluteExpirationExpiresAsync() Assert.Null(result); } - // NegativeRelativeExpirationThrowsAsync test moved to UnitTests/TimeExpirationAsyncUnitTests.cs - - // ZeroRelativeExpirationThrowsAsync test moved to UnitTests/TimeExpirationAsyncUnitTests.cs [Fact] public async Task RelativeExpirationExpiresAsync() { @@ -57,9 +48,6 @@ public async Task RelativeExpirationExpiresAsync() Assert.Null(result); } - // NegativeSlidingExpirationThrowsAsync test moved to UnitTests/TimeExpirationAsyncUnitTests.cs - - // ZeroSlidingExpirationThrowsAsync test moved to UnitTests/TimeExpirationAsyncUnitTests.cs [Fact] public async Task SlidingExpirationExpiresIfNotAccessedAsync() { @@ -144,26 +132,6 @@ private static async Task GetNameAndReset(IDistributedCache cache, [Call return caller; } - // async twin to ExceptionAssert.ThrowsArgumentOutOfRange - private static async Task ThrowsArgumentOutOfRangeAsync(Func test, string paramName, string message, object actualValue) - { - var ex = await Assert.ThrowsAsync(test); - if (paramName is not null) - { - Assert.Equal(paramName, ex.ParamName); - } - - if (message is not null) - { - Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc - } - - if (actualValue is not null) - { - Assert.Equal(actualValue, ex.ActualValue); - } - } - private IDistributedCache CreateCacheInstance() { return new NatsCache( diff --git a/test/IntegrationTests/TimeExpirationTests.cs b/test/IntegrationTests/TimeExpirationTests.cs index 16e8cc9..3532639 100644 --- a/test/IntegrationTests/TimeExpirationTests.cs +++ b/test/IntegrationTests/TimeExpirationTests.cs @@ -4,14 +4,8 @@ namespace CodeCargo.NatsDistributedCache.IntegrationTests; [Collection(NatsCollection.Name)] -public class TimeExpirationTests : TestBase +public class TimeExpirationTests(NatsIntegrationFixture fixture) : TestBase(fixture) { - public TimeExpirationTests(NatsIntegrationFixture fixture) - : base(fixture) - { - } - - // AbsoluteExpirationInThePastThrows test moved to UnitTests/TimeExpirationUnitTests.cs [Fact] public void AbsoluteExpirationExpires() { @@ -33,9 +27,6 @@ public void AbsoluteExpirationExpires() Assert.Null(result); } - // NegativeRelativeExpirationThrows test moved to UnitTests/TimeExpirationUnitTests.cs - - // ZeroRelativeExpirationThrows test moved to UnitTests/TimeExpirationUnitTests.cs [Fact] public void RelativeExpirationExpires() { @@ -57,9 +48,6 @@ public void RelativeExpirationExpires() Assert.Null(result); } - // NegativeSlidingExpirationThrows test moved to UnitTests/TimeExpirationUnitTests.cs - - // ZeroSlidingExpirationThrows test moved to UnitTests/TimeExpirationUnitTests.cs [Fact] public void SlidingExpirationExpiresIfNotAccessed() { @@ -144,13 +132,11 @@ private static string GetNameAndReset(IDistributedCache cache, [CallerMemberName return caller; } - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( + private IDistributedCache CreateCacheInstance() => + new NatsCache( Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions { BucketName = "cache" }), NatsConnection); - } } diff --git a/test/UnitTests/CacheServiceExtensionsUnitTests.cs b/test/UnitTests/CacheServiceExtensionsUnitTests.cs index aaeba90..23f4265 100644 --- a/test/UnitTests/CacheServiceExtensionsUnitTests.cs +++ b/test/UnitTests/CacheServiceExtensionsUnitTests.cs @@ -64,13 +64,13 @@ public void AddNatsCache_SetsCacheOptions() // Arrange var services = new ServiceCollection(); services.AddSingleton(_mockNatsConnection.Object); - var expectedInstanceName = "TestInstance"; + var expectedNamespace = "TestNamespace"; // Act services.AddNatsDistributedCache(options => { options.BucketName = "cache"; - options.InstanceName = expectedInstanceName; + options.CacheKeyPrefix = expectedNamespace; }); // Build the provider to verify options @@ -78,7 +78,7 @@ public void AddNatsCache_SetsCacheOptions() var options = provider.GetRequiredService>().Value; // Assert - Assert.Equal(expectedInstanceName, options.InstanceName); + Assert.Equal(expectedNamespace, options.CacheKeyPrefix); Assert.Equal("cache", options.BucketName); } From 4d56fea4582415307091426820aacc6a89836ed0 Mon Sep 17 00:00:00 2001 From: Caleb Lloyd Date: Sun, 11 May 2025 02:10:54 -0400 Subject: [PATCH 3/4] add Cache to TestBase Signed-off-by: Caleb Lloyd --- .../NatsCache.Log.cs | 20 +-- .../NatsCache.cs | 156 +++++------------- .../NatsCacheOptions.cs | 11 -- .../NatsCacheServiceCollectionExtensions.cs | 27 ++- .../Cache/NatsCacheSetAndRemoveTests.cs | 122 ++++++++++++++ .../{ => Cache}/TimeExpirationAsyncTests.cs | 69 +++----- .../{ => Cache}/TimeExpirationTests.cs | 67 +++----- .../CacheServiceExtensionsTests.cs | 35 ---- .../NatsCacheSetAndRemoveTests.cs | 145 ---------------- .../NatsIntegrationFixture.cs | 1 - test/IntegrationTests/TestBase.cs | 18 +- .../TestHelpers/ExceptionAssert.cs | 28 ---- test/TestUtils/Assertions/ExceptionAssert.cs | 53 ++++++ .../Cache/TimeExpirationAsyncUnitTests.cs | 88 ++++++++++ .../{ => Cache}/TimeExpirationUnitTests.cs | 63 +++---- .../CacheServiceExtensionsTests.cs} | 53 ++++-- .../NatsCacheSetAndRemoveUnitTests.cs | 35 ---- test/UnitTests/TestBase.cs | 33 ++++ test/UnitTests/TestHelpers/ExceptionAssert.cs | 28 ---- .../UnitTests/TimeExpirationAsyncUnitTests.cs | 131 --------------- 20 files changed, 484 insertions(+), 699 deletions(-) create mode 100644 test/IntegrationTests/Cache/NatsCacheSetAndRemoveTests.cs rename test/IntegrationTests/{ => Cache}/TimeExpirationAsyncTests.cs (55%) rename test/IntegrationTests/{ => Cache}/TimeExpirationTests.cs (56%) delete mode 100644 test/IntegrationTests/CacheServiceExtensionsTests.cs delete mode 100644 test/IntegrationTests/NatsCacheSetAndRemoveTests.cs delete mode 100644 test/IntegrationTests/TestHelpers/ExceptionAssert.cs create mode 100644 test/TestUtils/Assertions/ExceptionAssert.cs create mode 100644 test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs rename test/UnitTests/{ => Cache}/TimeExpirationUnitTests.cs (52%) rename test/UnitTests/{CacheServiceExtensionsUnitTests.cs => Extensions/CacheServiceExtensionsTests.cs} (65%) delete mode 100644 test/UnitTests/NatsCacheSetAndRemoveUnitTests.cs create mode 100644 test/UnitTests/TestBase.cs delete mode 100644 test/UnitTests/TestHelpers/ExceptionAssert.cs delete mode 100644 test/UnitTests/TimeExpirationAsyncUnitTests.cs diff --git a/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs b/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs index cb6ccf3..928964d 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCache.Log.cs @@ -5,16 +5,10 @@ namespace CodeCargo.NatsDistributedCache; public partial class NatsCache { private void LogException(Exception exception) => - _logger.LogError(EventIds.Exception, exception, "An exception occurred in NATS KV store."); + _logger.LogError(EventIds.Exception, exception, "Exception in NatsCache"); - private void LogConnectionError(Exception exception) => - _logger.LogError(EventIds.ConnectionError, exception, "Error connecting to NATS KV store."); - - private void LogConnectionIssue() => - _logger.LogWarning(EventIds.ConnectionIssue, "Connection issue with NATS KV store."); - - private void LogConnected() => - _logger.LogInformation(EventIds.Connected, "Connected to NATS KV store."); + private void LogConnected(string bucketName) => + _logger.LogInformation(EventIds.Connected, "Connected to NATS KV bucket {bucketName}", bucketName); private void LogUpdateFailed(string key) => _logger.LogDebug( EventIds.UpdateFailed, @@ -23,10 +17,8 @@ private void LogUpdateFailed(string key) => _logger.LogDebug( private static class EventIds { - public static readonly EventId ConnectionIssue = new(100, nameof(ConnectionIssue)); - public static readonly EventId ConnectionError = new(101, nameof(ConnectionError)); - public static readonly EventId Connected = new(102, nameof(Connected)); - public static readonly EventId UpdateFailed = new(103, nameof(UpdateFailed)); - public static readonly EventId Exception = new(104, nameof(Exception)); + public static readonly EventId Connected = new(100, nameof(Connected)); + public static readonly EventId UpdateFailed = new(101, nameof(UpdateFailed)); + public static readonly EventId Exception = new(102, nameof(Exception)); } } diff --git a/src/CodeCargo.NatsDistributedCache/NatsCache.cs b/src/CodeCargo.NatsDistributedCache/NatsCache.cs index 4807ad1..8c0f264 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCache.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCache.cs @@ -28,32 +28,33 @@ public class CacheEntry /// /// Distributed cache implementation using NATS Key-Value Store. /// -public partial class NatsCache : IBufferDistributedCache, IDisposable +public partial class NatsCache : IBufferDistributedCache { // Static JSON serializer for CacheEntry private static readonly NatsJsonContextSerializer CacheEntrySerializer = new(CacheEntryJsonContext.Default); - private readonly ILogger _logger; - private readonly NatsCacheOptions _options; + private readonly string _bucketName; private readonly string _keyPrefix; + private readonly ILogger _logger; private readonly INatsConnection _natsConnection; - private readonly SemaphoreSlim _connectionLock = new(initialCount: 1, maxCount: 1); - private NatsKVStore? _kvStore; - private bool _disposed; + private Lazy> _lazyKvStore; public NatsCache( IOptions optionsAccessor, ILogger logger, INatsConnection natsConnection) { - ArgumentNullException.ThrowIfNull(optionsAccessor); - ArgumentNullException.ThrowIfNull(natsConnection); - - _options = optionsAccessor.Value; + var options = optionsAccessor.Value; + _bucketName = !string.IsNullOrWhiteSpace(options.BucketName) + ? options.BucketName + : throw new NullReferenceException("BucketName must be set"); + _keyPrefix = string.IsNullOrEmpty(options.CacheKeyPrefix) + ? string.Empty + : options.CacheKeyPrefix.TrimEnd('.'); + _lazyKvStore = CreateLazyKvStore(); _logger = logger; _natsConnection = natsConnection; - _keyPrefix = string.IsNullOrEmpty(_options.CacheKeyPrefix) ? string.Empty : _options.CacheKeyPrefix.TrimEnd('.'); } public NatsCache(IOptions optionsAccessor, INatsConnection natsConnection) @@ -72,18 +73,14 @@ public async Task SetAsync( DistributedCacheEntryOptions options, CancellationToken token = default) { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(options); - token.ThrowIfCancellationRequested(); - var ttl = GetTtl(options); var entry = CreateCacheEntry(value, options); var kvStore = await GetKvStore().ConfigureAwait(false); try { - await kvStore.PutAsync(GetPrefixedKey(key), entry, ttl ?? TimeSpan.Zero, CacheEntrySerializer, token) + // todo: remove cast after https://github.com/nats-io/nats.net/pull/852 is released + await ((NatsKVStore)kvStore).PutAsync(GetPrefixedKey(key), entry, ttl ?? TimeSpan.Zero, CacheEntrySerializer, token) .ConfigureAwait(false); } catch (Exception ex) @@ -104,10 +101,6 @@ public async ValueTask SetAsync( DistributedCacheEntryOptions options, CancellationToken token = default) { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(options); - token.ThrowIfCancellationRequested(); - var array = value.IsSingleSegment ? value.First.ToArray() : value.ToArray(); await SetAsync(key, array, options, token).ConfigureAwait(false); } @@ -123,25 +116,15 @@ public async Task RemoveAsync(string key, CancellationToken token = default) => public void Refresh(string key) => RefreshAsync(key).GetAwaiter().GetResult(); /// - public async Task RefreshAsync(string key, CancellationToken token = default) - { - ArgumentNullException.ThrowIfNull(key); - token.ThrowIfCancellationRequested(); - - await GetAndRefreshAsync(key, getData: false, retry: true, token: token).ConfigureAwait(false); - } + public Task RefreshAsync(string key, CancellationToken token = default) => + GetAndRefreshAsync(key, getData: false, retry: true, token: token); /// public byte[]? Get(string key) => GetAsync(key).GetAwaiter().GetResult(); /// - public async Task GetAsync(string key, CancellationToken token = default) - { - ArgumentNullException.ThrowIfNull(key); - token.ThrowIfCancellationRequested(); - - return await GetAndRefreshAsync(key, getData: true, retry: true, token: token).ConfigureAwait(false); - } + public Task GetAsync(string key, CancellationToken token = default) => + GetAndRefreshAsync(key, getData: true, retry: true, token: token); /// public bool TryGet(string key, IBufferWriter destination) => @@ -153,10 +136,6 @@ public async ValueTask TryGetAsync( IBufferWriter destination, CancellationToken token = default) { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(destination); - token.ThrowIfCancellationRequested(); - try { var result = await GetAsync(key, token).ConfigureAwait(false); @@ -174,34 +153,9 @@ public async ValueTask TryGetAsync( return false; } - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - // This is the method used by hybrid caching to determine if it should use the distributed instance internal virtual bool IsHybridCacheActive() => false; - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - // Dispose managed state (managed objects) - _connectionLock.Dispose(); - _kvStore = null; // Set to null to ensure we don't use it after dispose - } - - // Free unmanaged resources (unmanaged objects) and override finalizer - _disposed = true; - } - private static TimeSpan? GetTtl(DistributedCacheEntryOptions options) { if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration.Value <= DateTimeOffset.Now) @@ -275,47 +229,27 @@ private string GetPrefixedKey(string key) => string.IsNullOrEmpty(_keyPrefix) ? key : _keyPrefix + "." + key; - private async Task GetKvStore() - { - if (_kvStore != null && !_disposed) + private Lazy> CreateLazyKvStore() => + new(async () => { - return _kvStore; - } - - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - if (_kvStore == null || _disposed) + try { - ObjectDisposedException.ThrowIf(_disposed, this); - if (string.IsNullOrEmpty(_options.BucketName)) - { - throw new InvalidOperationException("BucketName is required and cannot be null or empty."); - } - - var jsContext = _natsConnection.CreateJetStreamContext(); - var kvContext = new NatsKVContext(jsContext); - _kvStore = (NatsKVStore)await kvContext.GetStoreAsync(_options.BucketName).ConfigureAwait(false); - if (_kvStore == null) - { - throw new InvalidOperationException("Failed to create NATS KV store"); - } - - LogConnected(); + var kv = _natsConnection.CreateKeyValueStoreContext(); + var store = await kv.GetStoreAsync(_bucketName); + LogConnected(_bucketName); + return store; } - } - catch (Exception ex) - { - LogException(ex); - throw; - } - finally - { - _connectionLock.Release(); - } + catch (Exception ex) + { + // Reset the lazy initializer on failure for next attempt + _lazyKvStore = CreateLazyKvStore(); - return _kvStore; - } + LogException(ex); + throw; + } + }); + + private Task GetKvStore() => _lazyKvStore.Value; private async Task GetAndRefreshAsync( string key, @@ -323,8 +257,6 @@ private async Task GetKvStore() bool retry, CancellationToken token = default) { - token.ThrowIfCancellationRequested(); - var kvStore = await GetKvStore().ConfigureAwait(false); var prefixedKey = GetPrefixedKey(key); try @@ -375,7 +307,7 @@ private async Task GetKvStore() } private async Task UpdateEntryExpirationAsync( - NatsKVStore kvStore, + INatsKVStore kvStore, string key, NatsKVEntry kvEntry, CancellationToken token) @@ -405,7 +337,8 @@ private async Task UpdateEntryExpirationAsync( // Use optimistic concurrency control with the last revision try { - await kvStore.UpdateAsync( + // todo: remove cast after https://github.com/nats-io/nats.net/pull/852 is released + await ((NatsKVStore)kvStore).UpdateAsync( key, kvEntry.Value, kvEntry.Revision, @@ -421,23 +354,14 @@ await kvStore.UpdateAsync( } } - /// - /// Removes the value with the given key. - /// - /// A string identifying the requested value. - /// Nats KV delete options - /// Optional. The used to propagate notifications that the operation should be canceled. - /// The that represents the asynchronous operation. private async Task RemoveAsync( string key, NatsKVDeleteOpts? natsKvDeleteOpts = null, CancellationToken token = default) { - ArgumentNullException.ThrowIfNull(key); - token.ThrowIfCancellationRequested(); - var kvStore = await GetKvStore().ConfigureAwait(false); - await kvStore.DeleteAsync(GetPrefixedKey(key), natsKvDeleteOpts, cancellationToken: token).ConfigureAwait(false); + await kvStore.DeleteAsync(GetPrefixedKey(key), natsKvDeleteOpts, cancellationToken: token) + .ConfigureAwait(false); } } diff --git a/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs b/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs index 244112f..a5ed557 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCacheOptions.cs @@ -18,17 +18,6 @@ public class NatsCacheOptions : IOptions /// public string? CacheKeyPrefix { get; set; } - /// - /// If set, attempt to retrieve the INatsConnection as a keyed service from the service provider. - /// - public string? ConnectionServiceKey { get; set; } - - /// - /// When true (the default) register a as an - /// IHybridCacheSerializerFactory that uses the NATS Connection's Serializer Registry. - /// - public bool RegisterHybridCacheSerializerFactory { get; set; } = true; - NatsCacheOptions IOptions.Value => this; } } diff --git a/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs b/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs index e96fe18..0b6e299 100644 --- a/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs +++ b/src/CodeCargo.NatsDistributedCache/NatsCacheServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,27 +16,25 @@ public static class NatsCacheServiceCollectionExtensions /// Adds NATS distributed caching services to the specified . /// /// The to add services to. - /// An to configure the provided + /// An to configure the provided /// . + /// If set, used keyed service to resolve /// The so that additional calls can be chained. public static IServiceCollection AddNatsDistributedCache( this IServiceCollection services, - Action setupAction) + Action configureOptions, + object? connectionServiceKey = null) { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(setupAction); - services.AddOptions(); - services.Configure(setupAction); + services.Configure(configureOptions); services.Add(ServiceDescriptor.Singleton(serviceProvider => { var optionsAccessor = serviceProvider.GetRequiredService>(); var logger = serviceProvider.GetService>(); - var serviceKey = optionsAccessor.Value.ConnectionServiceKey; - var natsConnection = string.IsNullOrEmpty(serviceKey) + var natsConnection = connectionServiceKey == null ? serviceProvider.GetRequiredService() - : serviceProvider.GetRequiredKeyedService(serviceKey); + : serviceProvider.GetRequiredKeyedService(connectionServiceKey); return logger != null ? new NatsCacheImpl(optionsAccessor, logger, serviceProvider, natsConnection) @@ -44,5 +43,15 @@ public static IServiceCollection AddNatsDistributedCache( return services; } + + /// + /// Creates an that uses the provided + /// to perform serialization. + /// + /// The instance + /// The instance + public static IHybridCacheSerializerFactory ToHybridCacheSerializerFactory( + this INatsSerializerRegistry serializerRegistry) => + new NatsHybridCacheSerializerFactory(serializerRegistry); } } diff --git a/test/IntegrationTests/Cache/NatsCacheSetAndRemoveTests.cs b/test/IntegrationTests/Cache/NatsCacheSetAndRemoveTests.cs new file mode 100644 index 0000000..1b282e0 --- /dev/null +++ b/test/IntegrationTests/Cache/NatsCacheSetAndRemoveTests.cs @@ -0,0 +1,122 @@ +using System.Text; +using Microsoft.Extensions.Caching.Distributed; + +namespace CodeCargo.NatsDistributedCache.IntegrationTests.Cache; + +public class NatsCacheSetAndRemoveTests(NatsIntegrationFixture fixture) : TestBase(fixture) +{ + [Fact] + public void GetMissingKeyReturnsNull() + { + var key = MethodKey(); + var result = Cache.Get(key); + Assert.Null(result); + } + + [Fact] + public void SetAndGetReturnsObject() + { + var key = MethodKey(); + var value = new byte[1]; + + Cache.Set(key, value); + + var result = Cache.Get(key); + Assert.Equal(value, result); + } + + [Fact] + public void SetAndGetWorksWithCaseSensitiveKeys() + { + var key1 = MethodKey().ToUpper(); + var key2 = key1.ToLower(); + var value1 = new byte[] { 1 }; + var value2 = new byte[] { 2 }; + + Cache.Set(key1, value1); + Cache.Set(key2, value2); + + var result1 = Cache.Get(key1); + var result2 = Cache.Get(key2); + + Assert.Equal(value1, result1); + Assert.Equal(value2, result2); + } + + [Fact] + public void SetAlwaysOverwrites() + { + var key = MethodKey(); + var value1 = new byte[] { 1 }; + var value2 = new byte[] { 2 }; + + Cache.Set(key, value1); + Cache.Set(key, value2); + + var result = Cache.Get(key); + Assert.Equal(value2, result); + } + + [Fact] + public void RemoveRemoves() + { + var key = MethodKey(); + var value = new byte[1]; + + Cache.Set(key, value); + Cache.Remove(key); + + var result = Cache.Get(key); + Assert.Null(result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc")] + [InlineData("abc def ghi jkl mno pqr stu vwx yz!")] + public void SetGetNonNullString(string payload) + { + var key = MethodKey(); + Cache.Remove(key); // known state + Assert.Null(Cache.Get(key)); // expect null + Cache.SetString(key, payload); + + // check raw bytes + var raw = Cache.Get(key); + Assert.NotNull(raw); + Assert.Equal(Hex(payload), Hex(raw)); + + // check via string API + var value = Cache.GetString(key); + Assert.NotNull(value); + Assert.Equal(payload, value); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc")] + [InlineData("abc def ghi jkl mno pqr stu vwx yz!")] + public async Task SetGetNonNullStringAsync(string payload) + { + var key = MethodKey(); + await Cache.RemoveAsync(key); // known state + Assert.Null(await Cache.GetAsync(key)); // expect null + await Cache.SetStringAsync(key, payload); + + // check raw bytes + var raw = await Cache.GetAsync(key); + Assert.NotNull(raw); + Assert.Equal(Hex(payload), Hex(raw)); + + // check via string API + var value = await Cache.GetStringAsync(key); + Assert.NotNull(value); + Assert.Equal(payload, value); + } + + private static string Hex(byte[] value) => BitConverter.ToString(value); + + private static string Hex(string value) => Hex(Encoding.UTF8.GetBytes(value)); +} diff --git a/test/IntegrationTests/TimeExpirationAsyncTests.cs b/test/IntegrationTests/Cache/TimeExpirationAsyncTests.cs similarity index 55% rename from test/IntegrationTests/TimeExpirationAsyncTests.cs rename to test/IntegrationTests/Cache/TimeExpirationAsyncTests.cs index ac63acb..153d3c2 100644 --- a/test/IntegrationTests/TimeExpirationAsyncTests.cs +++ b/test/IntegrationTests/Cache/TimeExpirationAsyncTests.cs @@ -1,27 +1,24 @@ -using System.Runtime.CompilerServices; using Microsoft.Extensions.Caching.Distributed; -namespace CodeCargo.NatsDistributedCache.IntegrationTests; +namespace CodeCargo.NatsDistributedCache.IntegrationTests.Cache; -[Collection(NatsCollection.Name)] public class TimeExpirationAsyncTests(NatsIntegrationFixture fixture) : TestBase(fixture) { [Fact] public async Task AbsoluteExpirationExpiresAsync() { - var cache = CreateCacheInstance(); - var key = await GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); - var result = await cache.GetAsync(key); + var result = await Cache.GetAsync(key); Assert.Equal(value, result); for (var i = 0; i < 4 && result != null; i++) { await Task.Delay(TimeSpan.FromSeconds(0.5)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); } Assert.Null(result); @@ -30,19 +27,18 @@ public async Task AbsoluteExpirationExpiresAsync() [Fact] public async Task RelativeExpirationExpiresAsync() { - var cache = CreateCacheInstance(); - var key = await GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); - var result = await cache.GetAsync(key); + var result = await Cache.GetAsync(key); Assert.Equal(value, result); for (var i = 0; i < 4 && result != null; i++) { await Task.Delay(TimeSpan.FromSeconds(0.5)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); } Assert.Null(result); @@ -51,68 +47,65 @@ public async Task RelativeExpirationExpiresAsync() [Fact] public async Task SlidingExpirationExpiresIfNotAccessedAsync() { - var cache = CreateCacheInstance(); - var key = await GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); - var result = await cache.GetAsync(key); + var result = await Cache.GetAsync(key); Assert.Equal(value, result); await Task.Delay(TimeSpan.FromSeconds(3)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); Assert.Null(result); } [Fact] public async Task SlidingExpirationRenewedByAccessAsync() { - var cache = CreateCacheInstance(); - var key = await GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); - var result = await cache.GetAsync(key); + var result = await Cache.GetAsync(key); Assert.Equal(value, result); for (var i = 0; i < 5; i++) { await Task.Delay(TimeSpan.FromSeconds(0.5)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); Assert.NotNull(result); Assert.Equal(value, result); } await Task.Delay(TimeSpan.FromSeconds(3)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); Assert.Null(result); } [Fact] public async Task SlidingExpirationRenewedByAccessUntilAbsoluteExpirationAsync() { - var cache = CreateCacheInstance(); - var key = await GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - await cache.SetAsync(key, value, new DistributedCacheEntryOptions() + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromSeconds(1.1)) .SetAbsoluteExpiration(TimeSpan.FromSeconds(4))); var setTime = DateTime.Now; - var result = await cache.GetAsync(key); + var result = await Cache.GetAsync(key); Assert.Equal(value, result); for (var i = 0; i < 4; i++) { await Task.Delay(TimeSpan.FromSeconds(0.5)); - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); Assert.NotNull(result); Assert.Equal(value, result); } @@ -122,23 +115,7 @@ public async Task SlidingExpirationRenewedByAccessUntilAbsoluteExpirationAsync() await Task.Delay(TimeSpan.FromSeconds(0.5)); } - result = await cache.GetAsync(key); + result = await Cache.GetAsync(key); Assert.Null(result); } - - private static async Task GetNameAndReset(IDistributedCache cache, [CallerMemberName] string caller = "") - { - await cache.RemoveAsync(caller); - return caller; - } - - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - NatsConnection); - } } diff --git a/test/IntegrationTests/TimeExpirationTests.cs b/test/IntegrationTests/Cache/TimeExpirationTests.cs similarity index 56% rename from test/IntegrationTests/TimeExpirationTests.cs rename to test/IntegrationTests/Cache/TimeExpirationTests.cs index 3532639..1765045 100644 --- a/test/IntegrationTests/TimeExpirationTests.cs +++ b/test/IntegrationTests/Cache/TimeExpirationTests.cs @@ -1,27 +1,24 @@ -using System.Runtime.CompilerServices; using Microsoft.Extensions.Caching.Distributed; -namespace CodeCargo.NatsDistributedCache.IntegrationTests; +namespace CodeCargo.NatsDistributedCache.IntegrationTests.Cache; -[Collection(NatsCollection.Name)] public class TimeExpirationTests(NatsIntegrationFixture fixture) : TestBase(fixture) { [Fact] public void AbsoluteExpirationExpires() { - var cache = CreateCacheInstance(); - var key = GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); - var result = cache.Get(key); + var result = Cache.Get(key); Assert.Equal(value, result); for (var i = 0; i < 4 && result != null; i++) { Thread.Sleep(TimeSpan.FromSeconds(0.5)); - result = cache.Get(key); + result = Cache.Get(key); } Assert.Null(result); @@ -30,19 +27,18 @@ public void AbsoluteExpirationExpires() [Fact] public void RelativeExpirationExpires() { - var cache = CreateCacheInstance(); - var key = GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(1.1))); - var result = cache.Get(key); + var result = Cache.Get(key); Assert.Equal(value, result); for (var i = 0; i < 4 && result != null; i++) { Thread.Sleep(TimeSpan.FromSeconds(0.5)); - result = cache.Get(key); + result = Cache.Get(key); } Assert.Null(result); @@ -51,68 +47,65 @@ public void RelativeExpirationExpires() [Fact] public void SlidingExpirationExpiresIfNotAccessed() { - var cache = CreateCacheInstance(); - var key = GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); - var result = cache.Get(key); + var result = Cache.Get(key); Assert.Equal(value, result); Thread.Sleep(TimeSpan.FromSeconds(3)); - result = cache.Get(key); + result = Cache.Get(key); Assert.Null(result); } [Fact] public void SlidingExpirationRenewedByAccess() { - var cache = CreateCacheInstance(); - var key = GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(1))); - var result = cache.Get(key); + var result = Cache.Get(key); Assert.Equal(value, result); for (var i = 0; i < 5; i++) { Thread.Sleep(TimeSpan.FromSeconds(0.5)); - result = cache.Get(key); + result = Cache.Get(key); Assert.NotNull(result); Assert.Equal(value, result); } Thread.Sleep(TimeSpan.FromSeconds(3)); - result = cache.Get(key); + result = Cache.Get(key); Assert.Null(result); } [Fact] public void SlidingExpirationRenewedByAccessUntilAbsoluteExpiration() { - var cache = CreateCacheInstance(); - var key = GetNameAndReset(cache); + var key = MethodKey(); var value = new byte[1]; - cache.Set(key, value, new DistributedCacheEntryOptions() + Cache.Set(key, value, new DistributedCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromSeconds(1.1)) .SetAbsoluteExpiration(TimeSpan.FromSeconds(4))); var setTime = DateTime.Now; - var result = cache.Get(key); + var result = Cache.Get(key); Assert.Equal(value, result); for (var i = 0; i < 4; i++) { Thread.Sleep(TimeSpan.FromSeconds(0.5)); - result = cache.Get(key); + result = Cache.Get(key); Assert.NotNull(result); Assert.Equal(value, result); } @@ -122,21 +115,7 @@ public void SlidingExpirationRenewedByAccessUntilAbsoluteExpiration() Thread.Sleep(TimeSpan.FromSeconds(0.5)); } - result = cache.Get(key); + result = Cache.Get(key); Assert.Null(result); } - - private static string GetNameAndReset(IDistributedCache cache, [CallerMemberName] string caller = "") - { - cache.Remove(caller); - return caller; - } - - private IDistributedCache CreateCacheInstance() => - new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - NatsConnection); } diff --git a/test/IntegrationTests/CacheServiceExtensionsTests.cs b/test/IntegrationTests/CacheServiceExtensionsTests.cs deleted file mode 100644 index 90a22e9..0000000 --- a/test/IntegrationTests/CacheServiceExtensionsTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; - -namespace CodeCargo.NatsDistributedCache.IntegrationTests; - -[Collection(NatsCollection.Name)] -public class CacheServiceExtensionsTests : TestBase -{ - private readonly NatsIntegrationFixture _fixture; - - public CacheServiceExtensionsTests(NatsIntegrationFixture fixture) - : base(fixture) - { - _fixture = fixture; - } - - // All tests moved to UnitTests/CacheServiceExtensionsUnitTests.cs - private class FakeDistributedCache : IDistributedCache - { - public byte[]? Get(string key) => throw new NotImplementedException(); - - public Task GetAsync(string key, CancellationToken token = default) => throw new NotImplementedException(); - - public void Refresh(string key) => throw new NotImplementedException(); - - public Task RefreshAsync(string key, CancellationToken token = default) => throw new NotImplementedException(); - - public void Remove(string key) => throw new NotImplementedException(); - - public Task RemoveAsync(string key, CancellationToken token = default) => throw new NotImplementedException(); - - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => throw new NotImplementedException(); - - public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) => throw new NotImplementedException(); - } -} diff --git a/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs b/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs deleted file mode 100644 index 12eb1e9..0000000 --- a/test/IntegrationTests/NatsCacheSetAndRemoveTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text; -using Microsoft.Extensions.Caching.Distributed; - -namespace CodeCargo.NatsDistributedCache.IntegrationTests; - -[Collection(NatsCollection.Name)] -public class NatsCacheSetAndRemoveTests(NatsIntegrationFixture fixture) : TestBase(fixture) -{ - [Fact] - public void GetMissingKeyReturnsNull() - { - var cache = CreateCacheInstance(); - var key = "non-existent-key"; - - var result = cache.Get(key); - Assert.Null(result); - } - - [Fact] - public void SetAndGetReturnsObject() - { - var cache = CreateCacheInstance(); - var value = new byte[1]; - var key = "myKey"; - - cache.Set(key, value); - - var result = cache.Get(key); - Assert.Equal(value, result); - } - - [Fact] - public void SetAndGetWorksWithCaseSensitiveKeys() - { - var cache = CreateCacheInstance(); - var value1 = new byte[1] { 1 }; - var value2 = new byte[1] { 2 }; - var key1 = "myKey"; - var key2 = "Mykey"; - - cache.Set(key1, value1); - cache.Set(key2, value2); - - var result1 = cache.Get(key1); - var result2 = cache.Get(key2); - - Assert.Equal(value1, result1); - Assert.Equal(value2, result2); - } - - [Fact] - public void SetAlwaysOverwrites() - { - var cache = CreateCacheInstance(); - var value1 = new byte[1] { 1 }; - var value2 = new byte[1] { 2 }; - var key = "myKey"; - - cache.Set(key, value1); - cache.Set(key, value2); - - var result = cache.Get(key); - Assert.Equal(value2, result); - } - - [Fact] - public void RemoveRemoves() - { - var cache = CreateCacheInstance(); - var value = new byte[1]; - var key = "myKey"; - - cache.Set(key, value); - cache.Remove(key); - - var result = cache.Get(key); - Assert.Null(result); - } - - // SetNullValueThrows test moved to UnitTests/NatsCacheSetAndRemoveUnitTests.cs - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("abc")] - [InlineData("abc def ghi jkl mno pqr stu vwx yz!")] - public void SetGetNonNullString(string payload) - { - var cache = CreateCacheInstance(); - var key = Me(); - cache.Remove(key); // known state - Assert.Null(cache.Get(key)); // expect null - cache.SetString(key, payload); - - // check raw bytes - var raw = cache.Get(key); - Assert.NotNull(raw); - Assert.Equal(Hex(payload), Hex(raw)); - - // check via string API - var value = cache.GetString(key); - Assert.NotNull(value); - Assert.Equal(payload, value); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("abc")] - [InlineData("abc def ghi jkl mno pqr stu vwx yz!")] - public async Task SetGetNonNullStringAsync(string payload) - { - var cache = CreateCacheInstance(); - var key = Me(); - await cache.RemoveAsync(key); // known state - Assert.Null(await cache.GetAsync(key)); // expect null - await cache.SetStringAsync(key, payload); - - // check raw bytes - var raw = await cache.GetAsync(key); - Assert.NotNull(raw); - Assert.Equal(Hex(payload), Hex(raw)); - - // check via string API - var value = await cache.GetStringAsync(key); - Assert.NotNull(value); - Assert.Equal(payload, value); - } - - private static string Hex(byte[] value) => BitConverter.ToString(value); - - private static string Hex(string value) => Hex(Encoding.UTF8.GetBytes(value)); - - private static string Me([CallerMemberName] string caller = "") => caller; - - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - NatsConnection); - } -} diff --git a/test/IntegrationTests/NatsIntegrationFixture.cs b/test/IntegrationTests/NatsIntegrationFixture.cs index 728a50c..b4aaaea 100644 --- a/test/IntegrationTests/NatsIntegrationFixture.cs +++ b/test/IntegrationTests/NatsIntegrationFixture.cs @@ -1,6 +1,5 @@ using Aspire.Hosting; using CodeCargo.NatsDistributedCache.TestUtils; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.KeyValueStore; diff --git a/test/IntegrationTests/TestBase.cs b/test/IntegrationTests/TestBase.cs index 053a9e0..9d524af 100644 --- a/test/IntegrationTests/TestBase.cs +++ b/test/IntegrationTests/TestBase.cs @@ -1,4 +1,6 @@ +using System.Runtime.CompilerServices; using CodeCargo.NatsDistributedCache.TestUtils.Services.Logging; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using NATS.Client.Core; using NATS.Client.JetStream.Models; @@ -23,7 +25,8 @@ protected TestBase(NatsIntegrationFixture fixture) // Get the test output helper from the current test context var testContext = TestContext.Current; var output = testContext.TestOutputHelper ?? - throw new InvalidOperationException("TestOutputHelper was not available in the current test context"); + throw new InvalidOperationException( + "TestOutputHelper was not available in the current test context"); // Create a service collection and configure logging var services = new ServiceCollection(); @@ -36,6 +39,9 @@ protected TestBase(NatsIntegrationFixture fixture) // Configure the service collection with NATS connection fixture.ConfigureServices(services); + // Add the cache + services.AddNatsDistributedCache(options => options.BucketName = "cache"); + // Build service provider ServiceProvider = services.BuildServiceProvider(); } @@ -50,6 +56,11 @@ protected TestBase(NatsIntegrationFixture fixture) /// protected INatsConnection NatsConnection => ServiceProvider.GetRequiredService(); + /// + /// Gets the cache from the service provider + /// + protected IDistributedCache Cache => ServiceProvider.GetRequiredService(); + /// /// Purge stream before test run /// @@ -71,4 +82,9 @@ public virtual async ValueTask DisposeAsync() await ServiceProvider.DisposeAsync(); GC.SuppressFinalize(this); } + + /// + /// Gets the key for the current test method + /// + protected string MethodKey([CallerMemberName] string caller = "") => caller; } diff --git a/test/IntegrationTests/TestHelpers/ExceptionAssert.cs b/test/IntegrationTests/TestHelpers/ExceptionAssert.cs deleted file mode 100644 index 563026a..0000000 --- a/test/IntegrationTests/TestHelpers/ExceptionAssert.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CodeCargo.NatsDistributedCache.IntegrationTests.TestHelpers; - -public static class ExceptionAssert -{ - public static void ThrowsArgumentOutOfRange( - Action testCode, - string paramName, - string message, - object actualValue) - { - var ex = Assert.Throws(testCode); - - if (paramName is not null) - { - Assert.Equal(paramName, ex.ParamName); - } - - if (message is not null) - { - Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc - } - - if (actualValue is not null) - { - Assert.Equal(actualValue, ex.ActualValue); - } - } -} diff --git a/test/TestUtils/Assertions/ExceptionAssert.cs b/test/TestUtils/Assertions/ExceptionAssert.cs new file mode 100644 index 0000000..9910482 --- /dev/null +++ b/test/TestUtils/Assertions/ExceptionAssert.cs @@ -0,0 +1,53 @@ +using Xunit; + +namespace CodeCargo.NatsDistributedCache.TestUtils.Assertions; + +public static class ExceptionAssert +{ + public static void ThrowsArgumentOutOfRange( + Action testCode, + string paramName, + string message, + object actualValue) + { + var ex = Assert.Throws(testCode); + + if (paramName is not null) + { + Assert.Equal(paramName, ex.ParamName); + } + + if (message is not null) + { + Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc + } + + if (actualValue is not null) + { + Assert.Equal(actualValue, ex.ActualValue); + } + } + + public static async Task ThrowsArgumentOutOfRangeAsync( + Func test, + string paramName, + string message, + object actualValue) + { + var ex = await Assert.ThrowsAsync(test); + if (paramName is not null) + { + Assert.Equal(paramName, ex.ParamName); + } + + if (message is not null) + { + Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc + } + + if (actualValue is not null) + { + Assert.Equal(actualValue, ex.ActualValue); + } + } +} diff --git a/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs b/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs new file mode 100644 index 0000000..1875731 --- /dev/null +++ b/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs @@ -0,0 +1,88 @@ +using CodeCargo.NatsDistributedCache.TestUtils.Assertions; +using Microsoft.Extensions.Caching.Distributed; + +namespace CodeCargo.NatsDistributedCache.UnitTests.Cache; + +public class TimeExpirationAsyncUnitTests : TestBase +{ + [Fact] + public async Task AbsoluteExpirationInThePastThrowsAsync() + { + var key = MethodKey(); + var value = new byte[1]; + + var expected = DateTimeOffset.Now - TimeSpan.FromMinutes(1); + await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( + async () => + { + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(expected)); + }, + nameof(DistributedCacheEntryOptions.AbsoluteExpiration), + "The absolute expiration value must be in the future.", + expected); + } + + [Fact] + public async Task NegativeRelativeExpirationThrowsAsync() + { + var key = MethodKey(); + var value = new byte[1]; + + await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( + async () => + { + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(-1))); + }, + nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), + "The relative expiration value must be positive.", + TimeSpan.FromMinutes(-1)); + } + + [Fact] + public async Task ZeroRelativeExpirationThrowsAsync() + { + var key = MethodKey(); + var value = new byte[1]; + + await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( + async () => + { + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.Zero)); + }, + nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), + "The relative expiration value must be positive.", + TimeSpan.Zero); + } + + [Fact] + public async Task NegativeSlidingExpirationThrowsAsync() + { + var key = MethodKey(); + var value = new byte[1]; + + await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( + async () => + { + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(-1))); + }, + nameof(DistributedCacheEntryOptions.SlidingExpiration), + "The sliding expiration value must be positive.", + TimeSpan.FromMinutes(-1)); + } + + [Fact] + public async Task ZeroSlidingExpirationThrowsAsync() + { + var key = MethodKey(); + var value = new byte[1]; + + await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( + async () => + { + await Cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.Zero)); + }, + nameof(DistributedCacheEntryOptions.SlidingExpiration), + "The sliding expiration value must be positive.", + TimeSpan.Zero); + } +} diff --git a/test/UnitTests/TimeExpirationUnitTests.cs b/test/UnitTests/Cache/TimeExpirationUnitTests.cs similarity index 52% rename from test/UnitTests/TimeExpirationUnitTests.cs rename to test/UnitTests/Cache/TimeExpirationUnitTests.cs index bdbaa17..d2a5f64 100644 --- a/test/UnitTests/TimeExpirationUnitTests.cs +++ b/test/UnitTests/Cache/TimeExpirationUnitTests.cs @@ -1,38 +1,21 @@ -using CodeCargo.NatsDistributedCache.UnitTests.TestHelpers; +using CodeCargo.NatsDistributedCache.TestUtils.Assertions; using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; -using Moq; -using NATS.Client.Core; -namespace CodeCargo.NatsDistributedCache.UnitTests; +namespace CodeCargo.NatsDistributedCache.UnitTests.Cache; -public class TimeExpirationUnitTests +public class TimeExpirationUnitTests : TestBase { - private readonly Mock _mockNatsConnection; - - public TimeExpirationUnitTests() - { - _mockNatsConnection = new Mock(); - - // Setup the mock to properly handle the Opts property - var opts = new NatsOpts { LoggerFactory = new LoggerFactory() }; - _mockNatsConnection.SetupGet(m => m.Opts).Returns(opts); - var connection = new NatsConnection(opts); - _mockNatsConnection.SetupGet(m => m.Connection).Returns(connection); - } - [Fact] public void AbsoluteExpirationInThePastThrows() { - var cache = CreateCacheInstance(); - var key = "AbsoluteExpirationInThePastThrows"; + var key = MethodKey(); var value = new byte[1]; var expected = DateTimeOffset.Now - TimeSpan.FromMinutes(1); ExceptionAssert.ThrowsArgumentOutOfRange( () => { - cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(expected)); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(expected)); }, nameof(DistributedCacheEntryOptions.AbsoluteExpiration), "The absolute expiration value must be in the future.", @@ -42,14 +25,16 @@ public void AbsoluteExpirationInThePastThrows() [Fact] public void NegativeRelativeExpirationThrows() { - var cache = CreateCacheInstance(); - var key = "NegativeRelativeExpirationThrows"; + var key = MethodKey(); var value = new byte[1]; ExceptionAssert.ThrowsArgumentOutOfRange( () => { - cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(-1))); + Cache.Set( + key, + value, + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(-1))); }, nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), "The relative expiration value must be positive.", @@ -59,14 +44,13 @@ public void NegativeRelativeExpirationThrows() [Fact] public void ZeroRelativeExpirationThrows() { - var cache = CreateCacheInstance(); - var key = "ZeroRelativeExpirationThrows"; + var key = MethodKey(); var value = new byte[1]; ExceptionAssert.ThrowsArgumentOutOfRange( () => { - cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.Zero)); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.Zero)); }, nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), "The relative expiration value must be positive.", @@ -76,14 +60,16 @@ public void ZeroRelativeExpirationThrows() [Fact] public void NegativeSlidingExpirationThrows() { - var cache = CreateCacheInstance(); - var key = "NegativeSlidingExpirationThrows"; + var key = MethodKey(); var value = new byte[1]; ExceptionAssert.ThrowsArgumentOutOfRange( () => { - cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(-1))); + Cache.Set( + key, + value, + new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(-1))); }, nameof(DistributedCacheEntryOptions.SlidingExpiration), "The sliding expiration value must be positive.", @@ -93,27 +79,16 @@ public void NegativeSlidingExpirationThrows() [Fact] public void ZeroSlidingExpirationThrows() { - var cache = CreateCacheInstance(); - var key = "ZeroSlidingExpirationThrows"; + var key = MethodKey(); var value = new byte[1]; ExceptionAssert.ThrowsArgumentOutOfRange( () => { - cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.Zero)); + Cache.Set(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.Zero)); }, nameof(DistributedCacheEntryOptions.SlidingExpiration), "The sliding expiration value must be positive.", TimeSpan.Zero); } - - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - _mockNatsConnection.Object); - } } diff --git a/test/UnitTests/CacheServiceExtensionsUnitTests.cs b/test/UnitTests/Extensions/CacheServiceExtensionsTests.cs similarity index 65% rename from test/UnitTests/CacheServiceExtensionsUnitTests.cs rename to test/UnitTests/Extensions/CacheServiceExtensionsTests.cs index 23f4265..ea86376 100644 --- a/test/UnitTests/CacheServiceExtensionsUnitTests.cs +++ b/test/UnitTests/Extensions/CacheServiceExtensionsTests.cs @@ -4,16 +4,11 @@ using Moq; using NATS.Client.Core; -namespace CodeCargo.NatsDistributedCache.UnitTests; +namespace CodeCargo.NatsDistributedCache.UnitTests.Extensions; public class CacheServiceExtensionsUnitTests { - private readonly Mock _mockNatsConnection; - - public CacheServiceExtensionsUnitTests() - { - _mockNatsConnection = new Mock(); - } + private readonly Mock _mockNatsConnection = new(); [Fact] public void AddNatsCache_RegistersDistributedCacheAsSingleton() @@ -105,11 +100,42 @@ public void AddNatsCache_UsesCacheOptionsAction() Assert.True(wasInvoked); } + [Fact] + public void AddNatsCache_AcceptsConnectionServiceKey_Parameter() + { + // Arrange + var services = new ServiceCollection(); + var defaultConnection = new Mock().Object; + var keyedConnection = new Mock().Object; + + services.AddSingleton(defaultConnection); + services.AddKeyedSingleton("my-key", keyedConnection); + + // Act - ensure this doesn't throw an exception + services.AddNatsDistributedCache( + options => { options.BucketName = "cache"; }, + connectionServiceKey: "my-key"); + + // Assert + // Verify the IDistributedCache registration looks correct + var cacheRegistration = services.FirstOrDefault(x => x.ServiceType == typeof(IDistributedCache)); + Assert.NotNull(cacheRegistration); + Assert.Equal(ServiceLifetime.Singleton, cacheRegistration.Lifetime); + Assert.Null(cacheRegistration.ImplementationType); // Should use a factory, not a direct type + Assert.NotNull(cacheRegistration.ImplementationFactory); // Should use a factory registration + + // Verify the NatsCacheOptions were configured + var optionsRegistration = services.FirstOrDefault(d => + d.ServiceType == typeof(IConfigureOptions)); + Assert.NotNull(optionsRegistration); + } + private class FakeDistributedCache : IDistributedCache { - public byte[]? Get(string key) => throw new NotImplementedException(); + public byte[] Get(string key) => throw new NotImplementedException(); - public Task GetAsync(string key, CancellationToken token = default) => throw new NotImplementedException(); + public Task GetAsync(string key, CancellationToken token = default) => + throw new NotImplementedException(); public void Refresh(string key) => throw new NotImplementedException(); @@ -119,8 +145,13 @@ private class FakeDistributedCache : IDistributedCache public Task RemoveAsync(string key, CancellationToken token = default) => throw new NotImplementedException(); - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => throw new NotImplementedException(); + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => + throw new NotImplementedException(); - public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) => throw new NotImplementedException(); + public Task SetAsync( + string key, + byte[] value, + DistributedCacheEntryOptions options, + CancellationToken token = default) => throw new NotImplementedException(); } } diff --git a/test/UnitTests/NatsCacheSetAndRemoveUnitTests.cs b/test/UnitTests/NatsCacheSetAndRemoveUnitTests.cs deleted file mode 100644 index ae4bddc..0000000 --- a/test/UnitTests/NatsCacheSetAndRemoveUnitTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using Moq; -using NATS.Client.Core; - -namespace CodeCargo.NatsDistributedCache.UnitTests; - -public class NatsCacheSetAndRemoveUnitTests -{ - private readonly Mock _mockNatsConnection; - - public NatsCacheSetAndRemoveUnitTests() - { - _mockNatsConnection = new Mock(); - } - - [Fact] - public void SetNullValueThrows() - { - var cache = CreateCacheInstance(); - byte[] value = null!; - var key = "myKey"; - - Assert.Throws(() => cache.Set(key, value)); - } - - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - _mockNatsConnection.Object); - } -} diff --git a/test/UnitTests/TestBase.cs b/test/UnitTests/TestBase.cs new file mode 100644 index 0000000..764fd3b --- /dev/null +++ b/test/UnitTests/TestBase.cs @@ -0,0 +1,33 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NATS.Client.Core; + +namespace CodeCargo.NatsDistributedCache.UnitTests; + +public abstract class TestBase +{ + protected TestBase() + { + var mockNatsConnection = new Mock(); + var opts = new NatsOpts { LoggerFactory = new LoggerFactory() }; + mockNatsConnection.SetupGet(m => m.Opts).Returns(opts); + var connection = new NatsConnection(opts); + mockNatsConnection.SetupGet(m => m.Connection).Returns(connection); + Cache = new NatsCache( + Options.Create(new NatsCacheOptions { BucketName = "cache" }), + mockNatsConnection.Object); + } + + /// + /// Gets the cache + /// + protected IDistributedCache Cache { get; } + + /// + /// Gets the key for the current test method + /// + protected string MethodKey([CallerMemberName] string caller = "") => caller; +} diff --git a/test/UnitTests/TestHelpers/ExceptionAssert.cs b/test/UnitTests/TestHelpers/ExceptionAssert.cs deleted file mode 100644 index 0cbe44a..0000000 --- a/test/UnitTests/TestHelpers/ExceptionAssert.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CodeCargo.NatsDistributedCache.UnitTests.TestHelpers; - -public static class ExceptionAssert -{ - public static void ThrowsArgumentOutOfRange( - Action testCode, - string paramName, - string message, - object actualValue) - { - var ex = Assert.Throws(testCode); - - if (paramName is not null) - { - Assert.Equal(paramName, ex.ParamName); - } - - if (message is not null) - { - Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc - } - - if (actualValue is not null) - { - Assert.Equal(actualValue, ex.ActualValue); - } - } -} diff --git a/test/UnitTests/TimeExpirationAsyncUnitTests.cs b/test/UnitTests/TimeExpirationAsyncUnitTests.cs deleted file mode 100644 index befc06a..0000000 --- a/test/UnitTests/TimeExpirationAsyncUnitTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using Moq; -using NATS.Client.Core; - -namespace CodeCargo.NatsDistributedCache.UnitTests; - -public class TimeExpirationAsyncUnitTests -{ - private readonly Mock _mockNatsConnection; - - public TimeExpirationAsyncUnitTests() - { - _mockNatsConnection = new Mock(); - } - - [Fact] - public async Task AbsoluteExpirationInThePastThrowsAsync() - { - var cache = CreateCacheInstance(); - var key = "AbsoluteExpirationInThePastThrowsAsync"; - var value = new byte[1]; - - var expected = DateTimeOffset.Now - TimeSpan.FromMinutes(1); - await ThrowsArgumentOutOfRangeAsync( - async () => - { - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(expected)); - }, - nameof(DistributedCacheEntryOptions.AbsoluteExpiration), - "The absolute expiration value must be in the future.", - expected); - } - - [Fact] - public async Task NegativeRelativeExpirationThrowsAsync() - { - var cache = CreateCacheInstance(); - var key = "NegativeRelativeExpirationThrowsAsync"; - var value = new byte[1]; - - await ThrowsArgumentOutOfRangeAsync( - async () => - { - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(-1))); - }, - nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), - "The relative expiration value must be positive.", - TimeSpan.FromMinutes(-1)); - } - - [Fact] - public async Task ZeroRelativeExpirationThrowsAsync() - { - var cache = CreateCacheInstance(); - var key = "ZeroRelativeExpirationThrowsAsync"; - var value = new byte[1]; - - await ThrowsArgumentOutOfRangeAsync( - async () => - { - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.Zero)); - }, - nameof(DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow), - "The relative expiration value must be positive.", - TimeSpan.Zero); - } - - [Fact] - public async Task NegativeSlidingExpirationThrowsAsync() - { - var cache = CreateCacheInstance(); - var key = "NegativeSlidingExpirationThrowsAsync"; - var value = new byte[1]; - - await ThrowsArgumentOutOfRangeAsync( - async () => - { - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(-1))); - }, - nameof(DistributedCacheEntryOptions.SlidingExpiration), - "The sliding expiration value must be positive.", - TimeSpan.FromMinutes(-1)); - } - - [Fact] - public async Task ZeroSlidingExpirationThrowsAsync() - { - var cache = CreateCacheInstance(); - var key = "ZeroSlidingExpirationThrowsAsync"; - var value = new byte[1]; - - await ThrowsArgumentOutOfRangeAsync( - async () => - { - await cache.SetAsync(key, value, new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.Zero)); - }, - nameof(DistributedCacheEntryOptions.SlidingExpiration), - "The sliding expiration value must be positive.", - TimeSpan.Zero); - } - - // async twin to ExceptionAssert.ThrowsArgumentOutOfRange - private static async Task ThrowsArgumentOutOfRangeAsync(Func test, string paramName, string message, object actualValue) - { - var ex = await Assert.ThrowsAsync(test); - if (paramName is not null) - { - Assert.Equal(paramName, ex.ParamName); - } - - if (message is not null) - { - Assert.StartsWith(message, ex.Message); // can have "\r\nParameter name:" etc - } - - if (actualValue is not null) - { - Assert.Equal(actualValue, ex.ActualValue); - } - } - - private IDistributedCache CreateCacheInstance() - { - return new NatsCache( - Microsoft.Extensions.Options.Options.Create(new NatsCacheOptions - { - BucketName = "cache" - }), - _mockNatsConnection.Object); - } -} From d72147331819e6fd69387528183e2bbfebc2cb4b Mon Sep 17 00:00:00 2001 From: Caleb Lloyd Date: Sun, 11 May 2025 02:20:36 -0400 Subject: [PATCH 4/4] serializer registry test Signed-off-by: Caleb Lloyd --- .../NatsHybridCacheSerializerFactoryTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/UnitTests/Serialization/NatsHybridCacheSerializerFactoryTests.cs diff --git a/test/UnitTests/Serialization/NatsHybridCacheSerializerFactoryTests.cs b/test/UnitTests/Serialization/NatsHybridCacheSerializerFactoryTests.cs new file mode 100644 index 0000000..b63b3b1 --- /dev/null +++ b/test/UnitTests/Serialization/NatsHybridCacheSerializerFactoryTests.cs @@ -0,0 +1,65 @@ +using System.Buffers; +using Microsoft.Extensions.Caching.Hybrid; +using Moq; +using NATS.Client.Core; +using NATS.Net; + +namespace CodeCargo.NatsDistributedCache.UnitTests.Serialization; + +/// +/// Tests for the NatsHybridCacheSerializerFactory +/// +public class NatsHybridCacheSerializerFactoryTests : TestBase +{ + [Fact] + public void TryCreateSerializer_String_CreatesSerializer() + { + // Arrange + var serializerRegistry = NatsOpts.Default.SerializerRegistry; + var factory = new NatsHybridCacheSerializerFactory(serializerRegistry); + + // Act + var result = factory.TryCreateSerializer(out var serializer); + + // Assert + Assert.True(result); + Assert.NotNull(serializer); + + // Test serialization and deserialization to ensure it works end-to-end + const string testValue = "Hello, NatsHybridCacheSerializer!"; + var writer = new ArrayBufferWriter(); + + // Serialize + serializer.Serialize(testValue, writer); + + // Deserialize + var sequence = new ReadOnlySequence(writer.WrittenMemory); + var deserializedValue = serializer.Deserialize(sequence); + + // Verify + Assert.Equal(testValue, deserializedValue); + } + + [Fact] + public void TryCreateSerializer_UnsupportedType_ReturnsFalse() + { + // Arrange - Create a mock registry that throws exceptions when accessed + var mockRegistry = new Mock(); + mockRegistry.Setup(r => r.GetSerializer()) + .Throws(); + mockRegistry.Setup(r => r.GetDeserializer()) + .Throws(); + + var factory = new NatsHybridCacheSerializerFactory(mockRegistry.Object); + + // Act + var result = factory.TryCreateSerializer(out var serializer); + + // Assert + Assert.False(result); + Assert.Null(serializer); + + // Verify the serializer was requested + mockRegistry.Verify(r => r.GetSerializer(), Times.Once); + } +}