diff --git a/README.md b/README.md index c4203519..46e8986a 100644 --- a/README.md +++ b/README.md @@ -401,132 +401,117 @@ public bool VerifyWebhookManually(string body, string signature, string timestam ## Configuration Options -There are a number of configuration options that can be specified passing in `ClientOptions` as a second parameter when instantiating the Schematic client. +There are a number of configuration options that can be specified by passing `ClientOptions` as a second parameter when instantiating the Schematic client. -### Flag Check Options +### Cache Options -By default, the client will do some local caching for flag checks. If you would like to change this behavior, you can do so using an initialization option to specify the max size of the cache (in terms of number of cached items) and the max age of the cache: +The recommended way to configure caching is through the fluent helpers on `ClientOptions` or by setting `ClientOptions.CacheConfiguration` directly. + +By default an in-memory cache will be configured, but you can customize it further if required: ```csharp using SchematicHQ.Client; -using System.Collections.Generic; -int cacheMaxItems = 1000; // Max number of entries in the cache -TimeSpan cacheTtl = TimeSpan.FromSeconds(1); // Set TTL to 1 second - -var options = new ClientOptions -{ - CacheProviders = new List> - { - new LocalCache(cacheMaxItems, cacheTtl) - } -}; +var options = new ClientOptions() + .WithLocalCache(capacity: 10000, ttl: TimeSpan.FromSeconds(1)); Schematic schematic = new Schematic("YOUR_API_KEY", options); ``` -***Note about LocalCache:*** LocalCache implementation returns default value the type it is initiated with when it is a cache miss. Hence, when using with Schematic it is initiated with type (bool?) so that cache returns null when it is a miss - -### Redis Cache Configuration -For distributed applications or when you need persistent caching across application restarts, you can use Redis as your cache provider: +If you prefer to configure it explicitly, you can set `CacheConfiguration` yourself: ```csharp using SchematicHQ.Client; using SchematicHQ.Client.Cache; -using SchematicHQ.Client.Datastream; var options = new ClientOptions { CacheConfiguration = new CacheConfiguration { - ProviderType = CacheProviderType.Redis, - RedisConfig = new RedisCacheConfig - { - // Required: Redis server endpoints - Endpoints = new List { "redis.example.com:6379" }, - - // Optional: Authentication - Password = "your-redis-password", - Username = "default", // For Redis 6.0+ ACL - - // Optional: Connection settings - Ssl = true, - ConnectTimeout = 5000, // milliseconds - ConnectRetry = 3, - AbortOnConnectFail = false, // Continue if Redis is unavailable - - // Optional: Cache settings - KeyPrefix = "schematic:", - Database = 0, - CacheTTL = TimeSpan.FromMinutes(5) - } + ProviderType = CacheProviderType.Local, + LocalCacheCapacity = 10000, + CacheTtl = TimeSpan.FromSeconds(1) } }; +``` + +You can also disable local caching entirely; bear in mind that, in this case, every flag check will result in a network request: + +```csharp +using SchematicHQ.Client; + +var options = new ClientOptions() + .WithLocalCache(capacity: 0); Schematic schematic = new Schematic("YOUR_API_KEY", options); ``` -You can also use the fluent API for simpler Redis configuration: +### Redis Cache Configuration + +For distributed applications or when you want cache persistence across application restarts, use Redis: ```csharp +using SchematicHQ.Client; +using SchematicHQ.Client.Cache; + var options = new ClientOptions() .WithRedisCache(new RedisCacheConfig { - Endpoints = new List { "redis.example.com:6379" }, - Password = "secret", - Username = "default" // For Redis 6.0+ ACL + Configuration = "redis.example.com:6379", + KeyPrefix = "schematic:", + Database = 0, + CacheTTL = TimeSpan.FromMinutes(5) }); -``` -#### Redis Cluster Configuration +Schematic schematic = new Schematic("YOUR_API_KEY", options); +``` -For Redis Cluster deployments, use the `RedisCacheClusterConfig` class: +If you need more control over the connection, supply a `ConfigurationOptions` instance or a `ConnectionMultiplexerFactory`: ```csharp -var options = new ClientOptions -{ - CacheConfiguration = new CacheConfiguration - { - ProviderType = CacheProviderType.Redis, - RedisConfig = new RedisCacheClusterConfig - { - // Multiple cluster nodes - Endpoints = new List - { - "cluster1.example.com:6379", - "cluster2.example.com:6379", - "cluster3.example.com:6379" - }, - - // Authentication - Password = "cluster-password", - Username = "default", // For Redis 6.0+ ACL +using SchematicHQ.Client; +using SchematicHQ.Client.Cache; +using StackExchange.Redis; - // Cluster optimization - RouteByLatency = true, // Enable latency-based routing +var redisOptions = ConfigurationOptions.Parse("redis-primary.example.com:6379,redis-replica.example.com:6379"); +redisOptions.AbortOnConnectFail = false; +redisOptions.Ssl = true; - // Other settings - ConnectTimeout = 10000, - AbortOnConnectFail = false - } - } -}; +var options = new ClientOptions() + .WithRedisCache(new RedisCacheConfig + { + ConfigurationOptions = redisOptions, + KeyPrefix = "schematic:", + Database = 0, + CacheTTL = TimeSpan.FromMinutes(10) + }); ``` -You can also disable local caching entirely; bear in mind that, in this case, every flag check will result in a network request: +### Custom Cache Implementation + +If you want to provide your own cache backend, implement `ICacheProvider` and assign it to `ClientOptions.CacheProvider`: ```csharp using SchematicHQ.Client; -using System.Collections.Generic; +using SchematicHQ.Client.Cache; + +public sealed class MyCustomCache : ICacheProvider +{ + // ... +} var options = new ClientOptions { - CacheProviders = new List>() + CacheProvider = new MyCustomCache() }; -Schematic schematic = new Schematic("YOUR_API_KEY", options); +var schematic = new Schematic("YOUR_API_KEY", options); ``` +Custom caches are useful if you need specialized eviction, a different backing store, or additional observability around cache access. + +### Default Flags + You may want to specify default flag values for your application, which will be used if there is a service interruption or if the client is running in offline mode (see below): ```csharp @@ -705,79 +690,20 @@ using SchematicHQ.Client; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; -// Create options with Datastream configuration -var options = new ClientOptions -{ - // Enable Datastream (required) - UseDatastream = true, - - // Configure Datastream-specific options - DatastreamOptions = new DatastreamOptions() - // Use Redis cache for better performance in distributed environments - .WithRedisCache(new RedisCacheConfig - { - Endpoints = new List - { - "redis-primary.example.com:6379", - "redis-replica.example.com:6379" - }, - Password = "your-redis-password", // Optional - Username = "default", // Optional (Redis 6.0+ ACL) - KeyPrefix = "my-app:", - CacheTTL = TimeSpan.FromMinutes(10), - Database = 0, - Ssl = true // Enable SSL if needed - }) - // Or use local cache for single-instance applications - //.WithLocalCache(capacity: 10000, ttl: TimeSpan.FromMinutes(5)) -}; - -// Initialize client with options -var schematic = new Schematic("YOUR_API_KEY", options); -``` - -### Using Datastream with Shared Cache Configuration - -You can configure your main client cache and Datastream to use the same cache settings: - -```csharp -using SchematicHQ.Client; -using SchematicHQ.Client.Cache; - -// Configure with multiple Redis endpoints for high availability var options = new ClientOptions { - // Enable Datastream UseDatastream = true, - - // Create cache configuration with Redis settings (used by both main client and Datastream) - CacheConfiguration = new CacheConfiguration + DatastreamOptions = new DatastreamOptions { - ProviderType = CacheProviderType.Redis, - RedisConfig = new RedisCacheConfig - { - Endpoints = new List - { - "redis-1.example.com:6379", - "redis-2.example.com:6379", - "redis-3.example.com:6379" - }, - Password = "your-redis-password", // Optional - Username = "default", // Optional (Redis 6.0+) - KeyPrefix = "prod:", - CacheTTL = TimeSpan.FromMinutes(10), - Ssl = true, // Enable SSL if needed - ConnectTimeout = 5000, // Connection timeout in ms - AbortOnConnectFail = false // Continue if Redis is unavailable - } + CacheTTL = TimeSpan.FromMinutes(10) } }; -// Schematic will use the same cache configuration for both -// the main client and Datastream client var schematic = new Schematic("YOUR_API_KEY", options); ``` +`DatastreamOptions` currently controls Datastream-specific TTL behavior; the cache provider itself still comes from `ClientOptions.CacheConfiguration` (or `CacheProvider` for custom implementations). + ### Checking Flags with Datastream The flag checking experience remains the same whether Datastream is enabled or not: @@ -811,18 +737,14 @@ using SchematicHQ.Client; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; -var options = new ClientOptions -{ - UseDatastream = true, - - DatastreamOptions = new DatastreamOptions() - .WithRedisCache(new RedisCacheConfig - { - Endpoints = new List { "redis://localhost:6379" }, - KeyPrefix = "schematic-replica:", - CacheTTL = TimeSpan.FromHours(24) - }) -}; +var options = new ClientOptions() + .WithRedisCache(new RedisCacheConfig + { + Configuration = "localhost:6379", + KeyPrefix = "schematic-replica:", + CacheTTL = TimeSpan.FromHours(24) + }) + .WithReplicatorMode("https://health.your-app.com/schematic-replicator"); var schematic = new Schematic("YOUR_API_KEY", options); ``` @@ -833,26 +755,20 @@ var schematic = new Schematic("YOUR_API_KEY", options); using SchematicHQ.Client; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; +using StackExchange.Redis; -var options = new ClientOptions -{ - UseDatastream = true, - - DatastreamOptions = new DatastreamOptions() - .WithHealthCheckUrl("https://health.your-app.com/schematic-replicator") - .WithRedisCache(new RedisCacheConfig - { - Endpoints = new List - { - "redis-primary.example.com:6379", - "redis-replica.example.com:6379" - }, - KeyPrefix = "schematic-replica:", - CacheTTL = TimeSpan.FromHours(24), - ConnectTimeout = 10000, - AbortOnConnectFail = false - }) -}; +var redisOptions = ConfigurationOptions.Parse("redis-primary.example.com:6379,redis-replica.example.com:6379"); +redisOptions.AbortOnConnectFail = false; +redisOptions.Ssl = true; + +var options = new ClientOptions() + .WithRedisCache(new RedisCacheConfig + { + ConfigurationOptions = redisOptions, + KeyPrefix = "schematic-replica:", + CacheTTL = TimeSpan.FromHours(24) + }) + .WithReplicatorMode("https://health.your-app.com/schematic-replicator"); var schematic = new Schematic("YOUR_API_KEY", options); ``` @@ -861,7 +777,7 @@ var schematic = new Schematic("YOUR_API_KEY", options); | Configuration Method | Description | Example | |---------------------|-------------|---------| -| `.WithHealthCheckUrl(url)` | Sets a custom health check endpoint URL for monitoring replicator status | `"https://health.example.com/replicator"` | +| `.WithReplicatorMode(url)` | Enables replicator mode and sets the health check endpoint URL | `"https://health.example.com/replicator"` | | `.WithRedisCache(config)` | Configures Redis as the cache provider for replicator data | Required for replicator mode | ## Contributing diff --git a/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs index 465a5f1f..9c29c359 100644 --- a/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs @@ -14,39 +14,40 @@ namespace SchematicHQ.Client.Test.Cache [TestFixture] public class LocalCacheTests { + private static readonly TimeSpan UnlimitedTtl = TimeSpan.FromDays(36500); + [Test] - public void BackgroundCleanup_RemovesExpiredItems() + public async Task BackgroundCleanup_RemovesExpiredItems() { // Arrange var shortTtl = TimeSpan.FromMilliseconds(100); // Increased from 50ms for better reliability var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(shortTtl.TotalMilliseconds / 4, 25)); // Min 25ms var waitTime = shortTtl + cleanupInterval + TimeSpan.FromMilliseconds(100); // More buffer time - var cache = new LocalCache( - maxItems: 100, - ttl: shortTtl, - enableBackgroundCleanup: true); + var cache = new LocalCache( + maxItems: 100, + ttl: shortTtl); // Add test items for (int i = 0; i < 10; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } // Act - Wait for background cleanup to run with extended timeout // Retry logic to handle timing issues on different test environments bool allItemsRemoved = false; int maxRetries = 3; - + for (int retry = 0; retry < maxRetries && !allItemsRemoved; retry++) { Thread.Sleep(waitTime); - + // Check if all items are removed allItemsRemoved = true; for (int i = 0; i < 10; i++) { - if (cache.Get($"key{i}") != null) + if (await cache.Get($"key{i}") != null) { allItemsRemoved = false; break; @@ -57,56 +58,52 @@ public void BackgroundCleanup_RemovesExpiredItems() // Assert for (int i = 0; i < 10; i++) { - var result = cache.Get($"key{i}"); + var result = await cache.Get($"key{i}"); Assert.That(result, Is.Null, $"Item with key 'key{i}' should have been removed by background cleanup"); } - - // Cleanup - cache.Dispose(); // Cleanup cache.Dispose(); } [Test] - public void BackgroundCleanup_HandlesNewItemsAddedAfterCleanup() + public async Task BackgroundCleanup_HandlesNewItemsAddedAfterCleanup() { // Arrange var shortTtl = TimeSpan.FromMilliseconds(150); // Increased for reliability var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(shortTtl.TotalMilliseconds / 4, 25)); // Min 25ms var waitTime = shortTtl + cleanupInterval + TimeSpan.FromMilliseconds(100); // More buffer - var cache = new LocalCache( - maxItems: 100, - ttl: shortTtl, - enableBackgroundCleanup: true); + var cache = new LocalCache( + maxItems: 100, + ttl: shortTtl); // Add initial items for (int i = 0; i < 5; i++) { - cache.Set($"initial{i}", $"value{i}"); + await cache.Set($"initial{i}", $"value{i}"); } // Ensure the items were added properly for (int i = 0; i < 5; i++) { - Assert.That(cache.Get($"initial{i}"), Is.EqualTo($"value{i}"), + Assert.That(await cache.Get($"initial{i}"), Is.EqualTo($"value{i}"), "Initial setup failed - items should be in the cache"); } // Wait for first cleanup cycle with retry logic bool initialItemsRemoved = false; int maxRetries = 3; - + for (int retry = 0; retry < maxRetries && !initialItemsRemoved; retry++) { Thread.Sleep(waitTime); - + // Check if all initial items are removed initialItemsRemoved = true; for (int i = 0; i < 5; i++) { - if (cache.Get($"initial{i}") != null) + if (await cache.Get($"initial{i}") != null) { initialItemsRemoved = false; break; @@ -117,35 +114,35 @@ public void BackgroundCleanup_HandlesNewItemsAddedAfterCleanup() // Add new items for (int i = 0; i < 5; i++) { - cache.Set($"new{i}", $"newvalue{i}"); + await cache.Set($"new{i}", $"newvalue{i}"); } // Verify initial items are gone for (int i = 0; i < 5; i++) { - var result = cache.Get($"initial{i}"); + var result = await cache.Get($"initial{i}"); Assert.That(result, Is.Null, $"Initial item with key 'initial{i}' should have been removed"); } // Verify new items are still present for (int i = 0; i < 5; i++) { - var result = cache.Get($"new{i}"); + var result = await cache.Get($"new{i}"); Assert.That(result, Is.EqualTo($"newvalue{i}"), $"New item with key 'new{i}' should still be present"); } // Wait for second cleanup cycle with retry logic bool newItemsRemoved = false; - + for (int retry = 0; retry < maxRetries && !newItemsRemoved; retry++) { Thread.Sleep(waitTime); - + // Check if all new items are removed newItemsRemoved = true; for (int i = 0; i < 5; i++) { - if (cache.Get($"new{i}") != null) + if (await cache.Get($"new{i}") != null) { newItemsRemoved = false; break; @@ -156,40 +153,36 @@ public void BackgroundCleanup_HandlesNewItemsAddedAfterCleanup() // Final verification that new items are gone for (int i = 0; i < 5; i++) { - var result = cache.Get($"new{i}"); + var result = await cache.Get($"new{i}"); Assert.That(result, Is.Null, $"New item with key 'new{i}' should have been removed in second cycle"); } - - // Cleanup - cache.Dispose(); // Cleanup cache.Dispose(); } [Test] - public void BackgroundCleanup_RespectsTTLOverride() + public async Task BackgroundCleanup_RespectsTTLOverride() { // Arrange var defaultTtl = TimeSpan.FromMilliseconds(100); var longTtl = TimeSpan.FromMilliseconds(300); var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(defaultTtl.TotalMilliseconds / 4, 1)); - - var cache = new LocalCache( - maxItems: 100, - ttl: defaultTtl, - enableBackgroundCleanup: true); + + var cache = new LocalCache( + maxItems: 100, + ttl: defaultTtl); // Add items with default TTL for (int i = 0; i < 5; i++) { - cache.Set($"default{i}", $"value{i}"); + await cache.Set($"default{i}", $"value{i}"); } // Add items with longer TTL for (int i = 0; i < 5; i++) { - cache.Set($"long{i}", $"longvalue{i}", ttlOverride: longTtl); + await cache.Set($"long{i}", $"longvalue{i}", ttlOverride: longTtl); } // Wait for first cleanup cycle (should remove default TTL items) @@ -199,14 +192,14 @@ public void BackgroundCleanup_RespectsTTLOverride() // Verify default TTL items are gone for (int i = 0; i < 5; i++) { - var result = cache.Get($"default{i}"); + var result = await cache.Get($"default{i}"); Assert.That(result, Is.Null, $"Default TTL item 'default{i}' should have been removed"); } // Verify long TTL items still present for (int i = 0; i < 5; i++) { - var result = cache.Get($"long{i}"); + var result = await cache.Get($"long{i}"); Assert.That(result, Is.EqualTo($"longvalue{i}"), $"Long TTL item 'long{i}' should still be present"); } @@ -217,7 +210,7 @@ public void BackgroundCleanup_RespectsTTLOverride() // Verify long TTL items are now gone for (int i = 0; i < 5; i++) { - var result = cache.Get($"long{i}"); + var result = await cache.Get($"long{i}"); Assert.That(result, Is.Null, $"Long TTL item 'long{i}' should have been removed after full expiry"); } @@ -226,21 +219,20 @@ public void BackgroundCleanup_RespectsTTLOverride() } [Test] - public void DisabledBackgroundCleanup_DoesNotRemoveExpiredItems() + public async Task DisabledBackgroundCleanup_DoesNotRemoveExpiredItems() { // Arrange var shortTtl = TimeSpan.FromMilliseconds(50); var waitTime = shortTtl + TimeSpan.FromMilliseconds(100); // Add buffer time - var cache = new LocalCache( - maxItems: 100, - ttl: shortTtl, - enableBackgroundCleanup: false); // Disable background cleanup + var cache = new LocalCache( + maxItems: 100, + ttl: shortTtl); // Add test items for (int i = 0; i < 10; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } // Act - Wait for what would be a cleanup cycle @@ -248,11 +240,11 @@ public void DisabledBackgroundCleanup_DoesNotRemoveExpiredItems() // Items should still be expired but not automatically removed bool anyItemsFound = false; - + // We need to use Get to trigger manual cleanup for (int i = 0; i < 10; i++) { - var result = cache.Get($"key{i}"); + var result = await cache.Get($"key{i}"); if (result != null) { anyItemsFound = true; @@ -268,21 +260,20 @@ public void DisabledBackgroundCleanup_DoesNotRemoveExpiredItems() } [Test] - public void Dispose_StopsBackgroundCleanup() + public async Task Dispose_StopsBackgroundCleanup() { // Arrange var shortTtl = TimeSpan.FromMilliseconds(50); var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(shortTtl.TotalMilliseconds / 4, 1)); - - var cache = new LocalCache( - maxItems: 100, - ttl: shortTtl, - enableBackgroundCleanup: true); + + var cache = new LocalCache( + maxItems: 100, + ttl: shortTtl); // Add test items for (int i = 0; i < 5; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } // Dispose cache to stop background cleanup @@ -294,7 +285,7 @@ public void Dispose_StopsBackgroundCleanup() // Since cache is disposed, these calls should be ignored but not throw for (int i = 5; i < 10; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } // Wait for what would be a cleanup cycle @@ -303,7 +294,7 @@ public void Dispose_StopsBackgroundCleanup() // Try to get values - should return default(T) for all keys for (int i = 0; i < 10; i++) { - var result = cache.Get($"key{i}"); + var result = await cache.Get($"key{i}"); Assert.That(result, Is.Null, $"Item should not be accessible after disposal"); } } @@ -313,38 +304,37 @@ public void Dispose_StopsBackgroundCleanup() Assert.Pass("Cache correctly throws ObjectDisposedException after disposal"); } } - + [Test] - public void DisposalPattern_CleanupWithFinalizer() + public async Task DisposalPattern_CleanupWithFinalizer() { // Use a local scope so we can test that the finalizer runs when the cache goes out of scope WeakReference weakRef; - + // Setup the cache in a local scope { - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMilliseconds(50), - enableBackgroundCleanup: true); - + ttl: TimeSpan.FromMilliseconds(50)); + // Store a weak reference to the cache object weakRef = new WeakReference(cache); - + // Add items to the cache - cache.Set("test", "value"); - + await cache.Set("test", "value"); + // Verify the item was added successfully - Assert.That(cache.Get("test"), Is.EqualTo("value")); - + Assert.That(await cache.Get("test"), Is.EqualTo("value")); + // Let the local variable go out of scope without calling Dispose // The finalizer should eventually run and clean up resources } - + // Force garbage collection GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); - + // The cache should eventually be collected (though we can't guarantee it will be immediately) // This is a best-effort check, as the GC behavior can't be fully controlled in tests if (weakRef.Target != null) @@ -352,32 +342,31 @@ public void DisposalPattern_CleanupWithFinalizer() Console.WriteLine("Note: GC hasn't collected the cache object yet, which is normal in some environments."); // Don't fail the test, as GC timing is not guaranteed } - + // The main purpose of this test is to verify that the finalizer runs without exceptions // (if there were exceptions during finalization, they would be reported by the test framework) } [Test] - public void UnlimitedTTL_DoesNotExpire() + public async Task UnlimitedTTL_DoesNotExpire() { // Arrange var defaultTtl = TimeSpan.FromMilliseconds(50); // Short TTL for normal items - - var cache = new LocalCache( - maxItems: 100, - ttl: defaultTtl, - enableBackgroundCleanup: true); + + var cache = new LocalCache( + maxItems: 100, + ttl: defaultTtl); // Add items with unlimited TTL for (int i = 0; i < 5; i++) { - cache.Set($"unlimited{i}", $"value{i}", ttlOverride: LocalCache.UNLIMITED_TTL); + await cache.Set($"unlimited{i}", $"value{i}", ttlOverride: UnlimitedTtl); } // Add items with default TTL for (int i = 0; i < 5; i++) { - cache.Set($"default{i}", $"value{i}"); + await cache.Set($"default{i}", $"value{i}"); } // Wait for background cleanup to run for default TTL items @@ -386,14 +375,14 @@ public void UnlimitedTTL_DoesNotExpire() // Verify default TTL items are gone for (int i = 0; i < 5; i++) { - var result = cache.Get($"default{i}"); + var result = await cache.Get($"default{i}"); Assert.That(result, Is.Null, $"Default TTL item 'default{i}' should have been removed"); } // Verify unlimited TTL items are still present for (int i = 0; i < 5; i++) { - var result = cache.Get($"unlimited{i}"); + var result = await cache.Get($"unlimited{i}"); Assert.That(result, Is.EqualTo($"value{i}"), $"Unlimited TTL item 'unlimited{i}' should still be present"); } @@ -403,59 +392,59 @@ public void UnlimitedTTL_DoesNotExpire() // Verify unlimited TTL items are still present for (int i = 0; i < 5; i++) { - var result = cache.Get($"unlimited{i}"); + var result = await cache.Get($"unlimited{i}"); Assert.That(result, Is.EqualTo($"value{i}"), $"Unlimited TTL item 'unlimited{i}' should still be present after additional wait"); } // Cleanup cache.Dispose(); } + [Test] - public void BackgroundCleanup_ThreadSafety_DuringCleanup() + public async Task BackgroundCleanup_ThreadSafety_DuringCleanup() { // Arrange var shortTtl = TimeSpan.FromMilliseconds(75); var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(shortTtl.TotalMilliseconds / 4, 1)); - var cache = new LocalCache( - maxItems: 1000, - ttl: shortTtl, - enableBackgroundCleanup: true); - + var cache = new LocalCache( + maxItems: 1000, + ttl: shortTtl); + // Act - Run multiple threads that add/get/remove items while cleanup is happening int threadCount = 10; var tasks = new List(); var startEvent = new ManualResetEventSlim(false); - + // Populate initial data for (int i = 0; i < 100; i++) { - cache.Set($"initial{i}", $"value{i}"); + await cache.Set($"initial{i}", $"value{i}"); } - + for (int t = 0; t < threadCount; t++) { int threadId = t; - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { startEvent.Wait(); - + // Each thread performs different operations for (int i = 0; i < 50; i++) { string key = $"thread{threadId}-item{i}"; - + // Add a new item - cache.Set(key, $"value-{threadId}-{i}"); - + await cache.Set(key, $"value-{threadId}-{i}"); + // Get some existing items - _ = cache.Get($"initial{(i + threadId) % 100}"); - + _ = await cache.Get($"initial{(i + threadId) % 100}"); + // Delete some items if (i % 5 == 0) { - cache.Delete($"initial{(i + threadId*7) % 100}"); + await cache.Delete($"initial{(i + threadId * 7) % 100}"); } - + if (i % 3 == 0) { // Small pause to increase chance of thread interleaving @@ -464,587 +453,574 @@ public void BackgroundCleanup_ThreadSafety_DuringCleanup() } })); } - + // Start all threads simultaneously startEvent.Set(); - + // Wait for threads to complete their work Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(2)); - + // Wait for cleanup to occur Thread.Sleep(shortTtl + cleanupInterval + TimeSpan.FromMilliseconds(50)); - + // Assert - no exceptions should have occurred during the concurrent operations - + // Check that we can still use the cache normally after heavy concurrent use string testKey = "final-test-key"; - cache.Set(testKey, "final-value"); - Assert.That(cache.Get(testKey), Is.EqualTo("final-value")); - + await cache.Set(testKey, "final-value"); + Assert.That(await cache.Get(testKey), Is.EqualTo("final-value")); + // Cleanup cache.Dispose(); } [Test] - public void BackgroundCleanup_HandlesHighLoad() + public async Task BackgroundCleanup_HandlesHighLoad() { // Arrange var ttl = TimeSpan.FromMilliseconds(200); - var cache = new LocalCache( - maxItems: 10000, - ttl: ttl, - enableBackgroundCleanup: true); + var cache = new LocalCache( + maxItems: 10000, + ttl: ttl); // Add large number of items in quick succession for (int i = 0; i < 5000; i++) { - cache.Set($"key{i}", i); + await cache.Set($"key{i}", i); } - + // Check that items were added correctly - Assert.That(cache.Get("key42"), Is.EqualTo(42)); - Assert.That(cache.Get("key999"), Is.EqualTo(999)); - + Assert.That(await cache.Get("key42"), Is.EqualTo(42)); + Assert.That(await cache.Get("key999"), Is.EqualTo(999)); + // Wait for cleanup cycle Thread.Sleep(ttl + TimeSpan.FromMilliseconds(100)); - + // Add more items after cleanup should have run for (int i = 5000; i < 7500; i++) { - cache.Set($"key{i}", i); + await cache.Set($"key{i}", i); } - + // Verify old items were removed - Assert.That(cache.Get("key42"), Is.Null); - Assert.That(cache.Get("key999"), Is.Null); - + Assert.That(await cache.Get("key42"), Is.Null); + Assert.That(await cache.Get("key999"), Is.Null); + // Verify new items are present - Assert.That(cache.Get("key5042"), Is.EqualTo(5042)); - Assert.That(cache.Get("key6999"), Is.EqualTo(6999)); - + Assert.That(await cache.Get("key5042"), Is.EqualTo(5042)); + Assert.That(await cache.Get("key6999"), Is.EqualTo(6999)); + // Cleanup cache.Dispose(); } - + [Test] - public void ZeroCapacity_BackgroundCleanupDisabled() + public async Task ZeroCapacity_BackgroundCleanupDisabled() { // Arrange - Create a cache with zero capacity - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 0, - ttl: TimeSpan.FromMilliseconds(50), - enableBackgroundCleanup: true); - + ttl: TimeSpan.FromMilliseconds(50)); + // Act - Try to add items (should be ignored) - cache.Set("key1", "value1"); - + await cache.Set("key1", "value1"); + // Assert - Items should not be stored - Assert.That(cache.Get("key1"), Is.Null); - + Assert.That(await cache.Get("key1"), Is.Null); + // Cleanup cache.Dispose(); } - + [Test] - public void EdgeCaseValues_HandledCorrectly() + public async Task EdgeCaseValues_HandledCorrectly() { // Test with extremely short TTL - using (var cache1 = new LocalCache( + using (var cache1 = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMilliseconds(1), // 1ms TTL - enableBackgroundCleanup: true)) + ttl: TimeSpan.FromMilliseconds(1))) // 1ms TTL { - cache1.Set("key1", "value1"); + await cache1.Set("key1", "value1"); // Wait to make sure it expires Thread.Sleep(100); - Assert.That(cache1.Get("key1"), Is.Null); + Assert.That(await cache1.Get("key1"), Is.Null); } - + // Test with very large TTL that gets capped for cleanup interval - using (var cache2 = new LocalCache( + using (var cache2 = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromDays(30), // 30-day TTL - enableBackgroundCleanup: true)) + ttl: TimeSpan.FromDays(30))) // 30-day TTL { - cache2.Set("key1", "value1", TimeSpan.FromMilliseconds(50)); + await cache2.Set("key1", "value1", TimeSpan.FromMilliseconds(50)); // Wait to make sure short TTL item expires despite long default TTL Thread.Sleep(200); - Assert.That(cache2.Get("key1"), Is.Null); - + Assert.That(await cache2.Get("key1"), Is.Null); + // Add an item with the default long TTL - cache2.Set("key2", "value2"); + await cache2.Set("key2", "value2"); // This shouldn't expire in our test timeframe Thread.Sleep(200); - Assert.That(cache2.Get("key2"), Is.EqualTo("value2")); + Assert.That(await cache2.Get("key2"), Is.EqualTo("value2")); } - + // Test with a single item - using (var cache3 = new LocalCache( + using (var cache3 = new LocalCache( maxItems: 1, - ttl: TimeSpan.FromMilliseconds(50), - enableBackgroundCleanup: true)) + ttl: TimeSpan.FromMilliseconds(50))) { - cache3.Set("key1", "value1"); - Assert.That(cache3.Get("key1"), Is.EqualTo("value1")); - + await cache3.Set("key1", "value1"); + Assert.That(await cache3.Get("key1"), Is.EqualTo("value1")); + // Add another item should replace the first due to capacity = 1 - cache3.Set("key2", "value2"); - Assert.That(cache3.Get("key1"), Is.Null); - Assert.That(cache3.Get("key2"), Is.EqualTo("value2")); + await cache3.Set("key2", "value2"); + Assert.That(await cache3.Get("key1"), Is.Null); + Assert.That(await cache3.Get("key2"), Is.EqualTo("value2")); } } - + [Test] - public void ConcurrentOperationsDuringBackgroundCleanup() + public async Task ConcurrentOperationsDuringBackgroundCleanup() { // Arrange // Use a short TTL so cleanup runs quickly var shortTtl = TimeSpan.FromMilliseconds(75); var cleanupInterval = TimeSpan.FromMilliseconds(Math.Max(shortTtl.TotalMilliseconds / 4, 1)); - - var cache = new LocalCache( + + var cache = new LocalCache( maxItems: 500, - ttl: shortTtl, - enableBackgroundCleanup: true); - + ttl: shortTtl); + // Populate initial data for (int i = 0; i < 50; i++) { - cache.Set($"initial{i}", $"value{i}"); + await cache.Set($"initial{i}", $"value{i}"); } - + // Wait a bit to ensure some items are close to expiration Thread.Sleep((int)(shortTtl.TotalMilliseconds * 0.6)); - + // Act - Run concurrent operations right before and during cleanup var concurrencyLevel = 5; var operationsPerThread = 100; var barriers = new Barrier(concurrencyLevel); var tasks = new List(); var random = new Random(); - + for (int t = 0; t < concurrencyLevel; t++) { int threadId = t; - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { // Synchronize threads to start simultaneously barriers.SignalAndWait(); - + // Each thread does mixed operations for (int i = 0; i < operationsPerThread; i++) { var operation = i % 3; int randomIndex = random.Next(50); string key = $"thread{threadId}-item{i}"; - + switch (operation) { case 0: // Set - cache.Set(key, $"value-{threadId}-{i}"); + await cache.Set(key, $"value-{threadId}-{i}"); break; - + case 1: // Get // Try to access an item being cleaned up - _ = cache.Get($"initial{randomIndex}"); + _ = await cache.Get($"initial{randomIndex}"); break; - + case 2: // Delete if (i > 0) { // Delete an item we just created - cache.Delete($"thread{threadId}-item{i-1}"); + await cache.Delete($"thread{threadId}-item{i - 1}"); } else { // Delete a random initial item - cache.Delete($"initial{randomIndex}"); + await cache.Delete($"initial{randomIndex}"); } break; } } })); } - + // Wait for all threads to complete Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(5)); - + // Wait for cleanup to complete Thread.Sleep(shortTtl + cleanupInterval + TimeSpan.FromMilliseconds(50)); - + // Assert - Check if we can still use the cache after concurrent operations string finalKey = "final-concurrent-test"; string finalValue = "concurrent-final-value"; - cache.Set(finalKey, finalValue); - Assert.That(cache.Get(finalKey), Is.EqualTo(finalValue)); - + await cache.Set(finalKey, finalValue); + Assert.That(await cache.Get(finalKey), Is.EqualTo(finalValue)); + // Verify that initial items are expired for (int i = 0; i < 50; i++) { - Assert.That(cache.Get($"initial{i}"), Is.Null); + Assert.That(await cache.Get($"initial{i}"), Is.Null); } - + // Cleanup cache.Dispose(); } - + [Test] - public void DeletingItemsDuringBackgroundCleanup() + public async Task DeletingItemsDuringBackgroundCleanup() { // Arrange var ttl = TimeSpan.FromMilliseconds(150); - var cache = new LocalCache( - maxItems: 1000, - ttl: ttl, - enableBackgroundCleanup: true); - + var cache = new LocalCache( + maxItems: 1000, + ttl: ttl); + // Add items for (int i = 0; i < 100; i++) { - cache.Set($"item{i}", i); + await cache.Set($"item{i}", i); } - + // Manually delete some items for (int i = 0; i < 50; i += 2) { - cache.Delete($"item{i}"); + await cache.Delete($"item{i}"); } - + // Verify deleted items are gone - Assert.That(cache.Get("item0"), Is.Null); - Assert.That(cache.Get("item48"), Is.Null); - + Assert.That(await cache.Get("item0"), Is.Null); + Assert.That(await cache.Get("item48"), Is.Null); + // Verify non-deleted items are still there - Assert.That(cache.Get("item1"), Is.EqualTo(1)); - Assert.That(cache.Get("item99"), Is.EqualTo(99)); - + Assert.That(await cache.Get("item1"), Is.EqualTo(1)); + Assert.That(await cache.Get("item99"), Is.EqualTo(99)); + // Wait for background cleanup Thread.Sleep(ttl + TimeSpan.FromMilliseconds(100)); - + // All items should be gone now, either by manual deletion or background cleanup for (int i = 0; i < 100; i++) { - Assert.That(cache.Get($"item{i}"), Is.Null); + Assert.That(await cache.Get($"item{i}"), Is.Null); } - + // Cleanup cache.Dispose(); } [Test] - public void DeleteMissing_RemovesUnspecifiedKeys() + public async Task DeleteMissing_RemovesUnspecifiedKeys() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: true); + ttl: TimeSpan.FromHours(1)); // Add a set of items var keysToKeep = new List { "key1", "key2", "key3" }; var keysToRemove = new List { "removeKey1", "removeKey2", "removeKey3" }; - + // Add both sets to the cache foreach (var key in keysToKeep) { - cache.Set(key, $"value-{key}"); + await cache.Set(key, $"value-{key}"); } - + foreach (var key in keysToRemove) { - cache.Set(key, $"value-{key}"); + await cache.Set(key, $"value-{key}"); } - + // Verify all items were added foreach (var key in keysToKeep.Concat(keysToRemove)) { - Assert.That(cache.Get(key), Is.Not.Null); + Assert.That(await cache.Get(key), Is.Not.Null); } - + // Act - Delete all keys not in the "keysToKeep" list - cache.DeleteMissing(keysToKeep); - + await cache.DeleteMissing(keysToKeep); + // Assert - Verify that only specified keys remain foreach (var key in keysToKeep) { - Assert.That(cache.Get(key), Is.Not.Null, $"Key '{key}' should be kept"); + Assert.That(await cache.Get(key), Is.Not.Null, $"Key '{key}' should be kept"); } - + foreach (var key in keysToRemove) { - Assert.That(cache.Get(key), Is.Null, $"Key '{key}' should be removed"); + Assert.That(await cache.Get(key), Is.Null, $"Key '{key}' should be removed"); } - + // Cleanup cache.Dispose(); } [Test] - public void DeleteMissing_HandlesEmptyList() + public async Task DeleteMissing_HandlesEmptyList() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: true); - + ttl: TimeSpan.FromHours(1)); + // Add some items for (int i = 0; i < 5; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } - + // Act - Delete all keys not in an empty list (should remove all) - cache.DeleteMissing(new List()); - + await cache.DeleteMissing(new List()); + // Assert - Verify all keys were removed for (int i = 0; i < 5; i++) { - Assert.That(cache.Get($"key{i}"), Is.Null, $"Key 'key{i}' should be removed"); + Assert.That(await cache.Get($"key{i}"), Is.Null, $"Key 'key{i}' should be removed"); } - + // Cleanup cache.Dispose(); } [Test] - public void LRUEviction_PreciseEvictionOrder() + public async Task LRUEviction_PreciseEvictionOrder() { // Arrange int capacity = 5; - var cache = new LocalCache( + var cache = new LocalCache( maxItems: capacity, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: false); // Disable background cleanup for this test - + ttl: TimeSpan.FromHours(1)); + // We need to ensure exact LRU order, so we'll insert one by one - cache.Set("key0", 0); + await cache.Set("key0", 0); Thread.Sleep(10); - cache.Set("key1", 1); + await cache.Set("key1", 1); Thread.Sleep(10); - cache.Set("key2", 2); + await cache.Set("key2", 2); Thread.Sleep(10); - cache.Set("key3", 3); + await cache.Set("key3", 3); Thread.Sleep(10); - cache.Set("key4", 4); - + await cache.Set("key4", 4); + // At this point, key0 should be the least recently used - + // Now access each key to establish a clear LRU order // Access in reverse order to make key4 the most recently used for (int i = 4; i >= 0; i--) { - _ = cache.Get($"key{i}"); + _ = await cache.Get($"key{i}"); Thread.Sleep(10); // Ensure distinct access times } - + // Now the LRU order should be: key0 (most recent) to key4 (least recent) - + // Add a new key which should evict the least recently used (key4) - cache.Set("newKey", 99); - + await cache.Set("newKey", 99); + // Verify that the least recently used (key4) was evicted - Assert.That(cache.Get("key4"), Is.Null, "Least recently used key should have been evicted"); - + Assert.That(await cache.Get("key4"), Is.Null, "Least recently used key should have been evicted"); + // Verify that all other keys remain - Assert.That(cache.Get("key0"), Is.EqualTo(0), "Most recently used key should still be in cache"); - Assert.That(cache.Get("key1"), Is.EqualTo(1), "Second most recently used key should still be in cache"); - Assert.That(cache.Get("key2"), Is.EqualTo(2), "Third most recently used key should still be in cache"); - Assert.That(cache.Get("key3"), Is.EqualTo(3), "Fourth most recently used key should still be in cache"); - Assert.That(cache.Get("newKey"), Is.EqualTo(99), "New key should be in cache"); - + Assert.That(await cache.Get("key0"), Is.EqualTo(0), "Most recently used key should still be in cache"); + Assert.That(await cache.Get("key1"), Is.EqualTo(1), "Second most recently used key should still be in cache"); + Assert.That(await cache.Get("key2"), Is.EqualTo(2), "Third most recently used key should still be in cache"); + Assert.That(await cache.Get("key3"), Is.EqualTo(3), "Fourth most recently used key should still be in cache"); + Assert.That(await cache.Get("newKey"), Is.EqualTo(99), "New key should be in cache"); + // Change LRU order: make key2 least recently used by accessing all except key2 for (int i = 0; i < capacity; i++) { if (i != 2) { - _ = cache.Get($"key{i}"); + _ = await cache.Get($"key{i}"); } } - _ = cache.Get("newKey"); - + _ = await cache.Get("newKey"); + // Add another new key, should evict key2 now as it's the least recently used - cache.Set("newKey2", 100); - + await cache.Set("newKey2", 100); + // Verify key2 is evicted and others remain - Assert.That(cache.Get("key0"), Is.EqualTo(0)); - Assert.That(cache.Get("key1"), Is.EqualTo(1)); - Assert.That(cache.Get("key2"), Is.Null, "The least recently used key should be evicted"); - Assert.That(cache.Get("key3"), Is.EqualTo(3)); - Assert.That(cache.Get("newKey"), Is.EqualTo(99)); - Assert.That(cache.Get("newKey2"), Is.EqualTo(100)); - + Assert.That(await cache.Get("key0"), Is.EqualTo(0)); + Assert.That(await cache.Get("key1"), Is.EqualTo(1)); + Assert.That(await cache.Get("key2"), Is.Null, "The least recently used key should be evicted"); + Assert.That(await cache.Get("key3"), Is.EqualTo(3)); + Assert.That(await cache.Get("newKey"), Is.EqualTo(99)); + Assert.That(await cache.Get("newKey2"), Is.EqualTo(100)); + // Cleanup cache.Dispose(); } [Test] - public void MixedOperations_WithVariousTTL() + public async Task MixedOperations_WithVariousTTL() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMilliseconds(200), - enableBackgroundCleanup: true); - + ttl: TimeSpan.FromMilliseconds(200)); + // Add items with different TTLs - cache.Set("shortTTL", "short", TimeSpan.FromMilliseconds(50)); - cache.Set("mediumTTL", "medium", TimeSpan.FromMilliseconds(150)); - cache.Set("longTTL", "long", TimeSpan.FromMilliseconds(300)); - cache.Set("unlimitedTTL", "unlimited", LocalCache.UNLIMITED_TTL); - cache.Set("defaultTTL", "default"); // Uses default TTL - + await cache.Set("shortTTL", "short", TimeSpan.FromMilliseconds(50)); + await cache.Set("mediumTTL", "medium", TimeSpan.FromMilliseconds(150)); + await cache.Set("longTTL", "long", TimeSpan.FromMilliseconds(300)); + await cache.Set("unlimitedTTL", "unlimited", UnlimitedTtl); + await cache.Set("defaultTTL", "default"); // Uses default TTL + // Verify all items were added - Assert.That(cache.Get("shortTTL"), Is.EqualTo("short")); - Assert.That(cache.Get("mediumTTL"), Is.EqualTo("medium")); - Assert.That(cache.Get("longTTL"), Is.EqualTo("long")); - Assert.That(cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); - Assert.That(cache.Get("defaultTTL"), Is.EqualTo("default")); - + Assert.That(await cache.Get("shortTTL"), Is.EqualTo("short")); + Assert.That(await cache.Get("mediumTTL"), Is.EqualTo("medium")); + Assert.That(await cache.Get("longTTL"), Is.EqualTo("long")); + Assert.That(await cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); + Assert.That(await cache.Get("defaultTTL"), Is.EqualTo("default")); + // Wait for short TTL to expire Thread.Sleep(100); - + // Short TTL should be gone, others should remain - Assert.That(cache.Get("shortTTL"), Is.Null, "Short TTL item should be expired"); - Assert.That(cache.Get("mediumTTL"), Is.EqualTo("medium")); - Assert.That(cache.Get("longTTL"), Is.EqualTo("long")); - Assert.That(cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); - Assert.That(cache.Get("defaultTTL"), Is.EqualTo("default")); - + Assert.That(await cache.Get("shortTTL"), Is.Null, "Short TTL item should be expired"); + Assert.That(await cache.Get("mediumTTL"), Is.EqualTo("medium")); + Assert.That(await cache.Get("longTTL"), Is.EqualTo("long")); + Assert.That(await cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); + Assert.That(await cache.Get("defaultTTL"), Is.EqualTo("default")); + // Wait for medium and default TTL to expire Thread.Sleep(150); - + // Medium and default TTL should be gone - Assert.That(cache.Get("mediumTTL"), Is.Null, "Medium TTL item should be expired"); - Assert.That(cache.Get("defaultTTL"), Is.Null, "Default TTL item should be expired"); - Assert.That(cache.Get("longTTL"), Is.EqualTo("long")); - Assert.That(cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); - + Assert.That(await cache.Get("mediumTTL"), Is.Null, "Medium TTL item should be expired"); + Assert.That(await cache.Get("defaultTTL"), Is.Null, "Default TTL item should be expired"); + Assert.That(await cache.Get("longTTL"), Is.EqualTo("long")); + Assert.That(await cache.Get("unlimitedTTL"), Is.EqualTo("unlimited")); + // Wait for long TTL to expire Thread.Sleep(150); - + // Long TTL should be gone, unlimited should remain - Assert.That(cache.Get("longTTL"), Is.Null, "Long TTL item should be expired"); - Assert.That(cache.Get("unlimitedTTL"), Is.EqualTo("unlimited"), "Unlimited TTL item should never expire"); - + Assert.That(await cache.Get("longTTL"), Is.Null, "Long TTL item should be expired"); + Assert.That(await cache.Get("unlimitedTTL"), Is.EqualTo("unlimited"), "Unlimited TTL item should never expire"); + // Update an unlimited TTL item to have a short TTL - cache.Set("unlimitedTTL", "now-limited", TimeSpan.FromMilliseconds(50)); - + await cache.Set("unlimitedTTL", "now-limited", TimeSpan.FromMilliseconds(50)); + // Wait for the new TTL to expire Thread.Sleep(100); - + // Updated item should be gone - Assert.That(cache.Get("unlimitedTTL"), Is.Null, "Updated item with short TTL should be expired"); - + Assert.That(await cache.Get("unlimitedTTL"), Is.Null, "Updated item with short TTL should be expired"); + // Cleanup cache.Dispose(); } [Test] - public void CacheCapacity_EnforcesLimit() + public async Task CacheCapacity_EnforcesLimit() { // Test with small capacity int smallCapacity = 10; - var cache = new LocalCache( + var cache = new LocalCache( maxItems: smallCapacity, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: false); - + ttl: TimeSpan.FromHours(1)); + // Add items exactly up to capacity for (int i = 0; i < smallCapacity; i++) { - cache.Set($"key{i}", i); - + await cache.Set($"key{i}", i); + // Ensure LRU ordering is as expected by accessing the items // immediately after setting to make key0 oldest, key9 newest } - + // Verify all items are present for (int i = 0; i < smallCapacity; i++) { - Assert.That(cache.Get($"key{i}"), Is.EqualTo(i)); + Assert.That(await cache.Get($"key{i}"), Is.EqualTo(i)); } - + // Add items beyond capacity - should start evicting int additionalItems = 5; for (int i = 0; i < additionalItems; i++) { - cache.Set($"newKey{i}", 100 + i); + await cache.Set($"newKey{i}", 100 + i); } - + // Count how many items are present from original set // Since each Get operation changes the LRU order, we need // to just count the items that still exist int presentOriginalItems = 0; for (int i = 0; i < smallCapacity; i++) { - if (cache.Get($"key{i}") != null) + if (await cache.Get($"key{i}") != null) { presentOriginalItems++; } } - + // Count new items present int presentNewItems = 0; for (int i = 0; i < additionalItems; i++) { - if (cache.Get($"newKey{i}") != null) + if (await cache.Get($"newKey{i}") != null) { presentNewItems++; } } - + // Total items should not exceed capacity Assert.That(presentOriginalItems + presentNewItems, Is.LessThanOrEqualTo(smallCapacity)); - + // All new items should be present since they were added most recently Assert.That(presentNewItems, Is.EqualTo(additionalItems)); - + // And since we added 5 new items to a cache of 10, at most 5 original items should remain Assert.That(presentOriginalItems, Is.EqualTo(smallCapacity - additionalItems)); - + // Cleanup cache.Dispose(); } [Test] - public void ConcurrentReadsAndWrites() + public async Task ConcurrentReadsAndWrites() { // Arrange int cacheCapacity = 1000; int concurrencyLevel = 5; // Reduced concurrency for test stability int operationsPerThread = 100; // Reduced operations for test stability - - var cache = new LocalCache( + + var cache = new LocalCache( maxItems: cacheCapacity, - ttl: TimeSpan.FromMinutes(5), - enableBackgroundCleanup: false); - + ttl: TimeSpan.FromMinutes(5)); + // Populate some initial data for (int i = 0; i < 50; i++) { - cache.Set($"initial{i}", $"value{i}"); + await cache.Set($"initial{i}", $"value{i}"); } - + // Act - Run concurrent read/write operations with retry on failure var exceptions = new ConcurrentBag(); var tasks = new List(); var startSignal = new ManualResetEventSlim(false); - + for (int t = 0; t < concurrencyLevel; t++) { int threadId = t; - + // Half the threads will primarily read, half will primarily write bool isWriteHeavy = t % 2 == 0; - - tasks.Add(Task.Run(() => + + tasks.Add(Task.Run(async () => { - try + try { // Wait for signal to ensure all threads start at the same time startSignal.Wait(); - + for (int i = 0; i < operationsPerThread; i++) { try @@ -1055,13 +1031,13 @@ public void ConcurrentReadsAndWrites() if (i % 5 != 0) { string key = $"thread{threadId}-item{i}"; - cache.Set(key, $"value-{threadId}-{i}"); + await cache.Set(key, $"value-{threadId}-{i}"); } else { // Read a random initial item int randomIndex = i % 50; - _ = cache.Get($"initial{randomIndex}"); + _ = await cache.Get($"initial{randomIndex}"); } } else @@ -1071,18 +1047,18 @@ public void ConcurrentReadsAndWrites() { // Read from initial items or other threads' items int randomIndex = i % 50; - _ = cache.Get($"initial{randomIndex}"); - + _ = await cache.Get($"initial{randomIndex}"); + // Also try to read items that other write-heavy threads may have written if (threadId > 0) { - _ = cache.Get($"thread{threadId-1}-item{i}"); + _ = await cache.Get($"thread{threadId - 1}-item{i}"); } } else { string key = $"thread{threadId}-item{i}"; - cache.Set(key, $"value-{threadId}-{i}"); + await cache.Set(key, $"value-{threadId}-{i}"); } } } @@ -1098,164 +1074,188 @@ public void ConcurrentReadsAndWrites() } })); } - + // Start all threads simultaneously startSignal.Set(); - + // Wait for all operations to complete var allTasksCompleted = Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(5)); - + // Assert - Verify the cache is still functional after heavy concurrent use Assert.That(allTasksCompleted, Is.True, "All tasks should complete within the timeout"); Assert.That(exceptions, Is.Empty, "No exceptions should be thrown"); - + // Verify the cache still works after concurrent operations string testKey = "final-concurrent-test-key"; string testValue = "final-concurrent-test-value"; - cache.Set(testKey, testValue); - Assert.That(cache.Get(testKey), Is.EqualTo(testValue), "Cache should still function correctly after concurrent operations"); - - // Cleanup - cache.Dispose(); - + await cache.Set(testKey, testValue); + Assert.That(await cache.Get(testKey), Is.EqualTo(testValue), "Cache should still function correctly after concurrent operations"); + // Try to verify some of the written values (we can't verify all due to LRU eviction) int successfulReads = 0; int expectedSuccessfulReads = 10; // We'll check just a few - + // Check some random keys from write-heavy threads for (int t = 0; t < concurrencyLevel; t += 2) { for (int i = operationsPerThread - 20; i < operationsPerThread && successfulReads < expectedSuccessfulReads; i++) { string key = $"thread{t}-item{i}"; - if (cache.Get(key) != null) + if (await cache.Get(key) != null) { successfulReads++; } } } - + // We should find at least some of the values still in cache // This is not a strict assertion since LRU could have evicted some values Console.WriteLine($"Found {successfulReads} values still in cache after concurrent operations"); - + // Cleanup cache.Dispose(); } [Test] - public void Set_UpdatesExistingValue() + public async Task Set_UpdatesExistingValue() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMinutes(5), - enableBackgroundCleanup: false); - + ttl: TimeSpan.FromMinutes(5)); + string key = "updateKey"; string originalValue = "original"; string updatedValue = "updated"; - + // Act - cache.Set(key, originalValue); - Assert.That(cache.Get(key), Is.EqualTo(originalValue)); - + await cache.Set(key, originalValue); + Assert.That(await cache.Get(key), Is.EqualTo(originalValue)); + // Update the same key - cache.Set(key, updatedValue); - + await cache.Set(key, updatedValue); + // Assert - Assert.That(cache.Get(key), Is.EqualTo(updatedValue)); - + Assert.That(await cache.Get(key), Is.EqualTo(updatedValue)); + // Cleanup cache.Dispose(); } - + + [Test] + public async Task GetOrSet_WithValueType_InvokesFactoryOnCacheMissAndCachesResult() + { + // Arrange + using var cache = new LocalCache( + maxItems: 100, + ttl: TimeSpan.FromMinutes(5)); + + int factoryCallCount = 0; + Func> factory = _ => + { + Interlocked.Increment(ref factoryCallCount); + return Task.FromResult(42); + }; + + // Act - first call should miss the cache and invoke the factory + var first = await cache.GetOrSet("answer", factory); + + // Assert + Assert.That(factoryCallCount, Is.EqualTo(1), "Factory should run once on a cache miss"); + Assert.That(first, Is.EqualTo(42), "First call should return the factory's value"); + + // Act - second call should hit the cache; factory must not run again + var second = await cache.GetOrSet("answer", factory); + + // Assert + Assert.That(factoryCallCount, Is.EqualTo(1), "Factory should not run again on a cache hit"); + Assert.That(second, Is.EqualTo(42), "Second call should return the cached value"); + } + [Test] - public void Delete_RemovesItemPermanently() + public async Task Delete_RemovesItemPermanently() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMinutes(5), - enableBackgroundCleanup: false); - + ttl: TimeSpan.FromMinutes(5)); + // Add some items for (int i = 0; i < 5; i++) { - cache.Set($"key{i}", $"value{i}"); + await cache.Set($"key{i}", $"value{i}"); } - + // Act - Delete specific items - bool deleteResult1 = cache.Delete("key1"); - bool deleteResult2 = cache.Delete("key3"); - bool deleteResult3 = cache.Delete("nonexistent"); - + bool deleteResult1 = await cache.Delete("key1"); + bool deleteResult2 = await cache.Delete("key3"); + bool deleteResult3 = await cache.Delete("nonexistent"); + // Assert Assert.That(deleteResult1, Is.True, "Delete should return true for existing keys"); Assert.That(deleteResult2, Is.True, "Delete should return true for existing keys"); Assert.That(deleteResult3, Is.True, "Delete should return true even for non-existent keys"); - + // Verify items are deleted - Assert.That(cache.Get("key1"), Is.Null); - Assert.That(cache.Get("key3"), Is.Null); - + Assert.That(await cache.Get("key1"), Is.Null); + Assert.That(await cache.Get("key3"), Is.Null); + // Verify other items remain - Assert.That(cache.Get("key0"), Is.EqualTo("value0")); - Assert.That(cache.Get("key2"), Is.EqualTo("value2")); - Assert.That(cache.Get("key4"), Is.EqualTo("value4")); - + Assert.That(await cache.Get("key0"), Is.EqualTo("value0")); + Assert.That(await cache.Get("key2"), Is.EqualTo("value2")); + Assert.That(await cache.Get("key4"), Is.EqualTo("value4")); + // Try setting a deleted key again - cache.Set("key1", "new-value1"); - Assert.That(cache.Get("key1"), Is.EqualTo("new-value1"), "Should be able to add back a deleted key"); - + await cache.Set("key1", "new-value1"); + Assert.That(await cache.Get("key1"), Is.EqualTo("new-value1"), "Should be able to add back a deleted key"); + // Cleanup cache.Dispose(); } - + [Test] - public void ParallelUpdates_ToSameKey() + public async Task ParallelUpdates_ToSameKey() { // This test verifies cache behavior with concurrent access, not exact counter value // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromMinutes(5), - enableBackgroundCleanup: false); - + ttl: TimeSpan.FromMinutes(5)); + string sharedKey = "contested-key"; int threadCount = 5; // Reduced from 10 to improve test stability int updatesPerThread = 100; // Reduced from 1000 for faster execution int initialValue = 0; - + // Initialize with a starting value - cache.Set(sharedKey, initialValue); - + await cache.Set(sharedKey, initialValue); + // Act - Have multiple threads update the same key var tasks = new List(); var barrier = new Barrier(threadCount); - + for (int t = 0; t < threadCount; t++) { - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { // Synchronize start of all threads barrier.SignalAndWait(); - + for (int i = 0; i < updatesPerThread; i++) { try { // Read current value - int currentValue = cache.Get(sharedKey) ?? 0; - + int currentValue = await cache.Get(sharedKey) ?? 0; + // Introduce a small delay to increase chance of race conditions if (i % 10 == 0) { Thread.Sleep(1); } - + // Update with incremented value - cache.Set(sharedKey, currentValue + 1); + await cache.Set(sharedKey, currentValue + 1); } catch (Exception) { @@ -1265,124 +1265,121 @@ public void ParallelUpdates_ToSameKey() } })); } - + // Wait for all threads to complete Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(5)); - + // Assert - Check if cache remains functional, not exact count - int finalValue = cache.Get(sharedKey) ?? 0; - + int finalValue = await cache.Get(sharedKey) ?? 0; + // Verify the cache is still functional - Assert.That(finalValue, Is.GreaterThan(initialValue), + Assert.That(finalValue, Is.GreaterThan(initialValue), "Final value should be greater than initial value after concurrent updates"); - + // Log the result but don't make strict assertions about exact value Console.WriteLine($"Parallel counter test: Initial value: {initialValue}, " + $"Final value: {finalValue}, Theoretical max: {threadCount * updatesPerThread}"); - + // Additional check: Set and get a new value to verify cache still works - cache.Set("new-after-parallel", 42); - Assert.That(cache.Get("new-after-parallel"), Is.EqualTo(42), + await cache.Set("new-after-parallel", 42); + Assert.That(await cache.Get("new-after-parallel"), Is.EqualTo(42), "Cache should still work properly after parallel operations"); - + Console.WriteLine($"Initial value: {initialValue}, Final value: {finalValue}, " + $"Theoretical max: {threadCount * updatesPerThread}"); - + // Add a new value to verify cache remains functional string newKey = "post-concurrency-test"; - cache.Set(newKey, 999); - Assert.That(cache.Get(newKey), Is.EqualTo(999), + await cache.Set(newKey, 999); + Assert.That(await cache.Get(newKey), Is.EqualTo(999), "Cache should still function correctly after concurrent operations"); - + // Cleanup cache.Dispose(); } - + [Test] - public void CapacityZero_DisablesCache() + public async Task CapacityZero_DisablesCache() { // Arrange - Create a cache with zero capacity - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 0, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: true); + ttl: TimeSpan.FromHours(1)); // Act - Try to use the cache - cache.Set("key1", "value1"); - cache.Set("key2", "value2"); + await cache.Set("key1", "value1"); + await cache.Set("key2", "value2"); // Assert - Nothing should be stored - Assert.That(cache.Get("key1"), Is.Null); - Assert.That(cache.Get("key2"), Is.Null); + Assert.That(await cache.Get("key1"), Is.Null); + Assert.That(await cache.Get("key2"), Is.Null); // Try delete - bool deleteResult = cache.Delete("key1"); + bool deleteResult = await cache.Delete("key1"); Assert.That(deleteResult, Is.False, "Delete on zero-capacity cache should return false"); // Try DeleteMissing - cache.DeleteMissing(new[] { "key1" }); + await cache.DeleteMissing(new[] { "key1" }); // Cleanup cache.Dispose(); } [Test] - public void Delete_RemovesKeyFromCache() + public async Task Delete_RemovesKeyFromCache() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: false); + ttl: TimeSpan.FromHours(1)); string key = "myKey"; string value = "myValue"; - cache.Set(key, value); + await cache.Set(key, value); // Verify it exists - Assert.That(cache.Get(key), Is.EqualTo(value)); + Assert.That(await cache.Get(key), Is.EqualTo(value)); // Act - cache.Delete(key); + await cache.Delete(key); // Assert - Assert.That(cache.Get(key), Is.Null, "Key should return null after deletion"); + Assert.That(await cache.Get(key), Is.Null, "Key should return null after deletion"); // Cleanup cache.Dispose(); } [Test] - public void DeleteMissing_RemovesOnlyUnlistedKeys() + public async Task DeleteMissing_RemovesOnlyUnlistedKeys() { // Arrange - var cache = new LocalCache( + var cache = new LocalCache( maxItems: 100, - ttl: TimeSpan.FromHours(1), - enableBackgroundCleanup: false); + ttl: TimeSpan.FromHours(1)); // Set multiple keys - cache.Set("keep1", "value1"); - cache.Set("keep2", "value2"); - cache.Set("remove1", "value3"); - cache.Set("remove2", "value4"); + await cache.Set("keep1", "value1"); + await cache.Set("keep2", "value2"); + await cache.Set("remove1", "value3"); + await cache.Set("remove2", "value4"); // Verify all keys exist - Assert.That(cache.Get("keep1"), Is.Not.Null); - Assert.That(cache.Get("keep2"), Is.Not.Null); - Assert.That(cache.Get("remove1"), Is.Not.Null); - Assert.That(cache.Get("remove2"), Is.Not.Null); + Assert.That(await cache.Get("keep1"), Is.Not.Null); + Assert.That(await cache.Get("keep2"), Is.Not.Null); + Assert.That(await cache.Get("remove1"), Is.Not.Null); + Assert.That(await cache.Get("remove2"), Is.Not.Null); // Act - Only keep "keep1" and "keep2" - cache.DeleteMissing(new List { "keep1", "keep2" }); + await cache.DeleteMissing(new List { "keep1", "keep2" }); // Assert - Kept keys remain - Assert.That(cache.Get("keep1"), Is.EqualTo("value1"), "Key 'keep1' should still be present"); - Assert.That(cache.Get("keep2"), Is.EqualTo("value2"), "Key 'keep2' should still be present"); + Assert.That(await cache.Get("keep1"), Is.EqualTo("value1"), "Key 'keep1' should still be present"); + Assert.That(await cache.Get("keep2"), Is.EqualTo("value2"), "Key 'keep2' should still be present"); // Assert - Unlisted keys are gone - Assert.That(cache.Get("remove1"), Is.Null, "Key 'remove1' should be removed"); - Assert.That(cache.Get("remove2"), Is.Null, "Key 'remove2' should be removed"); + Assert.That(await cache.Get("remove1"), Is.Null, "Key 'remove1' should be removed"); + Assert.That(await cache.Get("remove2"), Is.Null, "Key 'remove2' should be removed"); // Cleanup cache.Dispose(); diff --git a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs index 84787d5b..a0a6b05f 100644 --- a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Moq; using NUnit.Framework; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; @@ -33,7 +28,7 @@ public void Constructor_WithRedisCacheConfig_ParsesCorrectly() { try { - var cache = new RedisCache(config); + var cache = new RedisCache(config); } catch (InvalidOperationException ex) when (ex.Message.Contains("Failed to connect to Redis")) { @@ -67,7 +62,7 @@ public void Constructor_WithRedisCacheConfig_MultipleEndpoints() { try { - var cache = new RedisCache(config); + var cache = new RedisCache(config); } catch (InvalidOperationException ex) when (ex.Message.Contains("Failed to connect to Redis")) { @@ -82,7 +77,7 @@ public void Constructor_WithRedisCacheConfig_MultipleEndpoints() public void Constructor_WithRedisCacheConfig_NullConfig_ThrowsArgumentNullException() { // Act & Assert - Assert.Throws(() => new RedisCache((RedisCacheConfig)null)); + Assert.Throws(() => new RedisCache((RedisCacheConfig)null)); } [Test] @@ -101,7 +96,7 @@ public void Constructor_WithRedisCacheClusterConfig_AppliesClusterSettings() // Act & Assert try { - var cache = new RedisCache(clusterConfig); + var cache = new RedisCache(clusterConfig); // If we get here without Redis running, that's fine - the config was accepted Assert.Pass("Cluster configuration was accepted and RedisCache was created"); } @@ -127,7 +122,7 @@ public void Constructor_WithRedisCacheConfig_EmptyEndpoints_ThrowsArgumentExcept }; // Act & Assert - var ex = Assert.Throws(() => new RedisCache(config)); + var ex = Assert.Throws(() => new RedisCache(config)); Assert.That(ex.Message, Contains.Substring("Redis endpoints cannot be null or empty")); } diff --git a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs index 65c3dc3a..2acc36c5 100644 --- a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs @@ -29,8 +29,8 @@ public class CompanyMetricsTests : IDisposable // Client and dependencies private DatastreamClient _client; - private ICacheProvider _companyCache; - private ICacheProvider _companyLookupCache; + private ICacheProvider _companyCache; + private ICacheProvider _companyLookupCache; [SetUp] public void Setup() @@ -39,11 +39,9 @@ public void Setup() var (client, _, _, _) = DatastreamClientTestFactory.CreateClientWithMocks(); _client = client; // Use reflection to get the private cache fields - var cacheField = typeof(DatastreamClient).GetField("_companyCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - _companyCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company cache"); - - var lookupCacheField = typeof(DatastreamClient).GetField("_companyLookupCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - _companyLookupCache = (ICacheProvider?)lookupCacheField?.GetValue(_client) ?? throw new Exception("Could not get company lookup cache"); + var cacheField = typeof(DatastreamClient).GetField("_cacheProvider", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + _companyCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company cache"); + _companyLookupCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company lookup cache"); } [TearDown] @@ -80,7 +78,7 @@ private string CompanyIdCacheKey(string id) /// /// Helper method to set up a company in cache using both layers /// - private void SetupCompanyInCache(string companyJson) + private async Task SetupCompanyInCache(string companyJson) { var company = JsonSerializer.Deserialize(companyJson, new JsonSerializerOptions { @@ -91,7 +89,7 @@ private void SetupCompanyInCache(string companyJson) // Layer 1: Store company object at ID key var idKey = CompanyIdCacheKey(company.Id); - _companyCache.Set(idKey, company); + await _companyCache.Set(idKey, company); // Layer 2: Store company ID at each resource key if (company.Keys != null) @@ -99,7 +97,7 @@ private void SetupCompanyInCache(string companyJson) foreach (var key in company.Keys) { var resourceKey = ResourceKeyToCacheKey(key.Key, key.Value); - _companyLookupCache.Set(resourceKey, company.Id); + await _companyLookupCache.Set(resourceKey, company.Id); } } } @@ -107,18 +105,18 @@ private void SetupCompanyInCache(string companyJson) /// /// Helper method to get a company from cache using two-step lookup /// - private RulesengineCompany? GetCompanyFromCache(Dictionary keys) + private async Task GetCompanyFromCache(Dictionary keys) { if (keys.Count == 0) return null; foreach (var key in keys) { var resourceKey = ResourceKeyToCacheKey(key.Key, key.Value); - var companyId = _companyLookupCache.Get(resourceKey); + var companyId = await _companyLookupCache.Get(resourceKey); if (companyId != null) { var idKey = CompanyIdCacheKey(companyId); - var company = _companyCache.Get(idKey); + var company = await _companyCache.Get(idKey); if (company != null) return company; } } @@ -128,10 +126,10 @@ private void SetupCompanyInCache(string companyJson) /// /// Helper method to update company metrics using the Datastream client /// - private bool UpdateCompanyMetrics(Dictionary companyKeys, string metricName, string period, int quantity = 1) + private async Task UpdateCompanyMetrics(Dictionary companyKeys, string metricName, string period, int quantity = 1) { // Get the company from cache - var company = GetCompanyFromCache(companyKeys); + var company = await GetCompanyFromCache(companyKeys); if (company == null || company.Metrics == null) return false; // Find metrics matching the event name (metric name) @@ -146,18 +144,18 @@ private bool UpdateCompanyMetrics(Dictionary companyKeys, string // Save the updated company back to cache using two-layer approach var idKey = CompanyIdCacheKey(company.Id); - _companyCache.Set(idKey, company); + await _companyCache.Set(idKey, company); foreach (var key in companyKeys) { var resourceKey = ResourceKeyToCacheKey(key.Key, key.Value); - _companyLookupCache.Set(resourceKey, company.Id); + await _companyLookupCache.Set(resourceKey, company.Id); } return true; } [Test] - public void UpdateCompanyMetrics_WithNoMatchingMetric_ReturnsFalse() + public async Task UpdateCompanyMetrics_WithNoMatchingMetric_ReturnsFalse() { // Arrange var companyJson = @"{ @@ -192,17 +190,17 @@ public void UpdateCompanyMetrics_WithNoMatchingMetric_ReturnsFalse() ] }"; - SetupCompanyInCache(companyJson); + await SetupCompanyInCache(companyJson); // Act - var result = UpdateCompanyMetrics(SingleCompanyKey, "metric2", "all_time"); + var result = await UpdateCompanyMetrics(SingleCompanyKey, "metric2", "all_time"); // Assert Assert.That(result, Is.False, "Should return false when no matching metric is found"); } [Test] - public void UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuantity() + public async Task UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuantity() { // Arrange var companyJson = @"{ @@ -237,14 +235,14 @@ public void UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuant ] }"; - SetupCompanyInCache(companyJson); + await SetupCompanyInCache(companyJson); // Act - var result = UpdateCompanyMetrics(SingleCompanyKey, "metric1", "all_time"); + var result = await UpdateCompanyMetrics(SingleCompanyKey, "metric1", "all_time"); // Assert Assert.That(result, Is.True, "Should return true when metric is updated"); - var company = GetCompanyFromCache(SingleCompanyKey); + var company = await GetCompanyFromCache(SingleCompanyKey); Assert.That(company, Is.Not.Null); var metric = company?.Metrics?.FirstOrDefault(m => m.EventSubtype == "metric1" && m.Period.Value == "all_time"); @@ -253,7 +251,7 @@ public void UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuant } [Test] - public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() + public async Task UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() { // Arrange var companyJson = @"{ @@ -288,14 +286,14 @@ public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() ] }"; - SetupCompanyInCache(companyJson); + await SetupCompanyInCache(companyJson); // Act - var result = UpdateCompanyMetrics(SingleCompanyKey, "metric1", "all_time", 5); + var result = await UpdateCompanyMetrics(SingleCompanyKey, "metric1", "all_time", 5); // Assert Assert.That(result, Is.True, "Should return true when metric is updated"); - var company = GetCompanyFromCache(SingleCompanyKey); + var company = await GetCompanyFromCache(SingleCompanyKey); Assert.That(company, Is.Not.Null); var metric = company?.Metrics?.FirstOrDefault(m => m.EventSubtype == "metric1" && m.Period.Value == "all_time"); @@ -304,7 +302,7 @@ public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() } [Test] - public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() + public async Task UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() { // Arrange var companyJson = @"{ @@ -340,16 +338,16 @@ public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() ] }"; - SetupCompanyInCache(companyJson); + await SetupCompanyInCache(companyJson); // Act - var result = UpdateCompanyMetrics(MultipleCompanyKeys, "metric1", "all_time", 5); + var result = await UpdateCompanyMetrics(MultipleCompanyKeys, "metric1", "all_time", 5); // Assert Assert.That(result, Is.True, "Should return true when metrics are updated"); // Check company retrieved by primary key - var companyByPrimary = GetCompanyFromCache(new Dictionary { ["company_id"] = "12345" }); + var companyByPrimary = await GetCompanyFromCache(new Dictionary { ["company_id"] = "12345" }); Assert.That(companyByPrimary, Is.Not.Null); var metricByPrimary = companyByPrimary?.Metrics?.FirstOrDefault(m => m.EventSubtype == "metric1" && m.Period.Value == "all_time"); @@ -357,7 +355,7 @@ public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() Assert.That(metricByPrimary?.Value, Is.EqualTo(105)); // Check company retrieved by secondary key - var companyBySecondary = GetCompanyFromCache(new Dictionary { ["secondary_id"] = "secondary123" }); + var companyBySecondary = await GetCompanyFromCache(new Dictionary { ["secondary_id"] = "secondary123" }); Assert.That(companyBySecondary, Is.Not.Null); var metricBySecondary = companyBySecondary?.Metrics?.FirstOrDefault(m => m.EventSubtype == "metric1"); @@ -366,7 +364,7 @@ public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() } [Test] - public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() + public async Task UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() { // Arrange var companyJson = @"{ @@ -421,14 +419,14 @@ public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() ] }"; - SetupCompanyInCache(companyJson); + await SetupCompanyInCache(companyJson); // Act - var result = UpdateCompanyMetrics(SingleCompanyKey, "metric1", "current_month", 10); + var result = await UpdateCompanyMetrics(SingleCompanyKey, "metric1", "current_month", 10); // Assert Assert.That(result, Is.True, "Should return true when metric is updated"); - var company = GetCompanyFromCache(SingleCompanyKey); + var company = await GetCompanyFromCache(SingleCompanyKey); Assert.That(company, Is.Not.Null); // All metric1 metrics should be updated regardless of period diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs index e7493637..1ba7bf37 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs @@ -38,7 +38,7 @@ private void PopulateTwoLayerUserCache(RulesengineUser user) } [Test] - public void ExpiredCache_RequestsResourcesAgain() + public async Task ExpiredCache_RequestsResourcesAgain() { var companyKey = "company-123"; @@ -53,19 +53,19 @@ public void ExpiredCache_RequestsResourcesAgain() PopulateTwoLayerCompanyCache(company); var keys = new Dictionary { { "id", companyKey } }; - var cachedCompany = _client.GetCompanyFromCache(keys); + var cachedCompany = await _client.GetCompanyFromCache(keys); Assert.That(cachedCompany, Is.Not.Null, "Company should be in cache after Set"); Assert.That(cachedCompany.Id, Is.EqualTo(company.Id), "Cached company should match what we stored"); // Wait for cache to expire - Thread.Sleep(200); // Cache TTL is 100ms in Setup + await Task.Delay(200); // Cache TTL is 100ms in Setup - var expiredCompany = _client.GetCompanyFromCache(keys); + var expiredCompany = await _client.GetCompanyFromCache(keys); Assert.That(expiredCompany, Is.Null, "Company should not be in cache after TTL expiration"); } [Test] - public void TwoStepCompanyLookup_CacheAndRetrieve() + public async Task TwoStepCompanyLookup_CacheAndRetrieve() { var company = new RulesengineCompany { @@ -78,7 +78,7 @@ public void TwoStepCompanyLookup_CacheAndRetrieve() PopulateTwoLayerCompanyCache(company); var keys = new Dictionary { { "org_id", "org-789" } }; - var cached = _client.GetCompanyFromCache(keys); + var cached = await _client.GetCompanyFromCache(keys); Assert.That(cached, Is.Not.Null, "Company should be retrievable via two-step lookup"); Assert.That(cached!.Id, Is.EqualTo("comp_456")); @@ -86,7 +86,7 @@ public void TwoStepCompanyLookup_CacheAndRetrieve() } [Test] - public void TwoStepUserLookup_CacheAndRetrieve() + public async Task TwoStepUserLookup_CacheAndRetrieve() { var user = new RulesengineUser { @@ -99,7 +99,7 @@ public void TwoStepUserLookup_CacheAndRetrieve() PopulateTwoLayerUserCache(user); var keys = new Dictionary { { "email", "test@example.com" } }; - var cached = _client.GetUserFromCache(keys); + var cached = await _client.GetUserFromCache(keys); Assert.That(cached, Is.Not.Null, "User should be retrievable via two-step lookup"); Assert.That(cached!.Id, Is.EqualTo("user_456")); @@ -107,7 +107,7 @@ public void TwoStepUserLookup_CacheAndRetrieve() } [Test] - public void MultipleResourceKeys_ResolveToSameObject() + public async Task MultipleResourceKeys_ResolveToSameObject() { var company = new RulesengineCompany { @@ -124,9 +124,9 @@ public void MultipleResourceKeys_ResolveToSameObject() PopulateTwoLayerCompanyCache(company); - var byOrg = _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-111" } }); - var bySlug = _client.GetCompanyFromCache(new Dictionary { { "slug", "acme-corp" } }); - var byExternal = _client.GetCompanyFromCache(new Dictionary { { "external_id", "ext-222" } }); + var byOrg = await _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-111" } }); + var bySlug = await _client.GetCompanyFromCache(new Dictionary { { "slug", "acme-corp" } }); + var byExternal = await _client.GetCompanyFromCache(new Dictionary { { "external_id", "ext-222" } }); Assert.That(byOrg, Is.Not.Null); Assert.That(bySlug, Is.Not.Null); @@ -157,7 +157,7 @@ public async Task DeleteMessage_RemovesCompanyFromCache() // Verify company is in cache before deletion var keys = new Dictionary { { "org_id", "org-to-delete" } }; - var cachedBefore = _client.GetCompanyFromCache(keys); + var cachedBefore = await _client.GetCompanyFromCache(keys); Assert.That(cachedBefore, Is.Not.Null, "Company should be in cache before delete message"); Assert.That(cachedBefore!.Id, Is.EqualTo("comp_del_1")); @@ -184,7 +184,7 @@ public async Task DeleteMessage_RemovesCompanyFromCache() await Task.Delay(200); // Assert - Company should be removed from cache - var cachedAfter = _client.GetCompanyFromCache(keys); + var cachedAfter = await _client.GetCompanyFromCache(keys); Assert.That(cachedAfter, Is.Null, "Company should be removed from cache after delete message"); } @@ -204,7 +204,7 @@ public async Task DeleteMessage_RemovesUserFromCache() // Verify user is in cache before deletion var keys = new Dictionary { { "email", "delete-me@example.com" } }; - var cachedBefore = _client.GetUserFromCache(keys); + var cachedBefore = await _client.GetUserFromCache(keys); Assert.That(cachedBefore, Is.Not.Null, "User should be in cache before delete message"); Assert.That(cachedBefore!.Id, Is.EqualTo("user_del_1")); @@ -231,7 +231,7 @@ public async Task DeleteMessage_RemovesUserFromCache() await Task.Delay(200); // Assert - User should be removed from cache - var cachedAfter = _client.GetUserFromCache(keys); + var cachedAfter = await _client.GetUserFromCache(keys); Assert.That(cachedAfter, Is.Null, "User should be removed from cache after delete message"); } @@ -244,7 +244,7 @@ public async Task DeleteMessage_RemovesUserFromCache() // messages beyond what the Full message tests already cover. [Test] - public void CompanyUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() + public async Task CompanyUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() { var original = new RulesengineCompany { @@ -261,8 +261,8 @@ public void CompanyUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() PopulateTwoLayerCompanyCache(original); // Verify both keys resolve - Assert.That(_client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }), Is.Not.Null); - Assert.That(_client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }), Is.Not.Null); + Assert.That(await _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }), Is.Not.Null); + Assert.That(await _client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }), Is.Not.Null); // Update with "email" key removed var updated = new RulesengineCompany @@ -279,17 +279,17 @@ public void CompanyUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() PopulateTwoLayerCompanyCache(updated); // org_id should still resolve - var cached = _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }); + var cached = await _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }); Assert.That(cached, Is.Not.Null); Assert.That(cached!.Id, Is.EqualTo("comp_orphan_1")); // email key should no longer resolve - var orphaned = _client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }); + var orphaned = await _client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }); Assert.That(orphaned, Is.Null, "Removed company key should not remain in lookup cache"); } [Test] - public void UserUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() + public async Task UserUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() { var original = new RulesengineUser { @@ -306,8 +306,8 @@ public void UserUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() PopulateTwoLayerUserCache(original); // Verify both keys resolve - Assert.That(_client.GetUserFromCache(new Dictionary { { "user_id", "u-100" } }), Is.Not.Null); - Assert.That(_client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }), Is.Not.Null); + Assert.That(await _client.GetUserFromCache(new Dictionary { { "user_id", "u-100" } }), Is.Not.Null); + Assert.That(await _client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }), Is.Not.Null); // Update with "email" key removed var updated = new RulesengineUser @@ -324,17 +324,17 @@ public void UserUpdate_RemovedKey_OrphanedLookupEntryIsDeleted() PopulateTwoLayerUserCache(updated); // user_id should still resolve - var cached = _client.GetUserFromCache(new Dictionary { { "user_id", "u-100" } }); + var cached = await _client.GetUserFromCache(new Dictionary { { "user_id", "u-100" } }); Assert.That(cached, Is.Not.Null); Assert.That(cached!.Id, Is.EqualTo("user_orphan_1")); // email key should no longer resolve - var orphaned = _client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }); + var orphaned = await _client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }); Assert.That(orphaned, Is.Null, "Removed user key should not remain in lookup cache"); } [Test] - public void CompanyUpdate_ChangedKeyValue_OldValueIsRemoved() + public async Task CompanyUpdate_ChangedKeyValue_OldValueIsRemoved() { var original = new RulesengineCompany { @@ -366,17 +366,17 @@ public void CompanyUpdate_ChangedKeyValue_OldValueIsRemoved() PopulateTwoLayerCompanyCache(updated); // New email should resolve - var cached = _client.GetCompanyFromCache(new Dictionary { { "email", "new@example.com" } }); + var cached = await _client.GetCompanyFromCache(new Dictionary { { "email", "new@example.com" } }); Assert.That(cached, Is.Not.Null); Assert.That(cached!.Id, Is.EqualTo("comp_changed_1")); // Old email should no longer resolve - var stale = _client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }); + var stale = await _client.GetCompanyFromCache(new Dictionary { { "email", "old@example.com" } }); Assert.That(stale, Is.Null, "Old key value should not remain in lookup cache after value change"); } [Test] - public void UserUpdate_ChangedKeyValue_OldValueIsRemoved() + public async Task UserUpdate_ChangedKeyValue_OldValueIsRemoved() { var original = new RulesengineUser { @@ -408,17 +408,17 @@ public void UserUpdate_ChangedKeyValue_OldValueIsRemoved() PopulateTwoLayerUserCache(updated); // New email should resolve - var cached = _client.GetUserFromCache(new Dictionary { { "email", "new@example.com" } }); + var cached = await _client.GetUserFromCache(new Dictionary { { "email", "new@example.com" } }); Assert.That(cached, Is.Not.Null); Assert.That(cached!.Id, Is.EqualTo("user_changed_1")); // Old email should no longer resolve - var stale = _client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }); + var stale = await _client.GetUserFromCache(new Dictionary { { "email", "old@example.com" } }); Assert.That(stale, Is.Null, "Old key value should not remain in lookup cache after value change"); } [Test] - public void CompanyUpdate_UnchangedKeys_StillResolvable() + public async Task CompanyUpdate_UnchangedKeys_StillResolvable() { var original = new RulesengineCompany { @@ -450,8 +450,8 @@ public void CompanyUpdate_UnchangedKeys_StillResolvable() PopulateTwoLayerCompanyCache(updated); // Both keys should still resolve to the updated entity - var byOrg = _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }); - var bySlug = _client.GetCompanyFromCache(new Dictionary { { "slug", "acme" } }); + var byOrg = await _client.GetCompanyFromCache(new Dictionary { { "org_id", "org-100" } }); + var bySlug = await _client.GetCompanyFromCache(new Dictionary { { "slug", "acme" } }); Assert.That(byOrg, Is.Not.Null); Assert.That(bySlug, Is.Not.Null); diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs index 6e9e0715..a7a8e3e2 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -25,17 +26,18 @@ public void Setup() _mockLogger = new FakeLogger(); _mockWebSocket = new MockWebSocket(); _mockWebSocket.SetState(WebSocketState.Open); + var cacheProvider = new LocalCache(); // Create client factory with ability to capture the connection callback DatastreamClient CreateClientWithCallback(Action callback) { _connectionCallback = callback; - return new DatastreamClient("wss://test.example.com", _mockLogger, "test-api-key", callback, null, _mockWebSocket); + return new DatastreamClient("wss://test.example.com", _mockLogger, "test-api-key", callback, cacheProvider, null, _mockWebSocket); } // Use reflection to set private constructor parameters var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(10) }; - _adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", options); + _adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", cacheProvider, options); // Replace the internal client with our mocked version that captures the callback var clientField = typeof(DatastreamClientAdapter).GetField("_client", @@ -207,7 +209,7 @@ public async Task CheckFlag_WhenNotConnected_ThrowsException() // Set up a flag in the cache directly var flagsCacheField = client!.GetType().GetField("_flagsCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var flagsCache = flagsCacheField!.GetValue(client); + var flagsCache = (ICacheProvider)flagsCacheField!.GetValue(client)!; // Create a test flag var flag = new RulesengineFlag @@ -225,14 +227,9 @@ public async Task CheckFlag_WhenNotConnected_ThrowsException() var cacheKey = flagCacheKeyMethod!.Invoke(client, new object[] { "test-flag" }) as string; // Set the flag in cache - var setMethod = flagsCache!.GetType().GetMethod("Set"); - Assert.That(setMethod, Is.Not.Null, "Cache Set method not found"); - setMethod!.Invoke(flagsCache, new object[] { cacheKey!, flag, Type.Missing }); - + await flagsCache.Set(cacheKey!, flag); + var cachedFlag = await flagsCache.Get(cacheKey); // Verify flag is in cache - var flagCheck = client.GetType().GetMethod("GetFlag", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var cachedFlag = flagCheck!.Invoke(client, new object[] { "test-flag" }); Assert.That(cachedFlag, Is.Not.Null, "Flag should be in cache for test"); var request = new CheckFlagRequestBody diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs index a6edd4cc..d54340a6 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using NUnit.Framework; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -44,7 +45,7 @@ public void CheckFlag_WhenFlagIsNotInCache_ReturnsFalse() { // Arrange - create an adapter that will use our mock client var options = new DatastreamOptions(); - var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", new LocalCache(), options); // Get the private _client field from adapter and replace it with our mock client var clientField = typeof(DatastreamClientAdapter).GetField("_client", @@ -70,7 +71,7 @@ public void CheckFlag_WhenFlagIsNotInCache_ReturnsFalse() } [Test] - public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() + public async Task CheckFlag_WhenFlagExists_EvaluatesCorrectly() { // Arrange SetupFlagsResponse(); @@ -108,13 +109,13 @@ public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() // Verify company is in cache var companyKeys = new Dictionary { { "id", "company-123" } }; - var company = _client.GetCompanyFromCache(companyKeys); + var company = await _client.GetCompanyFromCache(companyKeys); Assert.That(company, Is.Not.Null, "Company should be in cache after directly adding it"); // Add the flag directly to the cache var flagsCacheField = typeof(DatastreamClient).GetField("_flagsCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var flagsCache = flagsCacheField!.GetValue(_client); + var flagsCache = (ICacheProvider)flagsCacheField!.GetValue(_client)!; // Create a test flag var testFlag = new RulesengineFlag @@ -133,13 +134,10 @@ public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() var flagCacheKey = flagCacheKeyMethod!.Invoke(_client, new object[] { "another-feature" }) as string; // Set the flag in cache - var flagSetMethod = flagsCache!.GetType().GetMethod("Set"); - flagSetMethod!.Invoke(flagsCache, new object[] { flagCacheKey!, testFlag, Type.Missing }); - + await flagsCache.Set(flagCacheKey, testFlag); + // Verify flag is in cache now - var getFlagMethod = typeof(DatastreamClient).GetMethod("GetFlag", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var flag = getFlagMethod!.Invoke(_client, new object[] { "another-feature" }) as RulesengineFlag; + var flag = await flagsCache.Get(flagCacheKey); Assert.That(flag, Is.Not.Null, "Flag should be in cache after setup"); // Now call CheckFlag directly on the client @@ -159,7 +157,7 @@ public async Task CachedResources_AreReusedWithoutWebSocketRequests() // Create an adapter that will use our mock client var options = new DatastreamOptions(); - var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _mockLogger, "test-api-key", new LocalCache(), options); // Get the private _client field from adapter and replace it with our mock client var clientField = typeof(DatastreamClientAdapter).GetField("_client", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs index 9501becc..3894b056 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -25,7 +26,7 @@ public async Task IsConnectedAsync_ReflectsConnectionState() { // Arrange var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(1) }; - var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", new LocalCache(), options); // Act - start the connection adapter.Start(); @@ -58,7 +59,7 @@ public async Task IsConnectedAsync_WithTimeout_WaitsForConnection() mockWebSocket.SetState(WebSocketState.Connecting); // Start in connecting state var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(1) }; - var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", new LocalCache(), options); // Set up a task to change the state after a delay _ = Task.Run(async () => { @@ -84,7 +85,7 @@ public async Task IsConnectedAsync_WithTimeout_ReturnsFalseOnTimeout() { // Arrange var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(1) }; - var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", new LocalCache(), options); // Act - check with a very short timeout var isConnected = await adapter.IsConnectedAsync(TimeSpan.FromMilliseconds(10)); @@ -106,7 +107,7 @@ public async Task ReconnectionState_ConnectedThenDisconnectedThenReconnected() { // Arrange var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(1) }; - var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", new LocalCache(), options); // Get the ConnectionStateTracker via reflection var trackerField = typeof(DatastreamClientAdapter).GetField("_connectionTracker", @@ -139,7 +140,7 @@ public async Task ConnectionMonitoring_DetectsReconnectionAfterLoss() { // Arrange var options = new DatastreamOptions { CacheTTL = TimeSpan.FromMinutes(1) }; - var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", options); + var adapter = new DatastreamClientAdapter("wss://test.example.com", _logger, "test-api-key", new LocalCache(), options); // Get the ConnectionStateTracker via reflection var trackerField = typeof(DatastreamClientAdapter).GetField("_connectionTracker", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs index e0446e41..222f62cc 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamRedisIntegrationTests.cs @@ -1,74 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; -using SchematicHQ.Client; using SchematicHQ.Client.Datastream; -using SchematicHQ.Client.Test.Datastream.Mocks; namespace SchematicHQ.Client.Test.Datastream { [TestFixture] public class DatastreamRedisIntegrationTests { - [Test] - public void DatastreamOptions_WithRedisConfiguration_CreatesCorrectly() - { - // Arrange - var options = new DatastreamOptions - { - CacheProviderType = DatastreamCacheProviderType.Redis, - RedisConfig = new RedisCacheConfig - { - Endpoints = new List - { - "localhost:6379", - "localhost:6380" - }, - Password = "testpass", - Ssl = true, - KeyPrefix = "schematic:test:", - Database = 1, - CacheTTL = TimeSpan.FromMinutes(10) - } - }; - - // Act & Assert - Assert.That(options.CacheProviderType, Is.EqualTo(DatastreamCacheProviderType.Redis)); - Assert.That(options.RedisConfig, Is.Not.Null); - Assert.That(options.RedisConfig.Endpoints.Count, Is.EqualTo(2)); - Assert.That(options.RedisConfig.Password, Is.EqualTo("testpass")); - Assert.That(options.RedisConfig.Ssl, Is.True); - Assert.That(options.RedisConfig.KeyPrefix, Is.EqualTo("schematic:test:")); - Assert.That(options.RedisConfig.Database, Is.EqualTo(1)); - } - - [Test] - public void Schematic_WithDatastreamRedisOptions_InitializesWithoutError() - { - // Arrange - var clientOptions = new ClientOptions - { - DatastreamOptions = new DatastreamOptions() - .WithRedisCache(new RedisCacheConfig - { - Endpoints = new List { "localhost:6379" }, - Password = "testpass", - AbortOnConnectFail = false, // prevents hanging on missing Redis - KeyPrefix = "schematic:test:", - CacheTTL = TimeSpan.FromMinutes(5) - }) - }; - - // Act & Assert - Should not throw TypeLoadException - Assert.DoesNotThrow(() => - { - var schematic = new Schematic("test_api_key", clientOptions); - }); - } - [Test] public void DatastreamClient_CanLoadRulesEngineTypes() { @@ -80,16 +17,6 @@ public void DatastreamClient_CanLoadRulesEngineTypes() { UseDatastream = true, DatastreamOptions = new DatastreamOptions() - .WithRedisCache(new RedisCacheConfig - { - Endpoints = new List { "localhost:6379" }, - Password = "testpass", - AbortOnConnectFail = false, - ConnectRetry = 0, // Don't retry for tests - ConnectTimeout = 1000, // Short timeout for tests - KeyPrefix = "schematic:test:", - CacheTTL = TimeSpan.FromMinutes(5) - }) }; // Act & Assert - The key test is that we can instantiate Schematic with Datastream enabled diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs index b67cb974..7a64f41c 100644 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using Microsoft.Extensions.Logging.Testing; using System.Reflection; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; namespace SchematicHQ.Client.Test.Datastream.Mocks @@ -16,9 +17,10 @@ public static (DatastreamClient Client, MockWebSocket WebSocket, FakeLogger Logg var logger = new FakeLogger(); var mockWebSocket = new MockWebSocket(); mockWebSocket.SetState(WebSocketState.Open); + var cacheProvider = new LocalCache(); var monitorCallback = connectionCallback ?? (isConnected => {}); - var client = new DatastreamClient("wss://test.example.com", logger, apiKey, monitorCallback, cacheTtl, mockWebSocket); + var client = new DatastreamClient("wss://test.example.com", logger, apiKey, monitorCallback, cacheProvider, cacheTtl, mockWebSocket); return (client, mockWebSocket, logger, monitorCallback); } diff --git a/src/SchematicHQ.Client.Test/TestCache.cs b/src/SchematicHQ.Client.Test/TestCache.cs index e237178a..b67a7dba 100644 --- a/src/SchematicHQ.Client.Test/TestCache.cs +++ b/src/SchematicHQ.Client.Test/TestCache.cs @@ -13,66 +13,67 @@ static IEnumerable SeTAndGetTestCases { get { - yield return new TestCaseData(new LocalCache(), true).SetName("TestSetAndGet_Boolean"); - yield return new TestCaseData(new LocalCache(), "test_string").SetName("TestSetAndGet_string"); - yield return new TestCaseData(new LocalCache>(), new List { "test_string1", "test_string2" }).SetName("TestSetAndGet_RefType"); + yield return new TestCaseData(new LocalCache(), true).SetName("TestSetAndGet_Boolean"); + yield return new TestCaseData(new LocalCache(), "test_string").SetName("TestSetAndGet_string"); + yield return new TestCaseData(new LocalCache(), new List { "test_string1", "test_string2" }).SetName("TestSetAndGet_RefType"); } } [Test, TestCaseSource(nameof(SeTAndGetTestCases))] - public void Get_ReturnsSetValue(ICacheProvider cache, T value) + public async Task Get_ReturnsSetValue(ICacheProvider cache, T value) { string key = "test_key"; - cache.Set(key: key, val: value); - var result = cache.Get(key); + await cache.Set(key: key, val: value); + var result = await cache.Get(key); Assert.That(result, Is.EqualTo(value)); } [Test] - public void Test_DefaultTTL() + public async Task Test_DefaultTTL() { - LocalCache cacheProvider = new LocalCache(maxItems: 1); + LocalCache cacheProvider = new LocalCache(maxItems: 1); bool? expectedResult = true; var key = "test_key"; + var defaultTtl = TimeSpan.FromMilliseconds(5000); - cacheProvider.Set(key: key, val: expectedResult); - var existingResult = cacheProvider.Get(key); - Thread.Sleep(LocalCache.DEFAULT_CACHE_TTL + TimeSpan.FromMilliseconds(5)); - var evictedResult = cacheProvider.Get(key); + await cacheProvider.Set(key: key, val: expectedResult); + var existingResult = await cacheProvider.Get(key); + Thread.Sleep(defaultTtl + TimeSpan.FromMilliseconds(5)); + var evictedResult = await cacheProvider.Get(key); Assert.That(existingResult, Is.EqualTo(expectedResult)); Assert.That(evictedResult, Is.Null); } [Test] - public void Test_DefaultCapacity() + public async Task Test_DefaultCapacity() { - LocalCache cacheProvider = new LocalCache(ttl: TimeSpan.FromMinutes(10)); + LocalCache cacheProvider = new LocalCache(ttl: TimeSpan.FromMinutes(10)); int? expectedResult = -1; var key = "test_key"; - cacheProvider.Set(key: key, val: expectedResult); - foreach (int i in Enumerable.Range(1, LocalCache.DEFAULT_CACHE_CAPACITY - 1)) + await cacheProvider.Set(key: key, val: expectedResult); + foreach (int i in Enumerable.Range(1, LocalCache.DEFAULT_CACHE_CAPACITY - 1)) { - cacheProvider.Set(key: i.ToString(), val: i); + await cacheProvider.Set(key: i.ToString(), val: i); - Assert.That(cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); + Assert.That(await cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); } - cacheProvider.Set(key: "new_key", val: -2); - var evictedResult = cacheProvider.Get(1.ToString()); + await cacheProvider.Set(key: "new_key", val: -2); + var evictedResult = await cacheProvider.Get(1.ToString()); - Assert.That(cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); - Assert.That(cacheProvider.Get(key: "new_key"), Is.EqualTo(-2)); + Assert.That(await cacheProvider.Get(key: key), Is.EqualTo(expectedResult)); + Assert.That(await cacheProvider.Get(key: "new_key"), Is.EqualTo(-2)); Assert.That(evictedResult, Is.Null); } [Test] - public void Test_NotExistentKeyReturnsDefaultValue() + public async Task Test_NotExistentKeyReturnsDefaultValue() { - LocalCache cacheProvider = new LocalCache(maxItems: 1); - Assert.That(cacheProvider.Get("non_existent_key"), Is.Null); + LocalCache cacheProvider = new LocalCache(maxItems: 1); + Assert.That(await cacheProvider.Get("non_existent_key"), Is.Null); } [Test] @@ -80,7 +81,7 @@ public void Test_ConcurrentAccess() { int numberOfThreads = 50; int cacheCapacity = 30; - LocalCache cacheProvider = new LocalCache(maxItems: cacheCapacity, ttl: TimeSpan.FromHours(5)); + LocalCache cacheProvider = new LocalCache(maxItems: cacheCapacity, ttl: TimeSpan.FromHours(5)); var tasks = new List(); var countdownEvent = new CountdownEvent(1); @@ -89,12 +90,12 @@ public void Test_ConcurrentAccess() int start = t * cacheCapacity + 1; int end = start + cacheCapacity - 1; - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { countdownEvent.Wait(); for (int i = start; i <= end; i++) { - cacheProvider.Set(i.ToString(), i); + await cacheProvider.Set(i.ToString(), i); } })); } @@ -106,7 +107,7 @@ public void Test_ConcurrentAccess() for (int i = 1; i <= numberOfThreads * cacheCapacity; i++) { - if (cacheProvider.Get(i.ToString()) == i) + if (cacheProvider.Get(i.ToString()).GetAwaiter().GetResult() == i) { cacheHitsIndices.Add(i); } @@ -119,29 +120,29 @@ public void Test_ConcurrentAccess() [Test] public void Test_TTLOverride() { - LocalCache cacheProvider = new LocalCache(maxItems: 1000, ttl: TimeSpan.FromHours(5)); + LocalCache cacheProvider = new LocalCache(maxItems: 1000, ttl: TimeSpan.FromHours(5)); var tasks = new List(); var countdownEvent = new CountdownEvent(1); string key = "test_key"; int expectedValue = 5; TimeSpan ttlOverride = TimeSpan.FromSeconds(3); - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { countdownEvent.Wait(); - cacheProvider.Set(key: key, val: expectedValue, ttlOverride: ttlOverride); + await cacheProvider.Set(key: key, val: expectedValue, ttlOverride: ttlOverride); })); - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { countdownEvent.Wait(); Thread.Sleep(1000); - Assert.That(cacheProvider.Get(key), Is.EqualTo(expectedValue)); + Assert.That(await cacheProvider.Get(key), Is.EqualTo(expectedValue)); })); - tasks.Add(Task.Run(() => + tasks.Add(Task.Run(async () => { countdownEvent.Wait(); Thread.Sleep(ttlOverride + TimeSpan.FromMilliseconds(1)); - Assert.That(cacheProvider.Get(key), Is.Null); + Assert.That(await cacheProvider.Get(key), Is.Null); })); countdownEvent.Signal(); @@ -149,23 +150,23 @@ public void Test_TTLOverride() } [Test] - public void Test_EvictionByLastAccessed() + public async Task Test_EvictionByLastAccessed() { - LocalCache cacheProvider = new LocalCache(maxItems: 10, ttl: TimeSpan.FromHours(5)); + LocalCache cacheProvider = new LocalCache(maxItems: 10, ttl: TimeSpan.FromHours(5)); foreach (var i in Enumerable.Range(1, 10)) { - cacheProvider.Set(i.ToString(), i); + await cacheProvider.Set(i.ToString(), i); } foreach (var i in Enumerable.Range(1, 10)) { - Assert.That(cacheProvider.Get(i.ToString()), Is.EqualTo(i)); + Assert.That(await cacheProvider.Get(i.ToString()), Is.EqualTo(i)); } foreach (var I in Enumerable.Range(1, 10)) { - cacheProvider.Set((I + 10).ToString(), -1); - Assert.That(cacheProvider.Get(I.ToString()), Is.Null); + await cacheProvider.Set((I + 10).ToString(), -1); + Assert.That(await cacheProvider.Get(I.ToString()), Is.Null); } } } diff --git a/src/SchematicHQ.Client.Test/TestClient.cs b/src/SchematicHQ.Client.Test/TestClient.cs index 72be70e3..82ed2a37 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -108,7 +108,7 @@ private void SetupSchematicTestClient(bool isOffline, HttpResponseMessage respon Offline = isOffline, FlagDefaults = flagDefaults ?? new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), - CacheProviders = new List> { new LocalCache() } + CacheProvider = new LocalCache() }; if (!_options.Offline) @@ -145,12 +145,9 @@ public async Task CheckFlag_CachesResultIfNotCached() // Assert Assert.That(result, Is.True); - foreach (var cacheProvider in _options.CacheProviders) - { - var cached = cacheProvider.Get(flagKey); - Assert.That(cached, Is.Not.Null); - Assert.That(cached!.Value, Is.True); - } + var cached = await _options.CacheProvider!.Get(flagKey); + Assert.That(cached, Is.Not.Null); + Assert.That(cached!.Value, Is.True); } [Test] @@ -160,10 +157,7 @@ public async Task CheckFlag_StoreCorrectCacheKey() SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); string flagKey = "test_flag"; string cacheKey = "test_flag:c-name=test_company:u-id=unique_id"; - foreach (var cacheProvider in _options.CacheProviders) - { - cacheProvider.Set(cacheKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); - } + await _options.CacheProvider!.Set(cacheKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); // Act var result = await _schematic.CheckFlag( @@ -182,10 +176,7 @@ public async Task CheckFlag_ReturnsCachedValueIfExists() // Arrange SetupSchematicTestClient(isOffline: false, response: CreateCheckFlagResponse(HttpStatusCode.OK, false)); string flagKey = "test_flag"; - foreach (var cacheProvider in _options.CacheProviders) - { - cacheProvider.Set(flagKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); - } + await _options.CacheProvider!.Set(flagKey, new CheckFlagWithEntitlementResponse { FlagKey = flagKey, Value = true, Reason = "cache" }); // Act var result = await _schematic.CheckFlag(flagKey); @@ -432,14 +423,11 @@ public async Task CheckFlagWithEntitlement_CachesAndReturnsCachedResponse() Assert.That(result1.Value, Is.True); // Verify cache was populated with full response - foreach (var cacheProvider in _options.CacheProviders) - { - var cached = cacheProvider.Get(flagKey); - Assert.That(cached, Is.Not.Null); - Assert.That(cached!.Value, Is.True); - Assert.That(cached.FlagKey, Is.EqualTo(flagKey)); - Assert.That(cached.Reason, Is.EqualTo("matched entitlement rule")); - } + var cached = await _options.CacheProvider!.Get(flagKey); + Assert.That(cached, Is.Not.Null); + Assert.That(cached!.Value, Is.True); + Assert.That(cached.FlagKey, Is.EqualTo(flagKey)); + Assert.That(cached.Reason, Is.EqualTo("matched entitlement rule")); } [Test] @@ -457,7 +445,7 @@ public async Task CheckFlag_WithCacheDisabled_AlwaysCallsAPI() Offline = false, FlagDefaults = new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), - CacheProviders = new List> { new LocalCache(maxItems: 0) } + CacheProvider = new LocalCache(maxItems: 0) }; _schematic = new Schematic("dummy_api_key", _options.WithHttpClient(testClient)); @@ -507,7 +495,7 @@ public async Task CheckFlag_DifferentContextsProduceDifferentCacheKeys() Offline = false, FlagDefaults = new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), - CacheProviders = new List> { new LocalCache() } + CacheProvider = new LocalCache() }; _schematic = new Schematic("dummy_api_key", _options.WithHttpClient(testClient)); @@ -651,11 +639,8 @@ public async Task CheckFlags_UsesCacheWhenAllKeysCached() response: CreateCheckFlagsResponse(HttpStatusCode.OK, ("flag_a", false), ("flag_b", false)) ); - foreach (var provider in _options.CacheProviders) - { - provider.Set("flag_a", new CheckFlagWithEntitlementResponse { FlagKey = "flag_a", Value = true, Reason = "cache" }); - provider.Set("flag_b", new CheckFlagWithEntitlementResponse { FlagKey = "flag_b", Value = true, Reason = "cache" }); - } + await _options.CacheProvider.Set("flag_a", new CheckFlagWithEntitlementResponse { FlagKey = "flag_a", Value = true, Reason = "cache" }); + await _options.CacheProvider.Set("flag_b", new CheckFlagWithEntitlementResponse { FlagKey = "flag_b", Value = true, Reason = "cache" }); var results = await _schematic.CheckFlags(keys: new[] { "flag_a", "flag_b" }); diff --git a/src/SchematicHQ.Client/Cache/CacheConfiguration.cs b/src/SchematicHQ.Client/Cache/CacheConfiguration.cs index 2123a276..91a06350 100644 --- a/src/SchematicHQ.Client/Cache/CacheConfiguration.cs +++ b/src/SchematicHQ.Client/Cache/CacheConfiguration.cs @@ -1,7 +1,3 @@ -using SchematicHQ.Client.RulesEngine; - -#nullable enable - namespace SchematicHQ.Client.Cache { /// @@ -43,6 +39,6 @@ public class CacheConfiguration /// /// Cache capacity for local cache /// - public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; + public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; } } diff --git a/src/SchematicHQ.Client/Cache/CacheFactory.cs b/src/SchematicHQ.Client/Cache/CacheFactory.cs index ebde5c59..0473382a 100644 --- a/src/SchematicHQ.Client/Cache/CacheFactory.cs +++ b/src/SchematicHQ.Client/Cache/CacheFactory.cs @@ -1,7 +1,5 @@ #nullable enable -using StackExchange.Redis; - namespace SchematicHQ.Client.Cache { /// @@ -16,9 +14,9 @@ public static class CacheFactory /// Maximum number of items in the cache /// Time-to-live for cached items /// A new local cache instance - public static ICacheProvider CreateLocalCache(int capacity = LocalCache.DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) + public static ICacheProvider CreateLocalCache(int capacity = LocalCache.DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) { - return new LocalCache(capacity, ttl); + return new LocalCache(capacity, ttl); } /// @@ -27,9 +25,9 @@ public static ICacheProvider CreateLocalCache(int capacity = LocalCache /// Type of values to cache /// Redis configuration /// A new Redis cache instance - public static ICacheProvider CreateRedisCache(Datastream.RedisCacheConfig config) + public static ICacheProvider CreateRedisCache(Datastream.RedisCacheConfig config) { - return new RedisCache(config); + return new RedisCache(config); } } } diff --git a/src/SchematicHQ.Client/Cache/ICacheProvider.cs b/src/SchematicHQ.Client/Cache/ICacheProvider.cs index c60861e4..21df56b8 100644 --- a/src/SchematicHQ.Client/Cache/ICacheProvider.cs +++ b/src/SchematicHQ.Client/Cache/ICacheProvider.cs @@ -1,19 +1,18 @@ -#nullable enable namespace SchematicHQ.Client.Cache { /// /// Interface for cache providers used by SchematicHQ client /// - /// Type of values stored in the cache - public interface ICacheProvider + public interface ICacheProvider { /// /// Get a value from the cache by key /// /// Cache key + /// An optional cancellation token /// The cached value or default if not found or expired - T? Get(string key); + ValueTask Get(string key, CancellationToken token = default) where T: notnull; /// /// Set a value in the cache @@ -21,14 +20,25 @@ public interface ICacheProvider /// Cache key /// Value to cache /// Optional time-to-live override - void Set(string key, T val, TimeSpan? ttlOverride = null); + /// An optional cancellation token + ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull; + + /// + /// Gets a value from the cache or if not present, uses the provided factory function. + /// + /// Cache key + /// The factory to generate the value + /// Optional time-to-live override + /// An optional cancellation token + ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull; /// /// Delete a key from the cache /// /// Cache key to delete /// True if the key was deleted, false otherwise - bool Delete(string key); + /// An optional cancellation token + ValueTask Delete(string key, CancellationToken token = default); /// /// Delete all keys not present in the provided enumeration @@ -40,6 +50,6 @@ public interface ICacheProvider /// which can wipe sibling caches that share the same prefix and Redis DB. Callers should pass /// the narrowest pattern they own (e.g. "schematic:flags:*") to scope the scan. /// - void DeleteMissing(IEnumerable keys, string? scanPattern = null); + ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null); } } diff --git a/src/SchematicHQ.Client/Cache/LocalCache.cs b/src/SchematicHQ.Client/Cache/LocalCache.cs index 4db6c30f..ac17f0d4 100644 --- a/src/SchematicHQ.Client/Cache/LocalCache.cs +++ b/src/SchematicHQ.Client/Cache/LocalCache.cs @@ -1,21 +1,17 @@ using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; -#nullable enable +namespace SchematicHQ.Client.Cache; -namespace SchematicHQ.Client.Cache -{ /// /// A thread-safe in-memory cache implementation with LRU eviction policy and background expiration /// - /// Type of values stored in the cache - public class LocalCache : ICacheProvider, IDisposable + public class LocalCache : ICacheProvider, IDisposable { - public const int DEFAULT_CACHE_CAPACITY = 1000; + public const int DEFAULT_CACHE_CAPACITY = 10000; public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMilliseconds(5000); // 5000 milliseconds public static readonly TimeSpan UNLIMITED_TTL = TimeSpan.MaxValue; - private readonly ConcurrentDictionary> _cache; + private readonly ConcurrentDictionary _cache; private readonly LinkedList _lruList; private readonly object _lock = new object(); private readonly int _maxItems; @@ -32,7 +28,7 @@ public class LocalCache : ICacheProvider, IDisposable /// Whether to enable the background cleanup timer (defaults to true) public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, bool enableBackgroundCleanup = true) { - _cache = new ConcurrentDictionary>(); + _cache = new ConcurrentDictionary(); _lruList = new LinkedList(); _maxItems = maxItems; _ttl = ttl ?? DEFAULT_CACHE_TTL; @@ -58,55 +54,18 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } /// - public T? Get(string key) + public ValueTask Get(string key, CancellationToken token = default) where T: notnull { - if (_maxItems == 0 || _disposed) - return default; - - if (!_cache.TryGetValue(key, out var item)) - return default; - - if (item.Expiration != DateTime.MaxValue && DateTime.UtcNow > item.Expiration) - { - // This item has expired, remove it - Remove(key); - return default; - } - - // Update LRU position - We need to check if the node is still part of the list - // because it might have been removed by another thread - lock (_lock) - { - try - { - // Check if node is still in the list before removing it - if (item.Node.List != null) - { - _lruList.Remove(item.Node); - _lruList.AddFirst(item.Node); - } - else - { - // Node was already removed, add a new one - item.Node = _lruList.AddFirst(key); - } - } - catch (InvalidOperationException) - { - // If the node was already removed by another thread, - // create a new node for the key - item.Node = _lruList.AddFirst(key); - } - } - - return item.Value; + return TryGet(key, out var value) + ? ValueTask.FromResult(value) + : ValueTask.FromResult(default(T)); } /// - public void Set(string key, T val, TimeSpan? ttlOverride = null) + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull { if (_maxItems == 0 || _disposed) - return; + return ValueTask.CompletedTask; var ttl = ttlOverride ?? _ttl; // Determine expiration time - use MaxValue for unlimited TTL @@ -119,7 +78,7 @@ public void Set(string key, T val, TimeSpan? ttlOverride = null) if (_cache.TryGetValue(key, out var existingItem)) { // Update the existing item - existingItem.Value = val; + existingItem.Value = val!; existingItem.Expiration = expiration; try @@ -158,7 +117,7 @@ public void Set(string key, T val, TimeSpan? ttlOverride = null) // Add new item to cache and LRU list var node = _lruList.AddFirst(key); - var newItem = new CachedItem(val, expiration, node); + var newItem = new CachedItem(val!, expiration, node); if (!_cache.TryAdd(key, newItem)) { // If we couldn't add it, clean up the linked list node @@ -166,32 +125,98 @@ public void Set(string key, T val, TimeSpan? ttlOverride = null) } } } + + return ValueTask.CompletedTask; } - /// - public bool Delete(string key) + private bool TryGet(string key, [NotNullWhen(true)] out T? value) where T : notnull { + value = default; + if (_maxItems == 0 || _disposed) return false; + if (!_cache.TryGetValue(key, out var item)) + return false; + + if (item.Expiration != DateTime.MaxValue && DateTime.UtcNow > item.Expiration) + { + Remove(key); + return false; + } + + // Update LRU position - same logic as Get + lock (_lock) + { + try + { + if (item.Node.List != null) + { + _lruList.Remove(item.Node); + _lruList.AddFirst(item.Node); + } + else + { + item.Node = _lruList.AddFirst(key); + } + } + catch (InvalidOperationException) + { + item.Node = _lruList.AddFirst(key); + } + } + + if (item.Value is T typedValue) + { + value = typedValue; + return true; + } + + return false; // Type mismatch — treat as a miss + } + + /// + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull + { + if (TryGet(key, out var existing)) + return existing; + + // Cache miss — invoke the factory and store the result + var value = await factory(token); + await Set(key, value, ttlOverride, token); + return value; + } + + /// + public ValueTask Delete(string key, CancellationToken token = default) + { + if (_maxItems == 0 || _disposed) + return ValueTask.FromResult(false); + Remove(key); - return true; + return ValueTask.FromResult(true); } /// - public void DeleteMissing(IEnumerable keys, string? scanPattern = null) + public ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null) { if (_maxItems == 0 || _disposed) - return; + return ValueTask.CompletedTask; var keysSet = new HashSet(keys); var keysToRemove = new List(); - // Collect keys to remove (those not in the provided list) + // Collect keys to remove (those not in the provided list, optionally filtered by pattern) lock (_lock) { foreach (var cacheKey in _cache.Keys) { + // If a scanPattern is provided, only consider keys that match it + if (scanPattern != null && !GlobMatch(cacheKey, scanPattern)) + { + continue; + } + if (!keysSet.Contains(cacheKey)) { keysToRemove.Add(cacheKey); @@ -204,6 +229,50 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) { Remove(keyToRemove); } + + return ValueTask.CompletedTask; + } + + /// + /// Simple glob pattern matcher supporting '*' (match any sequence) and '?' (match single char). + /// This mirrors the Redis SCAN pattern semantics used by RedisCache. + /// + private static bool GlobMatch(string input, string pattern) + { + int i = 0, p = 0; + int starI = -1, starP = -1; + + while (i < input.Length) + { + if (p < pattern.Length && (pattern[p] == '?' || pattern[p] == input[i])) + { + i++; + p++; + } + else if (p < pattern.Length && pattern[p] == '*') + { + starI = i; + starP = p; + p++; + } + else if (starP >= 0) + { + p = starP + 1; + starI++; + i = starI; + } + else + { + return false; + } + } + + while (p < pattern.Length && pattern[p] == '*') + { + p++; + } + + return p == pattern.Length; } private void Remove(string key) @@ -307,13 +376,13 @@ protected virtual void Dispose(bool disposing) /// /// Represents a cached item with its value, expiration time, and position in the LRU list /// - private class CachedItem + private class CachedItem { - public TValue Value { get; set; } + public object Value { get; set; } public DateTime Expiration { get; set; } public LinkedListNode Node { get; set; } - public CachedItem(TValue value, DateTime expiration, LinkedListNode node) + public CachedItem(object value, DateTime expiration, LinkedListNode node) { Value = value; Expiration = expiration; @@ -321,4 +390,4 @@ public CachedItem(TValue value, DateTime expiration, LinkedListNode node } } } -} + diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index f8ee427b..e6df31a8 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -1,46 +1,67 @@ using System.Text.Json; -using System.Text.Json.Serialization; using StackExchange.Redis; using SchematicHQ.Client.Datastream; -#nullable enable - namespace SchematicHQ.Client.Cache { /// /// Redis cache provider for SchematicHQ client /// - /// Type of values stored in the cache - public class RedisCache : ICacheProvider + public class RedisCache : ICacheProvider { public const string DEFAULT_KEY_PREFIX = "schematic:"; public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMinutes(5); public static readonly TimeSpan UNLIMITED_TTL = TimeSpan.MaxValue; - private readonly ConnectionMultiplexer _redis; + private readonly IConnectionMultiplexer _redis; private readonly IDatabase _db; private readonly string _keyPrefix; private readonly TimeSpan _ttl; private readonly JsonSerializerOptions _jsonOptions; - /// - /// Creates a new RedisCache instance using structured configuration (recommended) - /// - /// Redis configuration - public RedisCache(RedisCacheConfig config) - { - if (config == null) - { - throw new ArgumentNullException(nameof(config)); - } - - if (config.Endpoints == null || !config.Endpoints.Any()) + /// + /// Creates a new RedisCache instance using structured configuration (recommended) + /// + /// Redis configuration + public RedisCache(RedisCacheConfig config) { - throw new ArgumentException("Redis endpoints cannot be null or empty", nameof(config)); + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + try + { + _redis = GetRedis(config); + _db = _redis.GetDatabase(config.Database); + _keyPrefix = config.KeyPrefix ?? DEFAULT_KEY_PREFIX; + _ttl = config.CacheTTL ?? DEFAULT_CACHE_TTL; + _jsonOptions = SchematicCacheSerializerDefaults.Options; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to connect to Redis: {ex.Message}", ex); + } } - try + private static IConnectionMultiplexer GetRedis(RedisCacheConfig config) { + if (config.ConnectionMultiplexerFactory != null) + return config.ConnectionMultiplexerFactory.Invoke(); + + if (config.ConfigurationOptions != null) + return ConnectionMultiplexer.Connect(config.ConfigurationOptions); + + if(config.Configuration != null) + return ConnectionMultiplexer.Connect(config.Configuration); + + // Obsolete configuration below +#pragma warning disable CS0618 // Type or member is obsolete + if (config.Endpoints == null || !config.Endpoints.Any()) + { + throw new ArgumentException("Redis endpoints cannot be null or empty", nameof(config)); + } + var options = new ConfigurationOptions { AbortOnConnectFail = config.AbortOnConnectFail, @@ -84,77 +105,78 @@ public RedisCache(RedisCacheConfig config) { options.Password = config.Password; } + if (!string.IsNullOrEmpty(config.Username)) { options.User = config.Username; } - - _redis = ConnectionMultiplexer.Connect(options); - _db = _redis.GetDatabase(config.Database); - _keyPrefix = config.KeyPrefix ?? DEFAULT_KEY_PREFIX; - _ttl = config.CacheTTL ?? DEFAULT_CACHE_TTL; - _jsonOptions = new JsonSerializerOptions - { - WriteIndented = false, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - Converters = { - new ComparableTypeConverter(), // Specific handler for ComparableType empty strings - new ResilientEnumConverter() // Fallback for all other enums - } - }; +#pragma warning restore CS0618 // Type or member is obsolete + + return ConnectionMultiplexer.Connect(options); } - catch (Exception ex) + + /// + public async ValueTask Get(string key, CancellationToken token = default) where T: notnull { - throw new InvalidOperationException($"Failed to connect to Redis: {ex.Message}", ex); + var (_, value) = await TryGetInternalAsync(key); + return value; } - } - /// - public T? Get(string key) + public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull + { + var redisKey = GetRedisKey(key); + var ttl = ttlOverride ?? _ttl; + + // Use infinite expiry for UNLIMITED_TTL + var expiry = ttl == UNLIMITED_TTL ? (TimeSpan?)null : ttl; + + var json = JsonSerializer.Serialize(val, _jsonOptions); + await _db.StringSetAsync(redisKey, json, expiry); + } + + private async Task<(bool Found, T? Value)> TryGetInternalAsync(string key) where T : notnull { var redisKey = GetRedisKey(key); - var value = _db.StringGet(redisKey); + var value = await _db.StringGetAsync(redisKey); if (value.IsNullOrEmpty) - { - return default; - } + return (false, default); try { - return JsonSerializer.Deserialize(value!, _jsonOptions); + return (true, JsonSerializer.Deserialize(value!, _jsonOptions)); } - catch (Exception e) + catch { - // If we can't deserialize, just remove the value and return null - Delete(key); - return default; + // Bad payload + await Delete(key); + return (false, default); } } - /// - public void Set(string key, T val, TimeSpan? ttlOverride = null) + //TODO update this with stampede protection at some point. + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull { - var redisKey = GetRedisKey(key); - var ttl = ttlOverride ?? _ttl; - - // Use infinite expiry for UNLIMITED_TTL - var expiry = ttl == UNLIMITED_TTL ? (TimeSpan?)null : ttl; - - var json = JsonSerializer.Serialize(val, _jsonOptions); - _db.StringSet(redisKey, json, expiry); + var (found, existing) = await TryGetInternalAsync(key); + if (found) + return existing!; + + // Cache miss — invoke the factory and store the result + var value = await factory(token); + await Set(key, value, ttlOverride, token); + return value; } /// - public bool Delete(string key) + public async ValueTask Delete(string key, CancellationToken token = default) { var redisKey = GetRedisKey(key); - return _db.KeyDelete(redisKey); + return await _db.KeyDeleteAsync(redisKey); } /// - public void DeleteMissing(IEnumerable keys, string? scanPattern = null) + public async ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null) { // Convert keys to Redis keys var keysToKeep = new HashSet(keys.Select(k => GetRedisKey(k))); @@ -169,28 +191,31 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) foreach (var endpoint in _redis.GetEndPoints()) { var server = _redis.GetServer(endpoint); - - // Get keys to delete - var keysToDelete = server.Keys(pattern: pattern) - .Where(key => !keysToKeep.Contains(key)) - .ToArray(); - + + var keysToDelete = new List(); + + await foreach (var key in server.KeysAsync(pattern: pattern)) + { + if (!keysToKeep.Contains(key)) + keysToDelete.Add(key); + } + // Delete keys in batches - if (keysToDelete.Length > 0) + if (keysToDelete.Count > 0) { const int batchSize = 100; - for (int i = 0; i < keysToDelete.Length; i += batchSize) + for (int i = 0; i < keysToDelete.Count; i += batchSize) { var batch = keysToDelete.Skip(i).Take(batchSize).ToArray(); - _db.KeyDelete(batch); + await _db.KeyDeleteAsync(batch); } } } } private RedisKey GetRedisKey(string key) - { + { return $"{_keyPrefix}{key}"; } } -} +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs b/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs new file mode 100644 index 00000000..9ed526e0 --- /dev/null +++ b/src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace SchematicHQ.Client.Cache; + +public static class SchematicCacheSerializerDefaults +{ + public static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = + { + new ComparableTypeConverter(), // Specific handler for ComparableType empty strings + new ResilientEnumConverter() // Fallback for all other enums + } + }; +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index 2b7be7d6..78c46c80 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -29,7 +29,9 @@ public ILoggerFactory LoggerFactory /// Sets the log level for the client. Defaults to LogLevel.Warning. No-op if a custom is provided. /// public LogLevel LogLevel { get; set; } = LogLevel.Warning; - public List> CacheProviders { get; set; } = new List>(); + + public ICacheProvider? CacheProvider { get; set; } + public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -61,7 +63,7 @@ public static ClientOptions WithHttpClient(this ClientOptions options, HttpClien return new ClientOptions { BaseUrl = options.BaseUrl, - CacheProviders = options.CacheProviders, + CacheProvider = options.CacheProvider, CacheConfiguration = options.CacheConfiguration, DatastreamOptions = options.DatastreamOptions, DefaultEventBufferPeriod = options.DefaultEventBufferPeriod, @@ -125,7 +127,7 @@ public static ClientOptions WithRedisCache( /// Updated client options public static ClientOptions WithLocalCache( this ClientOptions options, - int capacity = Cache.LocalCache.DEFAULT_CACHE_CAPACITY, + int capacity = Cache.LocalCache.DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) { options.CacheConfiguration = new Cache.CacheConfiguration diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index ff06969f..c0c8af2b 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -27,11 +27,8 @@ public class DatastreamClient : IDisposable private CancellationTokenSource _readCancellationSource = new CancellationTokenSource(); // Cache providers - private readonly ICacheProvider _flagsCache; - private readonly ICacheProvider _companyCache; - private readonly ICacheProvider _userCache; - private readonly ICacheProvider _companyLookupCache; - private readonly ICacheProvider _userLookupCache; + private readonly ICacheProvider _cacheProvider; + private readonly ICacheProvider _flagsCache; // Cache version provider (optional, for replicator mode) private readonly Func? _cacheVersionProvider; @@ -83,6 +80,7 @@ public DatastreamClient( ILogger logger, string apiKey, Action connectionStateCallback, + ICacheProvider cacheProvider, TimeSpan? cacheTtl = null, IWebSocketClient? webSocket = null, DatastreamOptions? options = null, @@ -112,43 +110,10 @@ public DatastreamClient( { flagTTL = _cacheTtl; } - - // Company and User caches use the configured provider type - if (options.CacheProviderType == DatastreamCacheProviderType.Redis && - options.RedisConfig != null) - { - try - { - _logger.LogInformation("Initializing Redis cache for Datastream company, user and flag data"); - // We need to use the Cache namespace version, but cast it to the Client namespace interface - _companyCache = new RedisCache(options.RedisConfig); - _userCache = new RedisCache(options.RedisConfig); - _companyLookupCache = new RedisCache(options.RedisConfig); - _userLookupCache = new RedisCache(options.RedisConfig); - var flagConfig = options.RedisConfig; - flagConfig.CacheTTL = flagTTL; // Set TTL for flags cache - _flagsCache = new RedisCache(flagConfig); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initialize Redis cache. Falling back to local cache."); - _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _companyLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); - } - } - else - { - // Use local cache (default) - _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _companyLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _userLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); - _flagsCache = new LocalCache(options.LocalCacheCapacity, flagTTL); - } - + + _cacheProvider = new DatastreamCacheDecorator(cacheProvider, _cacheTtl); + _flagsCache = new DatastreamCacheDecorator(cacheProvider, flagTTL); + _webSocket = webSocket ?? new StandardWebSocketClient(); } @@ -391,7 +356,7 @@ private async Task ReadMessagesAsync() try { var response = JsonSerializer.Deserialize(message); - HandleMessageResponse(response); + await HandleMessageResponse(response); } catch (Exception ex) { @@ -434,7 +399,7 @@ private async Task ReadMessagesAsync() } } - private void HandleMessageResponse(DataStreamResponse? message) + private async Task HandleMessageResponse(DataStreamResponse? message) { if (message == null) { @@ -450,16 +415,16 @@ private void HandleMessageResponse(DataStreamResponse? message) switch (message.EntityType) { case EntityType.Company: - HandleCompanyMessage(message); + await HandleCompanyMessage(message); break; case EntityType.Flags: - HandleFlagsMessage(message); + await HandleFlagsMessage(message); break; case EntityType.Flag: - HandleFlagMessage(message); + await HandleFlagMessage(message); break; case EntityType.User: - HandleUserMessage(message); + await HandleUserMessage(message); break; default: _logger.LogError("Received unknown entity type: {EntityType}", message.EntityType); @@ -467,7 +432,7 @@ private void HandleMessageResponse(DataStreamResponse? message) } } - private void HandleFlagsMessage(DataStreamResponse response) + private async Task HandleFlagsMessage(DataStreamResponse response) { try { @@ -502,11 +467,11 @@ private void HandleFlagsMessage(DataStreamResponse response) continue; } var cacheKey = FlagCacheKey(flag.Key); - _flagsCache.Set(cacheKey, flag); + await _flagsCache.Set(cacheKey, flag); cacheKeys.Add(cacheKey); } - _flagsCache.DeleteMissing(cacheKeys, $"{CacheKeyPrefix}:{CacheKeyPrefixFlags}:*"); + await _flagsCache.DeleteMissing(cacheKeys, $"{CacheKeyPrefix}:{CacheKeyPrefixFlags}:*"); lock (_pendingRequestsLock) { @@ -522,7 +487,7 @@ private void HandleFlagsMessage(DataStreamResponse response) } } - private void HandleFlagMessage(DataStreamResponse response) + private async Task HandleFlagMessage(DataStreamResponse response) { try { @@ -567,8 +532,8 @@ private void HandleFlagMessage(DataStreamResponse response) if (!string.IsNullOrEmpty(flagKey)) { var deleteCacheKey = FlagCacheKey(flagKey); - _flagsCache.Delete(deleteCacheKey); - _logger.LogDebug("Deleted single flag from cache: {FlagKey}", flagKey); + await _flagsCache.Delete(deleteCacheKey); + _logger.LogDebug("Deleted single flag from cache: {0}", flagKey); } else { @@ -594,8 +559,8 @@ private void HandleFlagMessage(DataStreamResponse response) } var cacheKey = FlagCacheKey(flag.Key); - _flagsCache.Set(cacheKey, flag); - _logger.LogDebug("Cached single flag: {FlagKey}", flag.Key); + await _flagsCache.Set(cacheKey, flag); + _logger.LogDebug("Cached single flag: {0}", flag.Key); // Note: Unlike bulk flags processing, we do NOT call DeleteMissing for single flag updates } @@ -633,7 +598,7 @@ private void NotifyPendingRequests(T? entity, IDictionary key } } - private async void HandleCompanyMessage(DataStreamResponse response) + private async Task HandleCompanyMessage(DataStreamResponse response) { try { @@ -666,7 +631,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) var id = response.EntityId; var existingIdKey = CompanyIdCacheKey(id); - var existing = _companyCache.Get(existingIdKey); + var existing = await _cacheProvider.Get(existingIdKey); if (existing == null) { _logger.LogWarning("Cache miss for partial company '{CompanyId}', skipping", id); @@ -712,18 +677,18 @@ private async void HandleCompanyMessage(DataStreamResponse response) { // Handle deletion: remove ID key from data cache and resource keys from lookup cache var idKey = CompanyIdCacheKey(company.Id); - _companyCache.Delete(idKey); + await _cacheProvider.Delete(idKey); foreach (var key in company.Keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); - _companyLookupCache.Delete(resourceKey); + await _cacheProvider.Delete(resourceKey); } return; } // Update cache using two-layer approach (inside the lock to prevent race conditions) - CacheCompanyForKeys(company); + await CacheCompanyForKeys(company); // Notify pending requests NotifyPendingRequests(company, company.Keys, CacheKeyPrefixCompany, _pendingCompanyRequests); @@ -745,7 +710,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) } } - private void HandleUserMessage(DataStreamResponse response) + private async Task HandleUserMessage(DataStreamResponse response) { try { @@ -777,7 +742,7 @@ private void HandleUserMessage(DataStreamResponse response) var id = response.EntityId; var existingIdKey = UserIdCacheKey(id); - var existing = _userCache.Get(existingIdKey); + var existing = await _cacheProvider.Get(existingIdKey); if (existing == null) { _logger.LogWarning("Cache miss for partial user '{UserId}', skipping", id); @@ -809,18 +774,18 @@ private void HandleUserMessage(DataStreamResponse response) { // Handle deletion: remove ID key from data cache and resource keys from lookup cache var idKey = UserIdCacheKey(user.Id); - _userCache.Delete(idKey); + await _cacheProvider.Delete(idKey); foreach (var key in user.Keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); - _userLookupCache.Delete(resourceKey); - _logger.LogDebug("Deleted user from cache with key: {ResourceKey}", resourceKey); + await _cacheProvider.Delete(resourceKey); + _logger.LogDebug("Deleted user from cache with key: {0}", resourceKey); } return; } // Update cache using two-layer approach - CacheUserForKeys(user); + await CacheUserForKeys(user); // Notify pending requests NotifyPendingRequests(user, user.Keys, CacheKeyPrefixUser, _pendingUserRequests); @@ -1074,22 +1039,22 @@ private async Task GetAllFlagsAsync(CancellationToken cancellationToken) } } - internal RulesengineFlag? GetFlag(string key) + internal async ValueTask GetFlag(string key) { - var flag = _flagsCache.Get(FlagCacheKey(key)); + var flag = await _flagsCache.Get(FlagCacheKey(key)); return flag; } - internal RulesengineCompany? GetCompanyFromCache(Dictionary keys) + internal async ValueTask GetCompanyFromCache(Dictionary keys) { foreach (var key in keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); - var companyId = _companyLookupCache.Get(resourceKey); + var companyId = await _cacheProvider.Get(resourceKey); if (companyId != null) { var idKey = CompanyIdCacheKey(companyId); - var company = _companyCache.Get(idKey); + var company = await _cacheProvider.Get(idKey); if (company != null) { return company; @@ -1099,16 +1064,16 @@ private async Task GetAllFlagsAsync(CancellationToken cancellationToken) return null; } - internal RulesengineUser? GetUserFromCache(Dictionary keys) + internal async ValueTask GetUserFromCache(Dictionary keys) { foreach (var key in keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); - var userId = _userLookupCache.Get(resourceKey); + var userId = await _cacheProvider.Get(resourceKey); if (userId != null) { var idKey = UserIdCacheKey(userId); - var user = _userCache.Get(idKey); + var user = await _cacheProvider.Get(idKey); if (user != null) { return user; @@ -1155,7 +1120,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) try { // Get company from cache (inside the lock to ensure consistency) - var company = GetCompanyFromCache(eventBody.Company); + var company = await GetCompanyFromCache(eventBody.Company); if (company == null) { return false; @@ -1190,7 +1155,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) // Cache the updated company using two-layer approach (still inside the lock) try { - CacheCompanyForKeys(companyCopy); + await CacheCompanyForKeys(companyCopy); } catch (Exception ex) { @@ -1354,12 +1319,12 @@ private string UserIdCacheKey(string id) return $"{CacheKeyPrefix}:{CacheKeyPrefixUser}:{schemaVersion}:{id}"; } - private void CacheCompanyForKeys(RulesengineCompany company) + private async Task CacheCompanyForKeys(RulesengineCompany company) { var idKey = CompanyIdCacheKey(company.Id); // Remove lookup cache entries for keys that no longer exist - var existing = _companyCache.Get(idKey); + var existing = await _cacheProvider.Get(idKey); if (existing != null) { foreach (var oldKey in existing.Keys) @@ -1368,28 +1333,28 @@ private void CacheCompanyForKeys(RulesengineCompany company) !string.Equals(company.Keys[oldKey.Key], oldKey.Value, StringComparison.OrdinalIgnoreCase)) { var staleResourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, oldKey.Key, oldKey.Value); - _companyLookupCache.Delete(staleResourceKey); + await _cacheProvider.Delete(staleResourceKey); } } } // Store the company object at the ID-based key - _companyCache.Set(idKey, company); + await _cacheProvider.Set(idKey, company); // Store the company ID string at each resource key foreach (var key in company.Keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixCompany, key.Key, key.Value); - _companyLookupCache.Set(resourceKey, company.Id); + await _cacheProvider.Set(resourceKey, company.Id); } } - private void CacheUserForKeys(RulesengineUser user) + private async Task CacheUserForKeys(RulesengineUser user) { var idKey = UserIdCacheKey(user.Id); // Remove lookup cache entries for keys that no longer exist - var existing = _userCache.Get(idKey); + var existing = await _cacheProvider.Get(idKey); if (existing != null) { foreach (var oldKey in existing.Keys) @@ -1398,19 +1363,19 @@ private void CacheUserForKeys(RulesengineUser user) !string.Equals(user.Keys[oldKey.Key], oldKey.Value, StringComparison.OrdinalIgnoreCase)) { var staleResourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, oldKey.Key, oldKey.Value); - _userLookupCache.Delete(staleResourceKey); + await _cacheProvider.Delete(staleResourceKey); } } } // Store the user object at the ID-based key - _userCache.Set(idKey, user); + await _cacheProvider.Set(idKey, user); // Store the user ID string at each resource key foreach (var key in user.Keys) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); - _userLookupCache.Set(resourceKey, user.Id); + await _cacheProvider.Set(resourceKey, user.Id); } } @@ -1540,9 +1505,9 @@ public void Dispose() _readCancellationSource.Dispose(); // Dispose lookup caches if they implement IDisposable - if (_companyLookupCache is IDisposable companyLookupDisposable) + if (_cacheProvider is IDisposable companyLookupDisposable) companyLookupDisposable.Dispose(); - if (_userLookupCache is IDisposable userLookupDisposable) + if (_flagsCache is IDisposable userLookupDisposable) userLookupDisposable.Dispose(); // Clean up company locks diff --git a/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs new file mode 100644 index 00000000..6e847d48 --- /dev/null +++ b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs @@ -0,0 +1,41 @@ +using SchematicHQ.Client.Cache; + +namespace SchematicHQ.Client.Datastream; + +// Datastream uses the same cache, but it has different default TTL +internal class DatastreamCacheDecorator : ICacheProvider +{ + private readonly ICacheProvider _inner; + private readonly TimeSpan _cacheTtl; + + public DatastreamCacheDecorator(ICacheProvider inner, TimeSpan cacheTtl) + { + _inner = inner; + _cacheTtl = cacheTtl; + } + + public ValueTask Get(string key, CancellationToken token = default) + { + return _inner.Get(key, token); + } + + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) + { + return _inner.Set(key, val, ttlOverride ?? _cacheTtl, token); + } + + public ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) + { + return _inner.GetOrSet(key, factory, ttlOverride ?? _cacheTtl, token); + } + + public ValueTask Delete(string key, CancellationToken token = default) + { + return _inner.Delete(key, token); + } + + public ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null) + { + return _inner.DeleteMissing(keys, scanPattern); + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs index f27b2592..64aff104 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs @@ -10,6 +10,7 @@ // Disambiguate from SchematicHQ.Client.LogLevel — this file's adapter // implements Microsoft.Extensions.Logging.ILogger and needs MS's enum. using MsLogLevel = Microsoft.Extensions.Logging.LogLevel; +using SchematicHQ.Client.Cache; namespace SchematicHQ.Client.Datastream { @@ -29,7 +30,7 @@ public class DatastreamClientAdapter /// /// Creates a new datastream client adapter /// - public DatastreamClientAdapter(string baseUrl, ILogger logger, string apiKey, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) + public DatastreamClientAdapter(string baseUrl, ILogger logger, string apiKey, ICacheProvider provider, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) { _logger = logger; _replicatorMode = replicatorMode; @@ -52,6 +53,7 @@ public DatastreamClientAdapter(string baseUrl, ILogger logger, string apiKey, Da logger, apiKey, _connectionTracker.UpdateConnectionState, // callback to update connection state + provider, options.CacheTTL, null, // default websocket client options, @@ -203,17 +205,17 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin var needsUser = request.User != null && request.User.Count > 0; // Always try to get cached resources first - var cachedFlag = _client.GetFlag(flagKey); + var cachedFlag = await _client.GetFlag(flagKey); RulesengineCompany? cachedCompany = null; RulesengineUser? cachedUser = null; if (needsCompany && request.Company != null) { - cachedCompany = _client.GetCompanyFromCache(request.Company); + cachedCompany = await _client.GetCompanyFromCache(request.Company); } if (needsUser && request.User != null) { - cachedUser = _client.GetUserFromCache(request.User); + cachedUser = await _client.GetUserFromCache(request.User); } // Check if all required resources are available in cache diff --git a/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs b/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs index 80b740c2..78160a42 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs @@ -1,112 +1,12 @@ -using System; -using SchematicHQ.Client.Cache; +namespace SchematicHQ.Client.Datastream; -namespace SchematicHQ.Client.Datastream +/// +/// Options for configuring the Datastream functionality +/// +public class DatastreamOptions { /// - /// Cache provider types for Datastream + /// Time-to-live for cached resources (companies and users) /// - public enum DatastreamCacheProviderType - { - /// - /// In-memory local cache (default) - /// - Local, - - /// - /// Redis distributed cache - /// - Redis - } - - /// - /// Options for configuring the Datastream functionality - /// - public class DatastreamOptions - { - /// - /// Time-to-live for cached resources (companies and users) - /// - public TimeSpan? CacheTTL { get; set; } = TimeSpan.FromHours(24); - - /// - /// Type of cache provider to use for company and user data - /// - public DatastreamCacheProviderType CacheProviderType { get; set; } = DatastreamCacheProviderType.Local; - - /// - /// Redis cache configuration - /// - public RedisCacheConfig? RedisConfig { get; set; } - - /// - /// Cache capacity for local cache - /// - public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; - } - - /// - /// Extension methods for DatastreamOptions - /// - public static class DatastreamOptionsExtensions - { - /// - /// Configure Datastream to use Redis cache with structured configuration (recommended) - /// - /// Datastream options - /// Redis configuration - /// Updated options - public static DatastreamOptions WithRedisCache( - this DatastreamOptions options, - RedisCacheConfig redisConfig) - { - options.CacheProviderType = DatastreamCacheProviderType.Redis; - options.RedisConfig = redisConfig ?? throw new ArgumentNullException(nameof(redisConfig)); - - // Also set the TTL if specified in Redis config - if (redisConfig.CacheTTL.HasValue) - { - options.CacheTTL = redisConfig.CacheTTL.Value; - } - - return options; - } - - /// - /// Configure Datastream to use Redis cache with a configuration builder - /// - /// Datastream options - /// Action to configure Redis settings - /// Updated options - public static DatastreamOptions WithRedisCache( - this DatastreamOptions options, - Action configureRedis) - { - var redisConfig = new RedisCacheConfig(); - configureRedis(redisConfig); - return WithRedisCache(options, redisConfig); - } - - - /// - /// Configure Datastream to use local in-memory cache for company and user data - /// - /// Datastream options - /// Cache capacity - /// Cache TTL - /// Updated options - public static DatastreamOptions WithLocalCache( - this DatastreamOptions options, - int capacity = LocalCache.DEFAULT_CACHE_CAPACITY, - TimeSpan? ttl = null) - { - options.CacheProviderType = DatastreamCacheProviderType.Local; - options.LocalCacheCapacity = capacity; - if (ttl.HasValue) - { - options.CacheTTL = ttl.Value; - } - return options; - } - } -} + public TimeSpan? CacheTTL { get; set; } = TimeSpan.FromHours(24); +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs b/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs index 473824a6..f422f499 100644 --- a/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs +++ b/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using StackExchange.Redis; namespace SchematicHQ.Client.Datastream { @@ -8,94 +9,126 @@ namespace SchematicHQ.Client.Datastream /// public class RedisCacheConfig { + /// + /// Key prefix for all Redis operations (default: "schematic:") + /// + public string? KeyPrefix { get; set; } = "schematic:"; + + /// + /// Time-to-live for cached items + /// + public TimeSpan? CacheTTL { get; set; } + + /// + /// Database number to use (default: 0) + /// + public int Database { get; set; } = 0; + + /// + /// The configuration string used to connect to Redis. + /// + public string? Configuration { get; set; } + + /// + /// The configuration used to connect to Redis. + /// This is prioritized over Configuration. + /// + public ConfigurationOptions? ConfigurationOptions { get; set; } + + /// + /// Gets or sets a delegate to create or return the ConnectionMultiplexer instance. + /// This is prioritized over Configuration and ConfigurationOptions. + /// + public Func? ConnectionMultiplexerFactory { get; set; } + /// /// The Redis server endpoints (host:port format) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public List Endpoints { get; set; } = new List(); /// /// Redis username for authentication (Redis 6.0+) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? Username { get; set; } /// /// Redis password for authentication /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? Password { get; set; } - - /// - /// Database number to use (default: 0) - /// - public int Database { get; set; } = 0; - + /// /// Use SSL/TLS for connection /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool Ssl { get; set; } = false; /// /// SSL host (defaults to endpoint if not specified) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? SslHost { get; set; } /// /// Client name for connection identification /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? ClientName { get; set; } /// /// Connection timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int ConnectTimeout { get; set; } = 5000; /// /// Synchronous operation timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int SyncTimeout { get; set; } = 5000; /// /// Asynchronous operation timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int AsyncTimeout { get; set; } = 5000; /// /// Keep-alive interval in seconds (default: 60s) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int KeepAlive { get; set; } = 60; /// /// Whether to abort connection on connect failure (default: true) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool AbortOnConnectFail { get; set; } = true; /// /// Connection retry count (default: 3) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int ConnectRetry { get; set; } = 3; - - /// - /// Key prefix for all Redis operations (default: "schematic:") - /// - public string? KeyPrefix { get; set; } = "schematic:"; - - /// - /// Time-to-live for cached items - /// - public TimeSpan? CacheTTL { get; set; } - + /// /// Allow admin operations (dangerous commands) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool AllowAdmin { get; set; } = false; /// /// Default database for commands (can be overridden per-operation) /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int? DefaultDatabase { get; set; } /// /// Service name for Sentinel support /// + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? ServiceName { get; set; } } @@ -104,6 +137,7 @@ public class RedisCacheConfig /// Note: StackExchange.Redis handles most cluster operations automatically. /// These settings provide hints for optimization but have limited direct mapping. /// + [Obsolete("Use RedisConnection on the base configuration instead.")] public class RedisCacheClusterConfig : RedisCacheConfig { /// diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 45df47e7..451102e9 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -19,7 +19,7 @@ public partial class Schematic private readonly ClientOptions _options; private readonly IEventBuffer _eventBuffer; private readonly ILogger _logger; - private readonly List> _flagCheckCacheProviders; + private readonly ICacheProvider _cache; private readonly bool _offline; private readonly DatastreamClientAdapter? _datastreamClient; private readonly bool _replicatorMode; @@ -70,25 +70,11 @@ public Schematic(string apiKey, ClientOptions? options = null) { hasRedisCache = true; } - - // Check if Redis is configured via DatastreamOptions - if (_options.DatastreamOptions?.CacheProviderType == DatastreamCacheProviderType.Redis && - _options.DatastreamOptions.RedisConfig != null) - { - hasRedisCache = true; - } - + // Check if explicit Redis cache providers are configured - if (_options.CacheProviders.Count > 0) + if (_options.CacheProvider is RedisCache) { - foreach (var provider in _options.CacheProviders) - { - if (provider is RedisCache) - { - hasRedisCache = true; - break; - } - } + hasRedisCache = true; } if (!hasRedisCache) @@ -133,23 +119,20 @@ public Schematic(string apiKey, ClientOptions? options = null) _eventBuffer.Start(); // Initialize cache providers based on configuration - if (_options.CacheProviders.Count > 0) + if (_options.CacheProvider is not null) { // Use explicitly provided cache providers - _flagCheckCacheProviders = _options.CacheProviders; + _cache = _options.CacheProvider; } else if (_options.CacheConfiguration != null) { - // Create cache providers based on configuration - _flagCheckCacheProviders = new List>(); - switch (_options.CacheConfiguration.ProviderType) { case CacheProviderType.Redis: if (_options.CacheConfiguration.RedisConfig == null) { _logger.LogWarning("Redis configuration not provided, falling back to local cache"); - _flagCheckCacheProviders.Add(new LocalCache()); + _cache = new LocalCache(); } else { @@ -159,27 +142,23 @@ public Schematic(string apiKey, ClientOptions? options = null) _options.CacheConfiguration.RedisConfig.CacheTTL = _options.CacheConfiguration.CacheTtl; } - RedisCache redisCache = new RedisCache(_options.CacheConfiguration.RedisConfig); - _flagCheckCacheProviders.Add(redisCache); + _cache = new RedisCache(_options.CacheConfiguration.RedisConfig); } break; case CacheProviderType.Local: default: - _flagCheckCacheProviders.Add(new LocalCache( + _cache = new LocalCache( _options.CacheConfiguration.LocalCacheCapacity, _options.CacheConfiguration.CacheTtl - )); + ); break; } } else { // Default to local cache - _flagCheckCacheProviders = new List> - { - new LocalCache() - }; + _cache = new LocalCache(); } // Initialize datastream if enabled or in replicator mode (for cache access) @@ -187,32 +166,18 @@ public Schematic(string apiKey, ClientOptions? options = null) { // Create DatastreamOptions with cache settings from _options.CacheConfiguration var datastreamOptions = _options.DatastreamOptions ?? new DatastreamOptions(); - - // Apply cache settings from the main configuration if not set in DatastreamOptions - if (_options.CacheConfiguration != null && datastreamOptions.RedisConfig == null) + + if (_options.CacheConfiguration != null) { - // Set cache provider type based on main configuration - datastreamOptions.CacheProviderType = _options.CacheConfiguration.ProviderType == CacheProviderType.Redis - ? DatastreamCacheProviderType.Redis - : DatastreamCacheProviderType.Local; - - // Pass through the Redis settings if using Redis unless explicitly set in DatastreamOptions - if (datastreamOptions.CacheProviderType == DatastreamCacheProviderType.Redis) - { - datastreamOptions.RedisConfig = _options.CacheConfiguration.RedisConfig; - } - - // Apply local cache settings - datastreamOptions.LocalCacheCapacity = _options.CacheConfiguration.LocalCacheCapacity; - // Apply cache TTL if not set in DatastreamOptions - datastreamOptions.CacheTTL = datastreamOptions.CacheTTL ?? _options.CacheConfiguration.CacheTtl; + datastreamOptions.CacheTTL ??= _options.CacheConfiguration.CacheTtl; } _datastreamClient = new DatastreamClientAdapter( _options.BaseUrl, _logger, apiKey, + _cache, datastreamOptions, _replicatorMode, _options.ReplicatorHealthUrl @@ -369,15 +334,12 @@ private async Task CheckFlagWithEntitlementApi string cacheKey = BuildFlagCacheKey(flagKey, company, user); // Check cache first - foreach (var provider in _flagCheckCacheProviders) + var cachedResponse = await _cache.Get(cacheKey); + if (cachedResponse != null) { - var cachedResponse = provider.Get(cacheKey); - if (cachedResponse != null) - { - // Submit flag check event for cached value - SubmitFlagCheckEventForValue(flagKey, cachedResponse.Value, company, user, "cache"); - return cachedResponse; - } + // Submit flag check event for cached value + SubmitFlagCheckEventForValue(flagKey, cachedResponse.Value, company, user, "cache"); + return cachedResponse; } // Make API request @@ -397,17 +359,21 @@ private async Task CheckFlagWithEntitlementApi var result = CheckFlagWithEntitlementResponse.FromApiResponse(apiResponse.Data, flagKey); // Cache the result - foreach (var provider in _flagCheckCacheProviders) + try { try { - provider.Set(cacheKey, result); + await _cache.Set(cacheKey, result); } catch (Exception cacheEx) { _logger.LogError(cacheEx, "Error caching flag result"); } } + catch (Exception cacheEx) + { + _logger.LogError("Error caching flag result: {0}", cacheEx.Message); + } return result; } @@ -474,12 +440,7 @@ public async Task> CheckFlags( foreach (var key in keyList) { var cacheKey = BuildFlagCacheKey(key, company, user); - CheckFlagWithEntitlementResponse? cached = null; - foreach (var provider in _flagCheckCacheProviders) - { - cached = provider.Get(cacheKey); - if (cached != null) break; - } + var cached = await _cache.Get(cacheKey); if (cached != null) { cachedResults[key] = new CheckFlagResponseData @@ -509,17 +470,23 @@ public async Task> CheckFlags( { var cacheKey = BuildFlagCacheKey(kvp.Key, company, user); var responseForCache = CheckFlagWithEntitlementResponse.FromApiResponse(kvp.Value, kvp.Key); - foreach (var provider in _flagCheckCacheProviders) + + try { try { - provider.Set(cacheKey, responseForCache); + await _cache.Set(cacheKey, responseForCache); } catch (Exception cacheEx) { _logger.LogError(cacheEx, "Error caching flag result"); } } + catch (Exception cacheEx) + { + _logger.LogError("Error caching flag result: {0}", cacheEx.Message); + } + } return keyList.Select(key =>