From d3ea560941cc1c246468f859693c264a37f978d3 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Jul 2026 09:07:03 -0700 Subject: [PATCH 1/5] #38 add bucket auto creation option Signed-off-by: Matthew DeVenny --- README.md | 69 ++++++++++++---- src/NatsDistributedCache/NatsCache.cs | 36 +++++++- src/NatsDistributedCache/NatsCacheOptions.cs | 36 ++++++++ .../Cache/BucketAutoCreationTests.cs | 82 +++++++++++++++++++ test/UnitTests/Cache/BucketConfigUnitTests.cs | 81 ++++++++++++++++++ .../NatsDistributedCacheExtensionsTests.cs | 36 ++++++++ .../ReadmeExample/DistributedCache.Example.cs | 11 +-- util/ReadmeExample/DistributedCache.cs | 14 +--- util/ReadmeExample/HybridCache.Example.cs | 14 +--- util/ReadmeExample/HybridCache.cs | 14 +--- 10 files changed, 335 insertions(+), 58 deletions(-) create mode 100644 test/IntegrationTests/Cache/BucketAutoCreationTests.cs create mode 100644 test/UnitTests/Cache/BucketConfigUnitTests.cs diff --git a/README.md b/README.md index 81e0795..ee1efaa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ A .NET 8+ library (tested on .NET 8 and .NET 10) for using NATS with `HybridCach ## Requirements - NATS 2.11 or later -- A NATS KV bucket with `LimitMarkerTTL` set for per-key TTL support. Example: +- A NATS KV bucket with `LimitMarkerTTL` set for per-key TTL support. Either enable + [automatic bucket creation](#automatic-bucket-creation) (`options.CreateBucketIfNotExists = true`), or + pre-create the bucket yourself: ```csharp // assuming an INatsConnection natsConnection var kvContext = natsConnection.CreateKeyValueStoreContext(); @@ -37,8 +39,6 @@ dotnet add package NATS.Extensions.Microsoft.DependencyInjection using CodeCargo.Nats.HybridCacheExtensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; using NATS.Net; @@ -56,19 +56,15 @@ builder.ConfigureServices(services => services.AddNatsHybridCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + // Omit this if you pre-create the bucket yourself (see Requirements). + options.CreateBucketIfNotExists = true; }); }); var host = builder.Build(); -// Ensure that the KV Store is created -var natsConnection = host.Services.GetRequiredService(); -var kvContext = natsConnection.CreateKeyValueStoreContext(); -await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") -{ - LimitMarkerTTL = TimeSpan.FromSeconds(1) -}); - // Start the host await host.RunAsync(); ``` @@ -88,8 +84,6 @@ dotnet add package NATS.Extensions.Microsoft.DependencyInjection using CodeCargo.Nats.DistributedCache; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; using NATS.Net; @@ -107,20 +101,59 @@ builder.ConfigureServices(services => services.AddNatsDistributedCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + // Omit this if you pre-create the bucket yourself (see Requirements). + options.CreateBucketIfNotExists = true; }); }); var host = builder.Build(); -// Ensure that the KV Store is created -var natsConnection = host.Services.GetRequiredService(); -var kvContext = natsConnection.CreateKeyValueStoreContext(); -await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }); - // Start the host await host.RunAsync(); ``` +## Automatic bucket creation + +By default the KV bucket must already exist. Set `CreateBucketIfNotExists = true` to have the cache +create it (via `CreateOrUpdateStoreAsync`) on first use, with the settings per-key TTL requires — +`History = 1` and a non-zero `LimitMarkerTTL`: + +```csharp +services.AddNatsDistributedCache(options => +{ + options.BucketName = "cache"; + options.CreateBucketIfNotExists = true; +}); +``` + +To customize storage, replication, or size limits, use `ConfigureBucket`. `NatsKVConfig` is an +immutable record, so return a modified copy with a `with` expression: + +```csharp +using NATS.Client.KeyValueStore; + +services.AddNatsDistributedCache(options => +{ + options.BucketName = "cache"; + options.CreateBucketIfNotExists = true; + options.ConfigureBucket = config => config with + { + Storage = NatsKVStorageType.File, + NumberOfReplicas = 3, + }; +}); +``` + +Notes: + +- Creating or updating a bucket requires JetStream stream-management permissions. +- Because `CreateOrUpdateStoreAsync` is used, an existing bucket is also updated to match the resolved + config; immutable properties (for example `Storage`) can't be changed on an existing bucket. +- Overriding `History` (away from `1`) or clearing `LimitMarkerTTL` in `ConfigureBucket` disables + reliable per-key TTL. + ## Controlling Expiration Timing Expiration is computed from a [`TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider), diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index f35765a..0d088bc 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -29,7 +29,12 @@ public partial class NatsCache : IBufferDistributedCache // Compact binary serializer for the CacheEntry envelope (replaces the previous JSON+base64 format). private static readonly CacheEntryBinarySerializer CacheEntrySerializer = CacheEntryBinarySerializer.Default; + // Non-zero LimitMarkerTTL enables per-key TTL (NATS 2.11+); the actual per-key expiry is set per Put. + private static readonly TimeSpan DefaultLimitMarkerTtl = TimeSpan.FromSeconds(1); + private readonly string _bucketName; + private readonly bool _createBucketIfNotExists; + private readonly Func? _configureBucket; private readonly INatsCacheKeyEncoder _keyEncoder; private readonly string _keyPrefix; private readonly ILogger _logger; @@ -49,6 +54,8 @@ public NatsCache( _keyPrefix = string.IsNullOrEmpty(options.CacheKeyPrefix) ? string.Empty : options.CacheKeyPrefix.TrimEnd('.'); + _createBucketIfNotExists = options.CreateBucketIfNotExists; + _configureBucket = options.ConfigureBucket; _lazyKvStore = CreateLazyKvStore(); _natsConnection = natsConnection; _logger = logger ?? NullLogger.Instance; @@ -252,6 +259,31 @@ internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions internal bool IsAbsolutelyExpired(CacheEntry entry) => entry.AbsoluteExpiration.HasValue && TimeProvider.GetUtcNow() >= entry.AbsoluteExpiration.Value; + // Builds the NatsKVConfig used when CreateBucketIfNotExists is enabled. Pure and synchronous (does not + // touch the NATS connection), so it is unit-testable without a server. Cache-appropriate defaults are + // applied first, then the user hook (a record `with` transform) may override them. The bucket name is + // re-asserted afterward so the hook cannot retarget creation to a bucket other than the one GetKvStore + // reads from. + internal NatsKVConfig BuildBucketConfig() + { + var config = new NatsKVConfig(_bucketName) + { + History = 1, // required for well-defined per-key TTL behavior + LimitMarkerTTL = DefaultLimitMarkerTtl, // non-zero => enables per-key TTL (NATS 2.11+) + }; + + if (_configureBucket != null) + { + config = _configureBucket(config); + if (config.Bucket != _bucketName) + { + config = config with { Bucket = _bucketName }; + } + } + + return config; + } + // Resolves the effective absolute expiration instant: a relative expiration (offset from the // current clock) takes precedence over an explicit absolute expiration when both are set. private DateTimeOffset? ResolveAbsoluteExpiration(DistributedCacheEntryOptions options) => @@ -270,7 +302,9 @@ private Lazy> CreateLazyKvStore() => try { var kv = _natsConnection.CreateKeyValueStoreContext(); - var store = await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); + var store = _createBucketIfNotExists + ? await kv.CreateOrUpdateStoreAsync(BuildBucketConfig()).ConfigureAwait(false) + : await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); LogConnected(_bucketName); return store; } diff --git a/src/NatsDistributedCache/NatsCacheOptions.cs b/src/NatsDistributedCache/NatsCacheOptions.cs index 1fd79f6..8049416 100644 --- a/src/NatsDistributedCache/NatsCacheOptions.cs +++ b/src/NatsDistributedCache/NatsCacheOptions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using NATS.Client.KeyValueStore; namespace CodeCargo.Nats.DistributedCache { @@ -22,6 +23,41 @@ public class NatsCacheOptions : IOptions /// public string? CacheKeyPrefix { get; set; } + /// + /// When , the KV bucket is created (via + /// CreateOrUpdateStoreAsync) the first time the cache is used, if it does not already exist. + /// Defaults to , in which case the bucket must be pre-created by the operator. + /// + /// + /// Because CreateOrUpdateStoreAsync is used, an existing bucket is also updated to match the + /// resolved ; immutable properties (for example Storage) cannot be + /// changed on an existing bucket and will surface an error on first use. Creating or updating a bucket + /// requires JetStream stream-management permissions. + /// + public bool CreateBucketIfNotExists { get; set; } + + /// + /// Optional hook to customize the used when + /// is enabled (for example Storage, + /// NumberOfReplicas, MaxBytes, or MaxAge). Ignored when + /// is . + /// + /// + /// + /// is an immutable record, so the hook receives the pre-populated config + /// and returns a modified copy using a with expression, for example + /// options.ConfigureBucket = cfg => cfg with { Storage = NatsKVStorageType.Memory };. + /// + /// + /// The library pre-populates the config with cache-appropriate defaults — History = 1 and a + /// non-zero LimitMarkerTTL, both required for per-key TTL on NATS 2.11+ — before this hook runs, + /// so the hook can override any property. The Bucket name is always forced back to + /// afterward. Overriding History to a value other than 1, or clearing + /// LimitMarkerTTL, disables reliable per-key TTL. + /// + /// + public Func? ConfigureBucket { get; set; } + NatsCacheOptions IOptions.Value => this; } } diff --git a/test/IntegrationTests/Cache/BucketAutoCreationTests.cs b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs new file mode 100644 index 0000000..6b37e7e --- /dev/null +++ b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs @@ -0,0 +1,82 @@ +using CodeCargo.Nats.DistributedCache.TestUtils; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using NATS.Client.KeyValueStore; +using NATS.Net; + +namespace CodeCargo.Nats.DistributedCache.IntegrationTests.Cache; + +/// +/// Verifies opt-in bucket auto-creation (issue #38). Uses a bucket distinct from the shared "cache" +/// bucket so it never collides with 's KV_cache purge, and deletes the +/// auto-created bucket on teardown so nothing leaks across the collection lifetime. +/// +[Collection(NatsCollection.Name)] +public class BucketAutoCreationTests : IAsyncLifetime +{ + private const string BucketName = "auto-created-cache"; + private const string Key = "auto-create-key"; + + private readonly NatsIntegrationFixture _fixture; + private readonly ServiceProvider _serviceProvider; + + public BucketAutoCreationTests(NatsIntegrationFixture fixture) + { + _fixture = fixture; + + var services = new ServiceCollection(); + services.AddLogging(); + fixture.ConfigureServices(services); + services.AddNatsDistributedCache(options => + { + options.BucketName = BucketName; + options.CreateBucketIfNotExists = true; + + // Memory storage keeps the test light and exercises the ConfigureBucket hook end-to-end. + options.ConfigureBucket = cfg => cfg with { Storage = NatsKVStorageType.Memory }; + }); + _serviceProvider = services.BuildServiceProvider(); + } + + [Fact] + public async Task FirstOperation_AutoCreatesBucket_WithHistoryOneAndLimitMarkerTtl() + { + var ct = TestContext.Current.CancellationToken; + var cache = _serviceProvider.GetRequiredService(); + var value = new byte[] { 1, 2, 3 }; + + // The first cache operation triggers the lazy CreateOrUpdateStoreAsync against a missing bucket. + await cache.SetAsync( + Key, + value, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }, + ct); + + Assert.Equal(value, await cache.GetAsync(Key, ct)); + + // The bucket now exists with the cache-required config: History = 1 and a non-zero LimitMarkerTTL. + var kv = _fixture.NatsConnection.CreateKeyValueStoreContext(); + var status = await (await kv.GetStoreAsync(BucketName, ct)).GetStatusAsync(ct); + Assert.Equal(BucketName, status.Bucket); + Assert.NotEqual(TimeSpan.Zero, status.LimitMarkerTTL); + Assert.Equal(1, status.Info.Config.MaxMsgsPerSubject); // KV History maps to MaxMsgsPerSubject + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public async ValueTask DisposeAsync() + { + try + { + var kv = _fixture.NatsConnection.CreateKeyValueStoreContext(); + await kv.DeleteStoreAsync(BucketName, TestContext.Current.CancellationToken); + } + catch + { + // Best-effort cleanup; the memory-backed bucket is discarded when the server stops regardless. + } + + await _serviceProvider.DisposeAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/test/UnitTests/Cache/BucketConfigUnitTests.cs b/test/UnitTests/Cache/BucketConfigUnitTests.cs new file mode 100644 index 0000000..0de6711 --- /dev/null +++ b/test/UnitTests/Cache/BucketConfigUnitTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Options; +using Moq; +using NATS.Client.Core; +using NATS.Client.KeyValueStore; + +namespace CodeCargo.Nats.DistributedCache.UnitTests.Cache; + +public class BucketConfigUnitTests +{ + private const string BucketName = "cache"; + + [Fact] + public void BuildBucketConfig_SetsBucketName() + { + var config = CreateCache().BuildBucketConfig(); + + Assert.Equal(BucketName, config.Bucket); + } + + [Fact] + public void BuildBucketConfig_DefaultsHistoryToOne() + { + var config = CreateCache().BuildBucketConfig(); + + Assert.Equal(1, config.History); + } + + [Fact] + public void BuildBucketConfig_SetsNonZeroLimitMarkerTtl() + { + var config = CreateCache().BuildBucketConfig(); + + Assert.NotEqual(TimeSpan.Zero, config.LimitMarkerTTL); + Assert.Equal(TimeSpan.FromSeconds(1), config.LimitMarkerTTL); + } + + [Fact] + public void BuildBucketConfig_InvokesConfigureBucketHook() + { + var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with + { + Storage = NatsKVStorageType.Memory, + NumberOfReplicas = 3, + }).BuildBucketConfig(); + + Assert.Equal(NatsKVStorageType.Memory, config.Storage); + Assert.Equal(3, config.NumberOfReplicas); + } + + [Fact] + public void BuildBucketConfig_HookCanOverrideDefaults() + { + var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with + { + History = 5, + LimitMarkerTTL = TimeSpan.FromSeconds(30), + }).BuildBucketConfig(); + + // The hook runs after the defaults, so it wins (documented as caller responsibility for TTL correctness). + Assert.Equal(5, config.History); + Assert.Equal(TimeSpan.FromSeconds(30), config.LimitMarkerTTL); + } + + [Fact] + public void BuildBucketConfig_ReassertsBucketName_WhenHookChangesIt() + { + // The hook must not be able to retarget creation to a different bucket than the cache reads from. + var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with { Bucket = "some-other-bucket" }) + .BuildBucketConfig(); + + Assert.Equal(BucketName, config.Bucket); + } + + // BuildBucketConfig never touches the connection, so a bare mock is sufficient and no server is needed. + private static NatsCache CreateCache(Action? configure = null) + { + var options = new NatsCacheOptions { BucketName = BucketName, CreateBucketIfNotExists = true }; + configure?.Invoke(options); + return new NatsCache(Options.Create(options), new Mock().Object); + } +} diff --git a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs index 2af5542..3f5ed19 100644 --- a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs +++ b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Time.Testing; using Moq; using NATS.Client.Core; +using NATS.Client.KeyValueStore; namespace CodeCargo.Nats.DistributedCache.UnitTests.Extensions; @@ -79,6 +80,41 @@ public void AddNatsCache_SetsCacheOptions() Assert.Equal("cache", options.BucketName); } + [Fact] + public void AddNatsCache_SetsBucketCreationOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(_mockNatsConnection.Object); + Func configureBucket = cfg => cfg; + + // Act + services.AddNatsDistributedCache(options => + { + options.BucketName = "cache"; + options.CreateBucketIfNotExists = true; + options.ConfigureBucket = configureBucket; + }); + + // Build the provider to verify options + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + // Assert + Assert.True(options.CreateBucketIfNotExists); + Assert.Same(configureBucket, options.ConfigureBucket); + } + + [Fact] + public void NatsCacheOptions_BucketCreation_DefaultsToDisabled() + { + // Guards the "disabled by default / existing behavior unchanged" contract for issue #38. + var options = new NatsCacheOptions(); + + Assert.False(options.CreateBucketIfNotExists); + Assert.Null(options.ConfigureBucket); + } + [Fact] public void AddNatsCache_UsesCacheOptionsAction() { diff --git a/util/ReadmeExample/DistributedCache.Example.cs b/util/ReadmeExample/DistributedCache.Example.cs index ddedcdc..28f7e74 100644 --- a/util/ReadmeExample/DistributedCache.Example.cs +++ b/util/ReadmeExample/DistributedCache.Example.cs @@ -1,8 +1,6 @@ using CodeCargo.Nats.DistributedCache; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; using NATS.Net; @@ -25,16 +23,15 @@ public static async Task Run(string[] args) services.AddNatsDistributedCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + // Omit this if you pre-create the bucket yourself (see Requirements). + options.CreateBucketIfNotExists = true; }); }); var host = builder.Build(); - // Ensure that the KV Store is created - var natsConnection = host.Services.GetRequiredService(); - var kvContext = natsConnection.CreateKeyValueStoreContext(); - await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }); - // Start the host await host.RunAsync(); } diff --git a/util/ReadmeExample/DistributedCache.cs b/util/ReadmeExample/DistributedCache.cs index 616e743..48f43de 100644 --- a/util/ReadmeExample/DistributedCache.cs +++ b/util/ReadmeExample/DistributedCache.cs @@ -5,10 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; -using NATS.Net; namespace CodeCargo.ReadmeExample; @@ -47,6 +44,9 @@ public static async Task RunAsync(string[] args) services.AddNatsDistributedCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + options.CreateBucketIfNotExists = true; }); services.AddScoped(); @@ -55,14 +55,6 @@ public static async Task RunAsync(string[] args) var host = builder.Build(); var lifetime = host.Services.GetRequiredService(); - // Ensure that the KV Store is created - Console.WriteLine("Creating KV store..."); - var natsConnection = host.Services.GetRequiredService(); - var kvContext = natsConnection.CreateKeyValueStoreContext(); - await kvContext.CreateOrUpdateStoreAsync( - new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }, startupCts.Token); - Console.WriteLine("KV store created"); - // Start the host Console.WriteLine("Starting app..."); using var appCts = new CancellationTokenSource(); diff --git a/util/ReadmeExample/HybridCache.Example.cs b/util/ReadmeExample/HybridCache.Example.cs index 53a8cff..cb514a6 100644 --- a/util/ReadmeExample/HybridCache.Example.cs +++ b/util/ReadmeExample/HybridCache.Example.cs @@ -1,8 +1,6 @@ using CodeCargo.Nats.HybridCacheExtensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; using NATS.Net; @@ -25,19 +23,15 @@ public static async Task Run(string[] args) services.AddNatsHybridCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + // Omit this if you pre-create the bucket yourself (see Requirements). + options.CreateBucketIfNotExists = true; }); }); var host = builder.Build(); - // Ensure that the KV Store is created - var natsConnection = host.Services.GetRequiredService(); - var kvContext = natsConnection.CreateKeyValueStoreContext(); - await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") - { - LimitMarkerTTL = TimeSpan.FromSeconds(1) - }); - // Start the host await host.RunAsync(); } diff --git a/util/ReadmeExample/HybridCache.cs b/util/ReadmeExample/HybridCache.cs index ffbe8af..cd9fe19 100644 --- a/util/ReadmeExample/HybridCache.cs +++ b/util/ReadmeExample/HybridCache.cs @@ -5,10 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using NATS.Client.Core; -using NATS.Client.KeyValueStore; using NATS.Extensions.Microsoft.DependencyInjection; -using NATS.Net; namespace CodeCargo.ReadmeExample; @@ -48,6 +45,9 @@ public static async Task RunAsync(string[] args) services.AddNatsHybridCache(options => { options.BucketName = "cache"; + + // Create the KV bucket on first use if it doesn't already exist. + options.CreateBucketIfNotExists = true; }); services.AddScoped(); @@ -56,14 +56,6 @@ public static async Task RunAsync(string[] args) var host = builder.Build(); var lifetime = host.Services.GetRequiredService(); - // Ensure that the KV Store is created - Console.WriteLine("Creating KV store..."); - var natsConnection = host.Services.GetRequiredService(); - var kvContext = natsConnection.CreateKeyValueStoreContext(); - await kvContext.CreateOrUpdateStoreAsync( - new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }, startupCts.Token); - Console.WriteLine("KV store created"); - // Start the host Console.WriteLine("Starting app..."); using var appCts = new CancellationTokenSource(); From 1b542d2305371060825ef63bf691bc0634297a42 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Jul 2026 09:41:45 -0700 Subject: [PATCH 2/5] #38 address PR review: create-only bucket semantics + null guard - Only create a missing bucket; never update an existing (operator-managed) one, so the behavior matches the CreateBucketIfNotExists name. Check GetBucketNamesAsync and use CreateStoreAsync instead of CreateOrUpdateStoreAsync. - Guard against ConfigureBucket returning null with a clear InvalidOperationException. - Docs: reflect create-only semantics; show History = 1 in the manual snippet. - Tests: add null-guard unit test and an integration test asserting an existing bucket is left unmodified. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- README.md | 12 +- src/NatsDistributedCache/NatsCache.cs | 24 +++- src/NatsDistributedCache/NatsCacheOptions.cs | 19 ++- .../Cache/BucketAutoCreationTests.cs | 130 +++++++++++++----- test/UnitTests/Cache/BucketConfigUnitTests.cs | 9 ++ 5 files changed, 140 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index ee1efaa..3d60bd1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ A .NET 8+ library (tested on .NET 8 and .NET 10) for using NATS with `HybridCach ```csharp // assuming an INatsConnection natsConnection var kvContext = natsConnection.CreateKeyValueStoreContext(); - await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }); + await kvContext.CreateOrUpdateStoreAsync( + new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1), History = 1 }); ``` ## Use with `HybridCache` @@ -117,7 +118,7 @@ await host.RunAsync(); ## Automatic bucket creation By default the KV bucket must already exist. Set `CreateBucketIfNotExists = true` to have the cache -create it (via `CreateOrUpdateStoreAsync`) on first use, with the settings per-key TTL requires — +create it on first use if it is missing, with the settings per-key TTL requires — `History = 1` and a non-zero `LimitMarkerTTL`: ```csharp @@ -148,9 +149,10 @@ services.AddNatsDistributedCache(options => Notes: -- Creating or updating a bucket requires JetStream stream-management permissions. -- Because `CreateOrUpdateStoreAsync` is used, an existing bucket is also updated to match the resolved - config; immutable properties (for example `Storage`) can't be changed on an existing bucket. +- Only a missing bucket is created; an existing bucket is used as-is and never modified, so + operator-managed settings are preserved. `ConfigureBucket` therefore only applies when the bucket is + first created. +- Creating a bucket requires JetStream stream-management permissions. - Overriding `History` (away from `1`) or clearing `LimitMarkerTTL` in `ConfigureBucket` disables reliable per-key TTL. diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 0d088bc..937e3a2 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -274,7 +274,9 @@ internal NatsKVConfig BuildBucketConfig() if (_configureBucket != null) { - config = _configureBucket(config); + config = _configureBucket(config) + ?? throw new InvalidOperationException( + $"{nameof(NatsCacheOptions)}.{nameof(NatsCacheOptions.ConfigureBucket)} must not return null."); if (config.Bucket != _bucketName) { config = config with { Bucket = _bucketName }; @@ -296,6 +298,24 @@ private string GetEncodedKey(string key) => ? _keyEncoder.Encode(key) : _keyEncoder.Encode($"{_keyPrefix}.{key}"); + // Returns the KV store for the configured bucket, creating it first only if it does not already exist. + // An existing (operator-managed) bucket is used as-is and never modified — matching the + // CreateBucketIfNotExists contract — so CreateStoreAsync is used rather than CreateOrUpdateStoreAsync. + // A create that loses a race with a concurrent creator surfaces as a failure and is retried via the + // reset-on-failure path in CreateLazyKvStore. + private async Task GetOrCreateStoreAsync(INatsKVContext kv) + { + await foreach (var bucket in kv.GetBucketNamesAsync().ConfigureAwait(false)) + { + if (bucket == _bucketName) + { + return await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); + } + } + + return await kv.CreateStoreAsync(BuildBucketConfig()).ConfigureAwait(false); + } + private Lazy> CreateLazyKvStore() => new(async () => { @@ -303,7 +323,7 @@ private Lazy> CreateLazyKvStore() => { var kv = _natsConnection.CreateKeyValueStoreContext(); var store = _createBucketIfNotExists - ? await kv.CreateOrUpdateStoreAsync(BuildBucketConfig()).ConfigureAwait(false) + ? await GetOrCreateStoreAsync(kv).ConfigureAwait(false) : await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); LogConnected(_bucketName); return store; diff --git a/src/NatsDistributedCache/NatsCacheOptions.cs b/src/NatsDistributedCache/NatsCacheOptions.cs index 8049416..283eb47 100644 --- a/src/NatsDistributedCache/NatsCacheOptions.cs +++ b/src/NatsDistributedCache/NatsCacheOptions.cs @@ -24,23 +24,22 @@ public class NatsCacheOptions : IOptions public string? CacheKeyPrefix { get; set; } /// - /// When , the KV bucket is created (via - /// CreateOrUpdateStoreAsync) the first time the cache is used, if it does not already exist. - /// Defaults to , in which case the bucket must be pre-created by the operator. + /// When , the KV bucket is created the first time the + /// cache is used, if it does not already exist. Defaults to , in which case the + /// bucket must be pre-created by the operator. /// /// - /// Because CreateOrUpdateStoreAsync is used, an existing bucket is also updated to match the - /// resolved ; immutable properties (for example Storage) cannot be - /// changed on an existing bucket and will surface an error on first use. Creating or updating a bucket - /// requires JetStream stream-management permissions. + /// Only a missing bucket is created; an existing bucket is used as-is and never modified, so + /// operator-managed settings are preserved. Creating a bucket requires JetStream stream-management + /// permissions. /// public bool CreateBucketIfNotExists { get; set; } /// /// Optional hook to customize the used when - /// is enabled (for example Storage, - /// NumberOfReplicas, MaxBytes, or MaxAge). Ignored when - /// is . + /// is enabled and a missing bucket is created (for example + /// Storage, NumberOfReplicas, MaxBytes, or MaxAge). Ignored when + /// is or the bucket already exists. /// /// /// diff --git a/test/IntegrationTests/Cache/BucketAutoCreationTests.cs b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs index 6b37e7e..edd975a 100644 --- a/test/IntegrationTests/Cache/BucketAutoCreationTests.cs +++ b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs @@ -7,45 +7,29 @@ namespace CodeCargo.Nats.DistributedCache.IntegrationTests.Cache; /// -/// Verifies opt-in bucket auto-creation (issue #38). Uses a bucket distinct from the shared "cache" -/// bucket so it never collides with 's KV_cache purge, and deletes the -/// auto-created bucket on teardown so nothing leaks across the collection lifetime. +/// Verifies opt-in bucket auto-creation (issue #38). Uses buckets distinct from the shared "cache" +/// bucket so they never collide with 's KV_cache purge, and deletes any +/// bucket it touches on teardown so nothing leaks across the collection lifetime. /// [Collection(NatsCollection.Name)] -public class BucketAutoCreationTests : IAsyncLifetime +public class BucketAutoCreationTests(NatsIntegrationFixture fixture) : IAsyncLifetime { - private const string BucketName = "auto-created-cache"; private const string Key = "auto-create-key"; - private readonly NatsIntegrationFixture _fixture; - private readonly ServiceProvider _serviceProvider; - - public BucketAutoCreationTests(NatsIntegrationFixture fixture) - { - _fixture = fixture; - - var services = new ServiceCollection(); - services.AddLogging(); - fixture.ConfigureServices(services); - services.AddNatsDistributedCache(options => - { - options.BucketName = BucketName; - options.CreateBucketIfNotExists = true; - - // Memory storage keeps the test light and exercises the ConfigureBucket hook end-to-end. - options.ConfigureBucket = cfg => cfg with { Storage = NatsKVStorageType.Memory }; - }); - _serviceProvider = services.BuildServiceProvider(); - } + private readonly List _serviceProviders = new(); + private readonly List _bucketsToDelete = new(); [Fact] - public async Task FirstOperation_AutoCreatesBucket_WithHistoryOneAndLimitMarkerTtl() + public async Task FirstOperation_CreatesMissingBucket_WithHistoryOneAndLimitMarkerTtl() { + const string bucketName = "auto-created-cache"; var ct = TestContext.Current.CancellationToken; - var cache = _serviceProvider.GetRequiredService(); + + // Memory storage keeps the test light and exercises the ConfigureBucket hook end-to-end. + var cache = BuildCache(bucketName, cfg => cfg with { Storage = NatsKVStorageType.Memory }); var value = new byte[] { 1, 2, 3 }; - // The first cache operation triggers the lazy CreateOrUpdateStoreAsync against a missing bucket. + // The first cache operation triggers creation of the missing bucket. await cache.SetAsync( Key, value, @@ -55,28 +39,100 @@ await cache.SetAsync( Assert.Equal(value, await cache.GetAsync(Key, ct)); // The bucket now exists with the cache-required config: History = 1 and a non-zero LimitMarkerTTL. - var kv = _fixture.NatsConnection.CreateKeyValueStoreContext(); - var status = await (await kv.GetStoreAsync(BucketName, ct)).GetStatusAsync(ct); - Assert.Equal(BucketName, status.Bucket); + var status = await GetStatusAsync(bucketName, ct); + Assert.Equal(bucketName, status.Bucket); Assert.NotEqual(TimeSpan.Zero, status.LimitMarkerTTL); Assert.Equal(1, status.Info.Config.MaxMsgsPerSubject); // KV History maps to MaxMsgsPerSubject } + [Fact] + public async Task ExistingBucket_IsUsedAsIs_AndNeverModified() + { + const string bucketName = "operator-managed-cache"; + var ct = TestContext.Current.CancellationToken; + + // Operator pre-creates the bucket with a distinctive LimitMarkerTTL (5s). + var kv = fixture.NatsConnection.CreateKeyValueStoreContext(); + _bucketsToDelete.Add(bucketName); + await kv.CreateStoreAsync( + new NatsKVConfig(bucketName) + { + History = 1, + LimitMarkerTTL = TimeSpan.FromSeconds(5), + Storage = NatsKVStorageType.Memory, + }, + ct); + + // Enable auto-create with a hook that WOULD set a different LimitMarkerTTL (1s) if it created the bucket. + var cache = BuildCache( + bucketName, + cfg => cfg with { LimitMarkerTTL = TimeSpan.FromSeconds(1), Storage = NatsKVStorageType.Memory }, + trackForDeletion: false); + + await cache.SetAsync( + Key, + new byte[] { 9 }, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }, + ct); + + // The pre-existing bucket must be left untouched: LimitMarkerTTL is still the operator's 5s, not 1s. + var status = await GetStatusAsync(bucketName, ct); + Assert.Equal(TimeSpan.FromSeconds(5), status.LimitMarkerTTL); + } + public ValueTask InitializeAsync() => ValueTask.CompletedTask; public async ValueTask DisposeAsync() { - try + var kv = fixture.NatsConnection.CreateKeyValueStoreContext(); + foreach (var bucket in _bucketsToDelete) { - var kv = _fixture.NatsConnection.CreateKeyValueStoreContext(); - await kv.DeleteStoreAsync(BucketName, TestContext.Current.CancellationToken); + try + { + await kv.DeleteStoreAsync(bucket, TestContext.Current.CancellationToken); + } + catch + { + // Best-effort cleanup; memory-backed buckets are discarded when the server stops regardless. + } } - catch + + foreach (var sp in _serviceProviders) { - // Best-effort cleanup; the memory-backed bucket is discarded when the server stops regardless. + await sp.DisposeAsync(); } - await _serviceProvider.DisposeAsync(); GC.SuppressFinalize(this); } + + private IDistributedCache BuildCache( + string bucketName, + Func configureBucket, + bool trackForDeletion = true) + { + var services = new ServiceCollection(); + services.AddLogging(); + fixture.ConfigureServices(services); + services.AddNatsDistributedCache(options => + { + options.BucketName = bucketName; + options.CreateBucketIfNotExists = true; + options.ConfigureBucket = configureBucket; + }); + + var serviceProvider = services.BuildServiceProvider(); + _serviceProviders.Add(serviceProvider); + if (trackForDeletion) + { + _bucketsToDelete.Add(bucketName); + } + + return serviceProvider.GetRequiredService(); + } + + private async Task GetStatusAsync(string bucketName, CancellationToken ct) + { + var kv = fixture.NatsConnection.CreateKeyValueStoreContext(); + return await (await kv.GetStoreAsync(bucketName, ct)).GetStatusAsync(ct); + } } diff --git a/test/UnitTests/Cache/BucketConfigUnitTests.cs b/test/UnitTests/Cache/BucketConfigUnitTests.cs index 0de6711..38ab9e4 100644 --- a/test/UnitTests/Cache/BucketConfigUnitTests.cs +++ b/test/UnitTests/Cache/BucketConfigUnitTests.cs @@ -71,6 +71,15 @@ public void BuildBucketConfig_ReassertsBucketName_WhenHookChangesIt() Assert.Equal(BucketName, config.Bucket); } + [Fact] + public void BuildBucketConfig_ThrowsClearException_WhenHookReturnsNull() + { + var cache = CreateCache(o => o.ConfigureBucket = _ => null!); + + var ex = Assert.Throws(() => cache.BuildBucketConfig()); + Assert.Contains(nameof(NatsCacheOptions.ConfigureBucket), ex.Message); + } + // BuildBucketConfig never touches the connection, so a bare mock is sufficient and no server is needed. private static NatsCache CreateCache(Action? configure = null) { From 42ea645529df5cce038c5e241ec1486f85cd5c30 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Jul 2026 10:55:32 -0700 Subject: [PATCH 3/5] #38 address 2nd review: probe bucket via GetStoreAsync, not a full list - GetOrCreateStoreAsync now tries GetStoreAsync first and creates only on the JetStream "stream not found" error (ErrCode 10059). Avoids the O(n) GetBucketNamesAsync list call and its stream-list permission requirement; other errors (connectivity, auth) propagate unchanged. - README: make the manual pre-create snippet self-contained with the required NATS.Client.KeyValueStore / NATS.Net usings. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- README.md | 3 +++ src/NatsDistributedCache/NatsCache.cs | 28 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3d60bd1..5d24fb9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ A .NET 8+ library (tested on .NET 8 and .NET 10) for using NATS with `HybridCach [automatic bucket creation](#automatic-bucket-creation) (`options.CreateBucketIfNotExists = true`), or pre-create the bucket yourself: ```csharp + using NATS.Client.KeyValueStore; + using NATS.Net; + // assuming an INatsConnection natsConnection var kvContext = natsConnection.CreateKeyValueStoreContext(); await kvContext.CreateOrUpdateStoreAsync( diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 937e3a2..77d970a 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NATS.Client.Core; +using NATS.Client.JetStream; using NATS.Client.KeyValueStore; using NATS.Net; @@ -26,6 +27,10 @@ public class CacheEntry /// public partial class NatsCache : IBufferDistributedCache { + // JetStream "stream not found" error code (JSStreamNotFoundErr), returned by GetStoreAsync when the + // KV bucket does not exist. + private const int StreamNotFoundErrCode = 10059; + // Compact binary serializer for the CacheEntry envelope (replaces the previous JSON+base64 format). private static readonly CacheEntryBinarySerializer CacheEntrySerializer = CacheEntryBinarySerializer.Default; @@ -298,22 +303,23 @@ private string GetEncodedKey(string key) => ? _keyEncoder.Encode(key) : _keyEncoder.Encode($"{_keyPrefix}.{key}"); - // Returns the KV store for the configured bucket, creating it first only if it does not already exist. - // An existing (operator-managed) bucket is used as-is and never modified — matching the + // Returns the KV store for the configured bucket, creating it only if it does not already exist. An + // existing (operator-managed) bucket is used as-is and never modified — matching the // CreateBucketIfNotExists contract — so CreateStoreAsync is used rather than CreateOrUpdateStoreAsync. - // A create that loses a race with a concurrent creator surfaces as a failure and is retried via the - // reset-on-failure path in CreateLazyKvStore. + // GetStoreAsync is attempted first (rather than listing every bucket) so this stays O(1) and needs no + // stream-list permission; only a genuine "stream not found" triggers creation, and any other error + // (connectivity, auth) propagates unchanged. A create that loses a race with a concurrent creator + // surfaces as a failure and is retried via the reset-on-failure path in CreateLazyKvStore. private async Task GetOrCreateStoreAsync(INatsKVContext kv) { - await foreach (var bucket in kv.GetBucketNamesAsync().ConfigureAwait(false)) + try { - if (bucket == _bucketName) - { - return await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); - } + return await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); + } + catch (NatsJSApiException ex) when (ex.Error.ErrCode == StreamNotFoundErrCode) + { + return await kv.CreateStoreAsync(BuildBucketConfig()).ConfigureAwait(false); } - - return await kv.CreateStoreAsync(BuildBucketConfig()).ConfigureAwait(false); } private Lazy> CreateLazyKvStore() => From 720a4f999de89c9f984b3aeb590543cbce21ebf3 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Jul 2026 11:25:06 -0700 Subject: [PATCH 4/5] #38 address 3rd review: null-safe pattern for stream-not-found filter Use `ex.Error is { ErrCode: StreamNotFoundErrCode }` so a null Error makes the catch filter evaluate false instead of throwing (which would silently skip auto-creation of a missing bucket). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- src/NatsDistributedCache/NatsCache.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 77d970a..24b6a2a 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -316,8 +316,9 @@ private async Task GetOrCreateStoreAsync(INatsKVContext kv) { return await kv.GetStoreAsync(_bucketName).ConfigureAwait(false); } - catch (NatsJSApiException ex) when (ex.Error.ErrCode == StreamNotFoundErrCode) + catch (NatsJSApiException ex) when (ex.Error is { ErrCode: StreamNotFoundErrCode }) { + // Null-safe pattern: a null Error simply doesn't match, so the filter is false rather than throwing. return await kv.CreateStoreAsync(BuildBucketConfig()).ConfigureAwait(false); } } From bdfb762feb82e9358372c708841d9dc061b6d854 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Jul 2026 13:34:47 -0700 Subject: [PATCH 5/5] #38 rename ConfigureBucket -> ConfigureBucketOnCreate Per review feedback, the hook only applies when a missing bucket is first created (it's a no-op for an existing bucket). The clearer name reflects that. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- README.md | 8 ++++---- src/NatsDistributedCache/NatsCache.cs | 10 +++++----- src/NatsDistributedCache/NatsCacheOptions.cs | 4 ++-- .../Cache/BucketAutoCreationTests.cs | 4 ++-- test/UnitTests/Cache/BucketConfigUnitTests.cs | 12 ++++++------ .../NatsDistributedCacheExtensionsTests.cs | 6 +++--- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5d24fb9..0c8761e 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ services.AddNatsDistributedCache(options => }); ``` -To customize storage, replication, or size limits, use `ConfigureBucket`. `NatsKVConfig` is an +To customize storage, replication, or size limits, use `ConfigureBucketOnCreate`. `NatsKVConfig` is an immutable record, so return a modified copy with a `with` expression: ```csharp @@ -142,7 +142,7 @@ services.AddNatsDistributedCache(options => { options.BucketName = "cache"; options.CreateBucketIfNotExists = true; - options.ConfigureBucket = config => config with + options.ConfigureBucketOnCreate = config => config with { Storage = NatsKVStorageType.File, NumberOfReplicas = 3, @@ -153,10 +153,10 @@ services.AddNatsDistributedCache(options => Notes: - Only a missing bucket is created; an existing bucket is used as-is and never modified, so - operator-managed settings are preserved. `ConfigureBucket` therefore only applies when the bucket is + operator-managed settings are preserved. `ConfigureBucketOnCreate` therefore only applies when the bucket is first created. - Creating a bucket requires JetStream stream-management permissions. -- Overriding `History` (away from `1`) or clearing `LimitMarkerTTL` in `ConfigureBucket` disables +- Overriding `History` (away from `1`) or clearing `LimitMarkerTTL` in `ConfigureBucketOnCreate` disables reliable per-key TTL. ## Controlling Expiration Timing diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 24b6a2a..a37d187 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -39,7 +39,7 @@ public partial class NatsCache : IBufferDistributedCache private readonly string _bucketName; private readonly bool _createBucketIfNotExists; - private readonly Func? _configureBucket; + private readonly Func? _configureBucketOnCreate; private readonly INatsCacheKeyEncoder _keyEncoder; private readonly string _keyPrefix; private readonly ILogger _logger; @@ -60,7 +60,7 @@ public NatsCache( ? string.Empty : options.CacheKeyPrefix.TrimEnd('.'); _createBucketIfNotExists = options.CreateBucketIfNotExists; - _configureBucket = options.ConfigureBucket; + _configureBucketOnCreate = options.ConfigureBucketOnCreate; _lazyKvStore = CreateLazyKvStore(); _natsConnection = natsConnection; _logger = logger ?? NullLogger.Instance; @@ -277,11 +277,11 @@ internal NatsKVConfig BuildBucketConfig() LimitMarkerTTL = DefaultLimitMarkerTtl, // non-zero => enables per-key TTL (NATS 2.11+) }; - if (_configureBucket != null) + if (_configureBucketOnCreate != null) { - config = _configureBucket(config) + config = _configureBucketOnCreate(config) ?? throw new InvalidOperationException( - $"{nameof(NatsCacheOptions)}.{nameof(NatsCacheOptions.ConfigureBucket)} must not return null."); + $"{nameof(NatsCacheOptions)}.{nameof(NatsCacheOptions.ConfigureBucketOnCreate)} must not return null."); if (config.Bucket != _bucketName) { config = config with { Bucket = _bucketName }; diff --git a/src/NatsDistributedCache/NatsCacheOptions.cs b/src/NatsDistributedCache/NatsCacheOptions.cs index 283eb47..67e95b8 100644 --- a/src/NatsDistributedCache/NatsCacheOptions.cs +++ b/src/NatsDistributedCache/NatsCacheOptions.cs @@ -45,7 +45,7 @@ public class NatsCacheOptions : IOptions /// /// is an immutable record, so the hook receives the pre-populated config /// and returns a modified copy using a with expression, for example - /// options.ConfigureBucket = cfg => cfg with { Storage = NatsKVStorageType.Memory };. + /// options.ConfigureBucketOnCreate = cfg => cfg with { Storage = NatsKVStorageType.Memory };. /// /// /// The library pre-populates the config with cache-appropriate defaults — History = 1 and a @@ -55,7 +55,7 @@ public class NatsCacheOptions : IOptions /// LimitMarkerTTL, disables reliable per-key TTL. /// /// - public Func? ConfigureBucket { get; set; } + public Func? ConfigureBucketOnCreate { get; set; } NatsCacheOptions IOptions.Value => this; } diff --git a/test/IntegrationTests/Cache/BucketAutoCreationTests.cs b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs index edd975a..4353ad4 100644 --- a/test/IntegrationTests/Cache/BucketAutoCreationTests.cs +++ b/test/IntegrationTests/Cache/BucketAutoCreationTests.cs @@ -25,7 +25,7 @@ public async Task FirstOperation_CreatesMissingBucket_WithHistoryOneAndLimitMark const string bucketName = "auto-created-cache"; var ct = TestContext.Current.CancellationToken; - // Memory storage keeps the test light and exercises the ConfigureBucket hook end-to-end. + // Memory storage keeps the test light and exercises the ConfigureBucketOnCreate hook end-to-end. var cache = BuildCache(bucketName, cfg => cfg with { Storage = NatsKVStorageType.Memory }); var value = new byte[] { 1, 2, 3 }; @@ -117,7 +117,7 @@ private IDistributedCache BuildCache( { options.BucketName = bucketName; options.CreateBucketIfNotExists = true; - options.ConfigureBucket = configureBucket; + options.ConfigureBucketOnCreate = configureBucket; }); var serviceProvider = services.BuildServiceProvider(); diff --git a/test/UnitTests/Cache/BucketConfigUnitTests.cs b/test/UnitTests/Cache/BucketConfigUnitTests.cs index 38ab9e4..7041716 100644 --- a/test/UnitTests/Cache/BucketConfigUnitTests.cs +++ b/test/UnitTests/Cache/BucketConfigUnitTests.cs @@ -35,9 +35,9 @@ public void BuildBucketConfig_SetsNonZeroLimitMarkerTtl() } [Fact] - public void BuildBucketConfig_InvokesConfigureBucketHook() + public void BuildBucketConfig_InvokesConfigureBucketOnCreateHook() { - var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with + var config = CreateCache(o => o.ConfigureBucketOnCreate = cfg => cfg with { Storage = NatsKVStorageType.Memory, NumberOfReplicas = 3, @@ -50,7 +50,7 @@ public void BuildBucketConfig_InvokesConfigureBucketHook() [Fact] public void BuildBucketConfig_HookCanOverrideDefaults() { - var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with + var config = CreateCache(o => o.ConfigureBucketOnCreate = cfg => cfg with { History = 5, LimitMarkerTTL = TimeSpan.FromSeconds(30), @@ -65,7 +65,7 @@ public void BuildBucketConfig_HookCanOverrideDefaults() public void BuildBucketConfig_ReassertsBucketName_WhenHookChangesIt() { // The hook must not be able to retarget creation to a different bucket than the cache reads from. - var config = CreateCache(o => o.ConfigureBucket = cfg => cfg with { Bucket = "some-other-bucket" }) + var config = CreateCache(o => o.ConfigureBucketOnCreate = cfg => cfg with { Bucket = "some-other-bucket" }) .BuildBucketConfig(); Assert.Equal(BucketName, config.Bucket); @@ -74,10 +74,10 @@ public void BuildBucketConfig_ReassertsBucketName_WhenHookChangesIt() [Fact] public void BuildBucketConfig_ThrowsClearException_WhenHookReturnsNull() { - var cache = CreateCache(o => o.ConfigureBucket = _ => null!); + var cache = CreateCache(o => o.ConfigureBucketOnCreate = _ => null!); var ex = Assert.Throws(() => cache.BuildBucketConfig()); - Assert.Contains(nameof(NatsCacheOptions.ConfigureBucket), ex.Message); + Assert.Contains(nameof(NatsCacheOptions.ConfigureBucketOnCreate), ex.Message); } // BuildBucketConfig never touches the connection, so a bare mock is sufficient and no server is needed. diff --git a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs index 3f5ed19..352ba69 100644 --- a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs +++ b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs @@ -93,7 +93,7 @@ public void AddNatsCache_SetsBucketCreationOptions() { options.BucketName = "cache"; options.CreateBucketIfNotExists = true; - options.ConfigureBucket = configureBucket; + options.ConfigureBucketOnCreate = configureBucket; }); // Build the provider to verify options @@ -102,7 +102,7 @@ public void AddNatsCache_SetsBucketCreationOptions() // Assert Assert.True(options.CreateBucketIfNotExists); - Assert.Same(configureBucket, options.ConfigureBucket); + Assert.Same(configureBucket, options.ConfigureBucketOnCreate); } [Fact] @@ -112,7 +112,7 @@ public void NatsCacheOptions_BucketCreation_DefaultsToDisabled() var options = new NatsCacheOptions(); Assert.False(options.CreateBucketIfNotExists); - Assert.Null(options.ConfigureBucket); + Assert.Null(options.ConfigureBucketOnCreate); } [Fact]