From 7ad3424be2ebee214605711cd312c29343423de5 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 26 Apr 2026 10:40:02 +0800 Subject: [PATCH 01/10] Modernize cache implementation --- .../Cache/LocalCacheTests.cs | 917 +++++++++--------- .../Cache/RedisCacheTests.cs | 10 +- .../Datastream/CompanyMetricsTests.cs | 68 +- .../Datastream/DatastreamCacheTests.cs | 68 +- .../DatastreamClientAdapterTests.cs | 6 +- .../Datastream/DatastreamClientTests.cs | 9 +- .../DatastreamConnectionMonitoringTests.cs | 11 +- .../Mocks/DatastreamClientTestFactory.cs | 4 +- src/SchematicHQ.Client.Test/TestCache.cs | 85 +- src/SchematicHQ.Client.Test/TestClient.cs | 38 +- .../Cache/CacheConfiguration.cs | 6 +- src/SchematicHQ.Client/Cache/CacheFactory.cs | 10 +- .../Cache/ICacheProvider.cs | 10 +- src/SchematicHQ.Client/Cache/LocalCache.cs | 54 +- src/SchematicHQ.Client/Cache/RedisCache.cs | 111 ++- .../Core/Public/ClientOptionsCustom.cs | 7 +- src/SchematicHQ.Client/Datastream/Client.cs | 115 +-- .../Datastream/DatastreamCacheDecorator.cs | 36 + .../Datastream/DatastreamClientAdapter.cs | 10 +- .../Datastream/DatastreamOptions.cs | 4 +- .../Datastream/RedisCacheConfig.cs | 56 +- src/SchematicHQ.Client/Schematic.cs | 62 +- .../SchematicHQ.Client.csproj | 4 +- 23 files changed, 838 insertions(+), 863 deletions(-) create mode 100644 src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs diff --git a/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs index 465a5f1f..5a4932c8 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,158 @@ 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 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 +1235,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..a144a00f 100644 --- a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs @@ -33,7 +33,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 +67,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 +82,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 +101,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 +127,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..daea1e12 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() @@ -40,10 +40,10 @@ public void Setup() _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"); + _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"); + _companyLookupCache = (ICacheProvider?)lookupCacheField?.GetValue(_client) ?? throw new Exception("Could not get company lookup cache"); } [TearDown] @@ -80,7 +80,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 +91,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 +99,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 +107,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 +128,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 +146,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 +192,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 +237,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 +253,7 @@ public void UpdateCompanyMetrics_WithMatchingMetric_UpdatesValueWithDefaultQuant } [Test] - public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() + public async Task UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() { // Arrange var companyJson = @"{ @@ -288,14 +288,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 +304,7 @@ public void UpdateCompanyMetrics_WithSpecificQuantity_UpdatesMetricValue() } [Test] - public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() + public async Task UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() { // Arrange var companyJson = @"{ @@ -340,16 +340,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 +357,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 +366,7 @@ public void UpdateCompanyMetrics_WithMultipleKeys_UpdatesAllCacheEntries() } [Test] - public void UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() + public async Task UpdateCompanyMetrics_WithMultipleMetrics_UpdatesAllMatchingMetrics() { // Arrange var companyJson = @"{ @@ -421,14 +421,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 b4dec908..57b6d2d7 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs @@ -37,7 +37,7 @@ private void PopulateTwoLayerUserCache(RulesengineUser user) } [Test] - public void ExpiredCache_RequestsResourcesAgain() + public async Task ExpiredCache_RequestsResourcesAgain() { var companyKey = "company-123"; @@ -52,19 +52,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 - 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 { @@ -77,7 +77,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")); @@ -85,7 +85,7 @@ public void TwoStepCompanyLookup_CacheAndRetrieve() } [Test] - public void TwoStepUserLookup_CacheAndRetrieve() + public async Task TwoStepUserLookup_CacheAndRetrieve() { var user = new RulesengineUser { @@ -98,7 +98,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")); @@ -106,7 +106,7 @@ public void TwoStepUserLookup_CacheAndRetrieve() } [Test] - public void MultipleResourceKeys_ResolveToSameObject() + public async Task MultipleResourceKeys_ResolveToSameObject() { var company = new RulesengineCompany { @@ -123,9 +123,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); @@ -156,7 +156,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")); @@ -183,7 +183,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"); } @@ -203,7 +203,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")); @@ -230,7 +230,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"); } @@ -243,7 +243,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 { @@ -260,8 +260,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 @@ -278,17 +278,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 { @@ -305,8 +305,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 @@ -323,17 +323,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 { @@ -365,17 +365,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 { @@ -407,17 +407,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 { @@ -449,8 +449,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 b3e19b96..b9093f26 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs @@ -3,6 +3,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; @@ -24,17 +25,18 @@ public void Setup() _mockLogger = new MockSchematicLogger(); _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", diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs index 21bc17d1..2ff860c3 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using NUnit.Framework; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -42,7 +43,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", @@ -68,7 +69,7 @@ public void CheckFlag_WhenFlagIsNotInCache_ReturnsFalse() } [Test] - public void CheckFlag_WhenFlagExists_EvaluatesCorrectly() + public async Task CheckFlag_WhenFlagExists_EvaluatesCorrectly() { // Arrange SetupFlagsResponse(); @@ -106,7 +107,7 @@ 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 @@ -157,7 +158,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 6c11ece9..190f503b 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs @@ -3,6 +3,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; @@ -24,7 +25,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(); @@ -57,7 +58,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 () => { @@ -83,7 +84,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)); @@ -105,7 +106,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", @@ -138,7 +139,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/Mocks/DatastreamClientTestFactory.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs index 63f72687..80973c49 100644 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using System.Reflection; +using SchematicHQ.Client.Cache; using SchematicHQ.Client.Datastream; namespace SchematicHQ.Client.Test.Datastream.Mocks @@ -15,9 +16,10 @@ public static (DatastreamClient Client, MockWebSocket WebSocket, MockSchematicLo var logger = new MockSchematicLogger(); 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 b3ebd6ca..caf9343f 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -105,7 +105,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) @@ -142,12 +142,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] @@ -157,10 +154,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( @@ -179,10 +173,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); @@ -429,14 +420,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] @@ -454,7 +442,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)); @@ -504,7 +492,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)); 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..fe5f7c1a 100644 --- a/src/SchematicHQ.Client/Cache/ICacheProvider.cs +++ b/src/SchematicHQ.Client/Cache/ICacheProvider.cs @@ -1,19 +1,17 @@ -#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 /// The cached value or default if not found or expired - T? Get(string key); + ValueTask Get(string key); /// /// Set a value in the cache @@ -21,14 +19,14 @@ public interface ICacheProvider /// Cache key /// Value to cache /// Optional time-to-live override - void Set(string key, T val, TimeSpan? ttlOverride = null); + ValueTask Set(string key, T val, TimeSpan? ttlOverride = null); /// /// Delete a key from the cache /// /// Cache key to delete /// True if the key was deleted, false otherwise - bool Delete(string key); + ValueTask Delete(string key); /// /// Delete all keys not present in the provided enumeration diff --git a/src/SchematicHQ.Client/Cache/LocalCache.cs b/src/SchematicHQ.Client/Cache/LocalCache.cs index 4db6c30f..e299977b 100644 --- a/src/SchematicHQ.Client/Cache/LocalCache.cs +++ b/src/SchematicHQ.Client/Cache/LocalCache.cs @@ -1,21 +1,16 @@ using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -#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 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 +27,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,19 +53,19 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } /// - public T? Get(string key) + public ValueTask Get(string key) { if (_maxItems == 0 || _disposed) - return default; + return ValueTask.FromResult(default(T)); if (!_cache.TryGetValue(key, out var item)) - return default; + return ValueTask.FromResult(default(T)); if (item.Expiration != DateTime.MaxValue && DateTime.UtcNow > item.Expiration) { // This item has expired, remove it Remove(key); - return default; + return ValueTask.FromResult(default(T)); } // Update LRU position - We need to check if the node is still part of the list @@ -99,14 +94,17 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } } - return item.Value; + if (item.Value is T typedValue) + return ValueTask.FromResult(typedValue); + + return ValueTask.FromResult(default(T)); } /// - public void Set(string key, T val, TimeSpan? ttlOverride = null) + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) { if (_maxItems == 0 || _disposed) - return; + return ValueTask.CompletedTask; var ttl = ttlOverride ?? _ttl; // Determine expiration time - use MaxValue for unlimited TTL @@ -119,7 +117,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 +156,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,23 +164,25 @@ public void Set(string key, T val, TimeSpan? ttlOverride = null) } } } + + return ValueTask.CompletedTask; } /// - public bool Delete(string key) + public ValueTask Delete(string key) { if (_maxItems == 0 || _disposed) - return false; + return ValueTask.FromResult(false); Remove(key); - return true; + return ValueTask.FromResult(true); } /// public void DeleteMissing(IEnumerable keys, string? scanPattern = null) { if (_maxItems == 0 || _disposed) - return; + return ValueTask.CompletedTask; var keysSet = new HashSet(keys); var keysToRemove = new List(); @@ -204,6 +204,8 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) { Remove(keyToRemove); } + + return ValueTask.CompletedTask; } private void Remove(string key) @@ -307,13 +309,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 +323,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..43bd82f9 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -1,17 +1,13 @@ 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); @@ -23,24 +19,52 @@ public class RedisCache : ICacheProvider 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) + /// + /// Creates a new RedisCache instance using structured configuration (recommended) + /// + /// Redis configuration + public RedisCache(RedisCacheConfig config) { - throw new ArgumentNullException(nameof(config)); - } + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } - if (config.Endpoints == null || !config.Endpoints.Any()) - { - throw new ArgumentException("Redis endpoints cannot be null or empty", nameof(config)); + try + { + _redis = GetRedis(config); + _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 + } + }; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to connect to Redis: {ex.Message}", ex); + } } - try + public ConnectionMultiplexer GetRedis(RedisCacheConfig config) { + if (config.RedisConnection != null) + return config.RedisConnection; + + // 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,37 +108,21 @@ 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 - } - }; - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to connect to Redis: {ex.Message}", ex); +#pragma warning restore CS0618 // Type or member is obsolete + + return ConnectionMultiplexer.Connect(options); } - } - /// - public T? Get(string key) + public async ValueTask Get(string key) { var redisKey = GetRedisKey(key); - var value = _db.StringGet(redisKey); + var value = await _db.StringGetAsync(redisKey); if (value.IsNullOrEmpty) { @@ -128,13 +136,13 @@ public RedisCache(RedisCacheConfig config) catch (Exception e) { // If we can't deserialize, just remove the value and return null - Delete(key); + await Delete(key); return default; } } /// - public void Set(string key, T val, TimeSpan? ttlOverride = null) + public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) { var redisKey = GetRedisKey(key); var ttl = ttlOverride ?? _ttl; @@ -143,14 +151,14 @@ public void Set(string key, T val, TimeSpan? ttlOverride = null) var expiry = ttl == UNLIMITED_TTL ? (TimeSpan?)null : ttl; var json = JsonSerializer.Serialize(val, _jsonOptions); - _db.StringSet(redisKey, json, expiry); + await _db.StringSetAsync(redisKey, json, expiry); } /// - public bool Delete(string key) + public async ValueTask Delete(string key) { var redisKey = GetRedisKey(key); - return _db.KeyDelete(redisKey); + return await _db.KeyDeleteAsync(redisKey); } /// @@ -170,11 +178,12 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) { var server = _redis.GetServer(endpoint); - // Get keys to delete - var keysToDelete = server.Keys(pattern: pattern) + var fetchedKeys = await server.CommandGetKeysAsync([pattern]); + + var keysToDelete = fetchedKeys .Where(key => !keysToKeep.Contains(key)) .ToArray(); - + // Delete keys in batches if (keysToDelete.Length > 0) { @@ -182,15 +191,15 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) for (int i = 0; i < keysToDelete.Length; 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/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index ac0261a9..cbd1f04d 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -11,7 +11,8 @@ public partial class ClientOptions { public Dictionary FlagDefaults { get; set; } = new Dictionary(); public ISchematicLogger Logger { get; set; } = new ConsoleLogger(); - public List> CacheProviders { get; set; } = new List>(); + + public ICacheProvider? CacheProvider { get; set; } public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -43,7 +44,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, @@ -106,7 +107,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 c43eb5cb..50c31334 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -28,11 +28,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; @@ -76,6 +73,7 @@ public DatastreamClient( ISchematicLogger logger, string apiKey, Action connectionStateCallback, + ICacheProvider cacheProvider, TimeSpan? cacheTtl = null, IWebSocketClient? webSocket = null, DatastreamOptions? options = null, @@ -105,43 +103,10 @@ public DatastreamClient( { flagTTL = _cacheTtl; } - - // Company and User caches use the configured provider type - if (options.CacheProviderType == DatastreamCacheProviderType.Redis && - options.RedisConfig != null) - { - try - { - _logger.Info("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.Error("Failed to initialize Redis cache: {0}. Falling back to local cache.", ex.Message); - _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 = cacheProvider; + _flagsCache = new DatastreamCacheDecorator(cacheProvider, flagTTL); + _webSocket = webSocket ?? new StandardWebSocketClient(); } @@ -457,7 +422,7 @@ private void HandleMessageResponse(DataStreamResponse? message) } } - private void HandleFlagsMessage(DataStreamResponse response) + private async Task HandleFlagsMessage(DataStreamResponse response) { try { @@ -492,7 +457,7 @@ private void HandleFlagsMessage(DataStreamResponse response) continue; } var cacheKey = FlagCacheKey(flag.Key); - _flagsCache.Set(cacheKey, flag); + await _flagsCache.Set(cacheKey, flag); cacheKeys.Add(cacheKey); } @@ -623,7 +588,7 @@ private void NotifyPendingRequests(T? entity, IDictionary key } } - private async void HandleCompanyMessage(DataStreamResponse response) + private async Task HandleCompanyMessage(DataStreamResponse response) { try { @@ -656,7 +621,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.Warn("Cache miss for partial company '{0}', skipping", id); @@ -702,18 +667,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); @@ -735,7 +700,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) } } - private void HandleUserMessage(DataStreamResponse response) + private async Task HandleUserMessage(DataStreamResponse response) { try { @@ -767,7 +732,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.Warn("Cache miss for partial user '{0}', skipping", id); @@ -799,11 +764,11 @@ 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); + await _cacheProvider.Delete(resourceKey); _logger.Debug("Deleted user from cache with key: {0}", resourceKey); } return; @@ -1064,22 +1029,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; @@ -1089,16 +1054,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; @@ -1145,7 +1110,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; @@ -1180,7 +1145,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) { @@ -1344,12 +1309,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) @@ -1358,28 +1323,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) @@ -1388,19 +1353,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); } } @@ -1530,9 +1495,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..0500d466 --- /dev/null +++ b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs @@ -0,0 +1,36 @@ +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) + { + return _inner.Get(key); + } + + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) + { + return _inner.Set(key, val, ttlOverride ?? _cacheTtl); + } + + public ValueTask Delete(string key) + { + return _inner.Delete(key); + } + + public ValueTask DeleteMissing(IEnumerable keys) + { + return _inner.DeleteMissing(keys); + } +} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs index 477986dc..f7cd1086 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs @@ -7,6 +7,7 @@ using SchematicHQ.Client; using SchematicHQ.Client.Core; using System.Net.WebSockets; +using SchematicHQ.Client.Cache; namespace SchematicHQ.Client.Datastream { @@ -26,7 +27,7 @@ public class DatastreamClientAdapter /// /// Creates a new datastream client adapter /// - public DatastreamClientAdapter(string baseUrl, ISchematicLogger logger, string apiKey, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) + public DatastreamClientAdapter(string baseUrl, ISchematicLogger logger, string apiKey, ICacheProvider provider, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) { _logger = logger; _replicatorMode = replicatorMode; @@ -49,6 +50,7 @@ public DatastreamClientAdapter(string baseUrl, ISchematicLogger logger, string a _logger, apiKey, _connectionTracker.UpdateConnectionState, // callback to update connection state + provider, options.CacheTTL, null, // default websocket client options, @@ -200,17 +202,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..39368378 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs @@ -42,7 +42,7 @@ public class DatastreamOptions /// /// Cache capacity for local cache /// - public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; + public int LocalCacheCapacity { get; set; } = LocalCache.DEFAULT_CACHE_CAPACITY; } /// @@ -97,7 +97,7 @@ public static DatastreamOptions WithRedisCache( /// Updated options public static DatastreamOptions WithLocalCache( this DatastreamOptions options, - int capacity = LocalCache.DEFAULT_CACHE_CAPACITY, + int capacity = LocalCache.DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null) { options.CacheProviderType = DatastreamCacheProviderType.Local; diff --git a/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs b/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs index 473824a6..2add3d80 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,114 @@ 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 redis connection multiplexer to use for this cache. + /// + public ConnectionMultiplexer? RedisConnection { get; set; } + /// /// The Redis server endpoints (host:port format) /// + [Obsolete("Use RedisConnection instead")] public List Endpoints { get; set; } = new List(); /// /// Redis username for authentication (Redis 6.0+) /// + [Obsolete("Use RedisConnection instead")] public string? Username { get; set; } /// /// Redis password for authentication /// + [Obsolete("Use RedisConnection 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 RedisConnection instead")] public bool Ssl { get; set; } = false; /// /// SSL host (defaults to endpoint if not specified) /// + [Obsolete("Use RedisConnection instead")] public string? SslHost { get; set; } /// /// Client name for connection identification /// + [Obsolete("Use RedisConnection instead")] public string? ClientName { get; set; } /// /// Connection timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use RedisConnection instead")] public int ConnectTimeout { get; set; } = 5000; /// /// Synchronous operation timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use RedisConnection instead")] public int SyncTimeout { get; set; } = 5000; /// /// Asynchronous operation timeout in milliseconds (default: 5000ms) /// + [Obsolete("Use RedisConnection instead")] public int AsyncTimeout { get; set; } = 5000; /// /// Keep-alive interval in seconds (default: 60s) /// + [Obsolete("Use RedisConnection instead")] public int KeepAlive { get; set; } = 60; /// /// Whether to abort connection on connect failure (default: true) /// + [Obsolete("Use RedisConnection instead")] public bool AbortOnConnectFail { get; set; } = true; /// /// Connection retry count (default: 3) /// + [Obsolete("Use RedisConnection 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 RedisConnection instead")] public bool AllowAdmin { get; set; } = false; /// /// Default database for commands (can be overridden per-operation) /// + [Obsolete("Use RedisConnection instead")] public int? DefaultDatabase { get; set; } /// /// Service name for Sentinel support /// + [Obsolete("Use RedisConnection instead")] public string? ServiceName { get; set; } } @@ -104,6 +125,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 525da6a5..3cf01c11 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -18,7 +18,7 @@ public partial class Schematic private readonly ClientOptions _options; private readonly IEventBuffer _eventBuffer; private readonly ISchematicLogger _logger; - private readonly List> _flagCheckCacheProviders; + private readonly ICacheProvider _cache; private readonly bool _offline; private readonly DatastreamClientAdapter? _datastreamClient; private readonly bool _replicatorMode; @@ -78,15 +78,11 @@ public Schematic(string apiKey, ClientOptions? options = null) } // Check if explicit Redis cache providers are configured - if (_options.CacheProviders.Count > 0) + if (_options.CacheProvider is not null) { - foreach (var provider in _options.CacheProviders) + if (_options.CacheProvider is RedisCache) { - if (provider is RedisCache) - { - hasRedisCache = true; - break; - } + hasRedisCache = true; } } @@ -132,23 +128,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.Warn("Redis configuration not provided, falling back to local cache"); - _flagCheckCacheProviders.Add(new LocalCache()); + _cache = new LocalCache(); } else { @@ -158,27 +151,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) @@ -212,6 +201,7 @@ public Schematic(string apiKey, ClientOptions? options = null) _options.BaseUrl, _logger, apiKey, + _cache, datastreamOptions, _replicatorMode, _options.ReplicatorHealthUrl @@ -368,15 +358,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 @@ -396,16 +383,13 @@ 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); - } - catch (Exception cacheEx) - { - _logger.Error("Error caching flag result: {0}", cacheEx.Message); - } + await _cache.Set(cacheKey, result); + } + catch (Exception cacheEx) + { + _logger.Error("Error caching flag result: {0}", cacheEx.Message); } return result; diff --git a/src/SchematicHQ.Client/SchematicHQ.Client.csproj b/src/SchematicHQ.Client/SchematicHQ.Client.csproj index 1158408c..1f9a1bf5 100644 --- a/src/SchematicHQ.Client/SchematicHQ.Client.csproj +++ b/src/SchematicHQ.Client/SchematicHQ.Client.csproj @@ -41,8 +41,8 @@ - - + + From 68d1881e734678b97899090f109593eebd01c0dc Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 26 Apr 2026 10:52:15 +0800 Subject: [PATCH 02/10] Additional test fixes --- src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs | 5 ----- .../Datastream/CompanyMetricsTests.cs | 6 ++---- .../Datastream/DatastreamCacheTests.cs | 2 +- .../Datastream/DatastreamClientAdapterTests.cs | 11 +++-------- .../Datastream/DatastreamClientTests.cs | 11 ++++------- src/SchematicHQ.Client/Datastream/Client.cs | 2 +- 6 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs index a144a00f..c4aa649e 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; diff --git a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs index daea1e12..2acc36c5 100644 --- a/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/CompanyMetricsTests.cs @@ -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); + 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"); - - 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"); + _companyLookupCache = (ICacheProvider?)cacheField?.GetValue(_client) ?? throw new Exception("Could not get company lookup cache"); } [TearDown] diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs index 57b6d2d7..c3268ad1 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs @@ -57,7 +57,7 @@ public async Task ExpiredCache_RequestsResourcesAgain() 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 = await _client.GetCompanyFromCache(keys); Assert.That(expiredCompany, Is.Null, "Company should not be in cache after TTL expiration"); diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs index b9093f26..3058a638 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs @@ -208,7 +208,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 @@ -226,14 +226,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 2ff860c3..5c9b12c2 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs @@ -113,7 +113,7 @@ public async Task CheckFlag_WhenFlagExists_EvaluatesCorrectly() // 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 @@ -132,13 +132,10 @@ public async Task 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 diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 50c31334..3412b036 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -104,7 +104,7 @@ public DatastreamClient( flagTTL = _cacheTtl; } - _cacheProvider = cacheProvider; + _cacheProvider = new DatastreamCacheDecorator(cacheProvider, _cacheTtl); _flagsCache = new DatastreamCacheDecorator(cacheProvider, flagTTL); _webSocket = webSocket ?? new StandardWebSocketClient(); From 8af54fc1ab788ecb1d7cdf492843ea82f34830dc Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 26 Apr 2026 10:54:38 +0800 Subject: [PATCH 03/10] Ensure serializer defaults are exposed --- src/SchematicHQ.Client/Cache/RedisCache.cs | 11 +---------- .../Cache/SchematicCacheSerializerDefaults.cs | 17 +++++++++++++++++ src/SchematicHQ.Client/Datastream/Client.cs | 16 ++++++++-------- src/SchematicHQ.Client/Schematic.cs | 7 ++----- 4 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 src/SchematicHQ.Client/Cache/SchematicCacheSerializerDefaults.cs diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index 43bd82f9..8e169018 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -36,16 +36,7 @@ public RedisCache(RedisCacheConfig config) _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 - } - }; + _jsonOptions = SchematicCacheSerializerDefaults.Options; } catch (Exception ex) { 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/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 3412b036..308ab3ad 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -405,16 +405,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.Error("Received unknown entity type: {0}", message.EntityType); @@ -477,7 +477,7 @@ private async Task HandleFlagsMessage(DataStreamResponse response) } } - private void HandleFlagMessage(DataStreamResponse response) + private async Task HandleFlagMessage(DataStreamResponse response) { try { @@ -522,7 +522,7 @@ private void HandleFlagMessage(DataStreamResponse response) if (!string.IsNullOrEmpty(flagKey)) { var deleteCacheKey = FlagCacheKey(flagKey); - _flagsCache.Delete(deleteCacheKey); + await _flagsCache.Delete(deleteCacheKey); _logger.Debug("Deleted single flag from cache: {0}", flagKey); } else @@ -549,7 +549,7 @@ private void HandleFlagMessage(DataStreamResponse response) } var cacheKey = FlagCacheKey(flag.Key); - _flagsCache.Set(cacheKey, flag); + await _flagsCache.Set(cacheKey, flag); _logger.Debug("Cached single flag: {0}", flag.Key); // Note: Unlike bulk flags processing, we do NOT call DeleteMissing for single flag updates @@ -775,7 +775,7 @@ private async Task HandleUserMessage(DataStreamResponse response) } // Update cache using two-layer approach - CacheUserForKeys(user); + await CacheUserForKeys(user); // Notify pending requests NotifyPendingRequests(user, user.Keys, CacheKeyPrefixUser, _pendingUserRequests); diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 3cf01c11..718d755b 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -78,12 +78,9 @@ public Schematic(string apiKey, ClientOptions? options = null) } // Check if explicit Redis cache providers are configured - if (_options.CacheProvider is not null) + if (_options.CacheProvider is RedisCache) { - if (_options.CacheProvider is RedisCache) - { - hasRedisCache = true; - } + hasRedisCache = true; } if (!hasRedisCache) From 2678cc4b280340edfb0b55031b3a4ce785026ebd Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 28 Apr 2026 14:04:18 +0800 Subject: [PATCH 04/10] Rebase --- src/SchematicHQ.Client.Test/TestClient.cs | 7 +-- .../Cache/ICacheProvider.cs | 2 +- src/SchematicHQ.Client/Cache/LocalCache.cs | 52 ++++++++++++++++++- src/SchematicHQ.Client/Cache/RedisCache.cs | 2 +- src/SchematicHQ.Client/Datastream/Client.cs | 4 +- .../Datastream/DatastreamCacheDecorator.cs | 4 +- src/SchematicHQ.Client/Schematic.cs | 24 ++++----- 7 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/SchematicHQ.Client.Test/TestClient.cs b/src/SchematicHQ.Client.Test/TestClient.cs index caf9343f..f679409c 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -636,11 +636,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/ICacheProvider.cs b/src/SchematicHQ.Client/Cache/ICacheProvider.cs index fe5f7c1a..4cd60d1e 100644 --- a/src/SchematicHQ.Client/Cache/ICacheProvider.cs +++ b/src/SchematicHQ.Client/Cache/ICacheProvider.cs @@ -38,6 +38,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 e299977b..224c7d7c 100644 --- a/src/SchematicHQ.Client/Cache/LocalCache.cs +++ b/src/SchematicHQ.Client/Cache/LocalCache.cs @@ -179,7 +179,7 @@ public ValueTask Delete(string key) } /// - public void DeleteMissing(IEnumerable keys, string? scanPattern = null) + public ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null) { if (_maxItems == 0 || _disposed) return ValueTask.CompletedTask; @@ -187,11 +187,17 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) 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); @@ -208,6 +214,48 @@ public void DeleteMissing(IEnumerable keys, string? scanPattern = null) 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) { if (_cache.TryRemove(key, out var removedItem)) diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index 8e169018..1a632585 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -153,7 +153,7 @@ public async ValueTask Delete(string key) } /// - 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))); diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 308ab3ad..5fdde608 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -346,7 +346,7 @@ private async Task ReadMessagesAsync() try { var response = JsonSerializer.Deserialize(message); - HandleMessageResponse(response); + await HandleMessageResponse(response); } catch (Exception ex) { @@ -389,7 +389,7 @@ private async Task ReadMessagesAsync() } } - private void HandleMessageResponse(DataStreamResponse? message) + private async Task HandleMessageResponse(DataStreamResponse? message) { if (message == null) { diff --git a/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs index 0500d466..d6036501 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs @@ -29,8 +29,8 @@ public ValueTask Delete(string key) return _inner.Delete(key); } - public ValueTask DeleteMissing(IEnumerable keys) + public ValueTask DeleteMissing(IEnumerable keys, string? scanPattern = null) { - return _inner.DeleteMissing(keys); + return _inner.DeleteMissing(keys, scanPattern); } } \ No newline at end of file diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 718d755b..9fddb188 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -454,12 +454,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 @@ -489,17 +484,16 @@ 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); - } - catch (Exception cacheEx) - { - _logger.Error("Error caching flag result: {0}", cacheEx.Message); - } + await _cache.Set(cacheKey, responseForCache); + } + catch (Exception cacheEx) + { + _logger.Error("Error caching flag result: {0}", cacheEx.Message); } + } return keyList.Select(key => From 4597a4c515af7ebe3efdbc075d296f42bd9c1ccb Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 28 Apr 2026 14:08:03 +0800 Subject: [PATCH 05/10] Test fix --- src/SchematicHQ.Client/Cache/RedisCache.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index 1a632585..b8f7c664 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -29,10 +29,10 @@ public RedisCache(RedisCacheConfig config) { throw new ArgumentNullException(nameof(config)); } - + _redis = GetRedis(config); + try { - _redis = GetRedis(config); _db = _redis.GetDatabase(config.Database); _keyPrefix = config.KeyPrefix ?? DEFAULT_KEY_PREFIX; _ttl = config.CacheTTL ?? DEFAULT_CACHE_TTL; From e0918295f7249036e5ab640202b39186ae980e09 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 29 Apr 2026 10:12:36 +0800 Subject: [PATCH 06/10] Future-proof the cache abstraction a bit --- .../Cache/ICacheProvider.cs | 18 ++++++++++++++--- src/SchematicHQ.Client/Cache/LocalCache.cs | 20 ++++++++++++++++--- src/SchematicHQ.Client/Cache/RedisCache.cs | 20 ++++++++++++++++--- src/SchematicHQ.Client/Datastream/Client.cs | 2 +- .../Datastream/DatastreamCacheDecorator.cs | 17 ++++++++++------ 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/SchematicHQ.Client/Cache/ICacheProvider.cs b/src/SchematicHQ.Client/Cache/ICacheProvider.cs index 4cd60d1e..13db2554 100644 --- a/src/SchematicHQ.Client/Cache/ICacheProvider.cs +++ b/src/SchematicHQ.Client/Cache/ICacheProvider.cs @@ -10,8 +10,9 @@ 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 - ValueTask Get(string key); + ValueTask Get(string key, CancellationToken token = default); /// /// Set a value in the cache @@ -19,14 +20,25 @@ public interface ICacheProvider /// Cache key /// Value to cache /// Optional time-to-live override - ValueTask Set(string key, T val, TimeSpan? ttlOverride = null); + /// An optional cancellation token + ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default); + + /// + /// 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); /// /// Delete a key from the cache /// /// Cache key to delete /// True if the key was deleted, false otherwise - ValueTask Delete(string key); + /// An optional cancellation token + ValueTask Delete(string key, CancellationToken token = default); /// /// Delete all keys not present in the provided enumeration diff --git a/src/SchematicHQ.Client/Cache/LocalCache.cs b/src/SchematicHQ.Client/Cache/LocalCache.cs index 224c7d7c..cfa55e65 100644 --- a/src/SchematicHQ.Client/Cache/LocalCache.cs +++ b/src/SchematicHQ.Client/Cache/LocalCache.cs @@ -53,7 +53,7 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } /// - public ValueTask Get(string key) + public ValueTask Get(string key, CancellationToken token = default) { if (_maxItems == 0 || _disposed) return ValueTask.FromResult(default(T)); @@ -101,7 +101,7 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } /// - public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) { if (_maxItems == 0 || _disposed) return ValueTask.CompletedTask; @@ -169,7 +169,21 @@ public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) } /// - public ValueTask Delete(string key) + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) + { + // Try to get from cache first + var existing = await Get(key, token); + if (existing is not null) + 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); diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index b8f7c664..4484c385 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -110,7 +110,7 @@ public ConnectionMultiplexer GetRedis(RedisCacheConfig config) } /// - public async ValueTask Get(string key) + public async ValueTask Get(string key, CancellationToken token = default) { var redisKey = GetRedisKey(key); var value = await _db.StringGetAsync(redisKey); @@ -133,7 +133,7 @@ public ConnectionMultiplexer GetRedis(RedisCacheConfig config) } /// - public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) + public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) { var redisKey = GetRedisKey(key); var ttl = ttlOverride ?? _ttl; @@ -145,8 +145,22 @@ public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) await _db.StringSetAsync(redisKey, json, expiry); } + //TODO update this with stampede protection at some point. + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) + { + // Try to get from cache first + var existing = await Get(key, token); + if (existing is not null) + 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 async ValueTask Delete(string key) + public async ValueTask Delete(string key, CancellationToken token = default) { var redisKey = GetRedisKey(key); return await _db.KeyDeleteAsync(redisKey); diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 5fdde608..6612c666 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -461,7 +461,7 @@ private async Task HandleFlagsMessage(DataStreamResponse response) cacheKeys.Add(cacheKey); } - _flagsCache.DeleteMissing(cacheKeys, $"{CacheKeyPrefix}:{CacheKeyPrefixFlags}:*"); + await _flagsCache.DeleteMissing(cacheKeys, $"{CacheKeyPrefix}:{CacheKeyPrefixFlags}:*"); lock (_pendingRequestsLock) { diff --git a/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs index d6036501..6e847d48 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamCacheDecorator.cs @@ -14,19 +14,24 @@ public DatastreamCacheDecorator(ICacheProvider inner, TimeSpan cacheTtl) _cacheTtl = cacheTtl; } - public ValueTask Get(string key) + public ValueTask Get(string key, CancellationToken token = default) { - return _inner.Get(key); + return _inner.Get(key, token); } - public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null) + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) { - return _inner.Set(key, val, ttlOverride ?? _cacheTtl); + return _inner.Set(key, val, ttlOverride ?? _cacheTtl, token); } - public ValueTask Delete(string key) + public ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) { - return _inner.Delete(key); + 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) From 3e001728127bd032f34697adb8971230a5a83036 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 29 Apr 2026 23:19:01 +0800 Subject: [PATCH 07/10] Use KeysAsync --- src/SchematicHQ.Client/Cache/RedisCache.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index 4484c385..e9a62ef3 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -182,18 +182,20 @@ public async ValueTask DeleteMissing(IEnumerable keys, string? scanPatte foreach (var endpoint in _redis.GetEndPoints()) { var server = _redis.GetServer(endpoint); - - var fetchedKeys = await server.CommandGetKeysAsync([pattern]); + + var keysToDelete = new List(); - var keysToDelete = fetchedKeys - .Where(key => !keysToKeep.Contains(key)) - .ToArray(); + 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(); await _db.KeyDeleteAsync(batch); From c80ae6ced906e90ccb97ade1196b15f255ea7f8f Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 29 Apr 2026 23:25:40 +0800 Subject: [PATCH 08/10] Review comments --- .../DatastreamRedisIntegrationTests.cs | 73 ----------- src/SchematicHQ.Client/Cache/RedisCache.cs | 2 +- .../Datastream/DatastreamOptions.cs | 116 ++---------------- src/SchematicHQ.Client/Schematic.cs | 30 +---- 4 files changed, 13 insertions(+), 208 deletions(-) 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/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index e9a62ef3..0bd373e2 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -44,7 +44,7 @@ public RedisCache(RedisCacheConfig config) } } - public ConnectionMultiplexer GetRedis(RedisCacheConfig config) + private ConnectionMultiplexer GetRedis(RedisCacheConfig config) { if (config.RedisConnection != null) return config.RedisConnection; diff --git a/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs b/src/SchematicHQ.Client/Datastream/DatastreamOptions.cs index 39368378..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/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 9fddb188..82c8d8e4 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -69,14 +69,7 @@ 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.CacheProvider is RedisCache) { @@ -172,26 +165,11 @@ 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( From 28930285677995d3e90b766313efb0958c459a46 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 30 Apr 2026 11:41:53 +0800 Subject: [PATCH 09/10] Ensure value types are supported correctly. --- .../Cache/LocalCacheTests.cs | 30 +++++ .../Cache/RedisCacheTests.cs | 2 +- .../Cache/ICacheProvider.cs | 6 +- src/SchematicHQ.Client/Cache/LocalCache.cs | 103 +++++++++--------- src/SchematicHQ.Client/Cache/RedisCache.cs | 57 +++++----- 5 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs index 5a4932c8..9c29c359 100644 --- a/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Cache/LocalCacheTests.cs @@ -1142,6 +1142,36 @@ public async Task Set_UpdatesExistingValue() 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 async Task Delete_RemovesItemPermanently() { diff --git a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs index c4aa649e..a0a6b05f 100644 --- a/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Cache/RedisCacheTests.cs @@ -122,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/Cache/ICacheProvider.cs b/src/SchematicHQ.Client/Cache/ICacheProvider.cs index 13db2554..21df56b8 100644 --- a/src/SchematicHQ.Client/Cache/ICacheProvider.cs +++ b/src/SchematicHQ.Client/Cache/ICacheProvider.cs @@ -12,7 +12,7 @@ public interface ICacheProvider /// Cache key /// An optional cancellation token /// The cached value or default if not found or expired - ValueTask Get(string key, CancellationToken token = default); + ValueTask Get(string key, CancellationToken token = default) where T: notnull; /// /// Set a value in the cache @@ -21,7 +21,7 @@ public interface ICacheProvider /// Value to cache /// Optional time-to-live override /// An optional cancellation token - ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default); + 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. @@ -30,7 +30,7 @@ public interface ICacheProvider /// 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); + ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull; /// /// Delete a key from the cache diff --git a/src/SchematicHQ.Client/Cache/LocalCache.cs b/src/SchematicHQ.Client/Cache/LocalCache.cs index cfa55e65..7e940903 100644 --- a/src/SchematicHQ.Client/Cache/LocalCache.cs +++ b/src/SchematicHQ.Client/Cache/LocalCache.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; namespace SchematicHQ.Client.Cache; @@ -53,55 +54,15 @@ public LocalCache(int maxItems = DEFAULT_CACHE_CAPACITY, TimeSpan? ttl = null, b } /// - public ValueTask Get(string key, CancellationToken token = default) + public ValueTask Get(string key, CancellationToken token = default) where T: notnull { - if (_maxItems == 0 || _disposed) - return ValueTask.FromResult(default(T)); - - if (!_cache.TryGetValue(key, out var item)) - return ValueTask.FromResult(default(T)); - - if (item.Expiration != DateTime.MaxValue && DateTime.UtcNow > item.Expiration) - { - // This item has expired, remove it - Remove(key); - return ValueTask.FromResult(default(T)); - } - - // 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); - } - } - - if (item.Value is T typedValue) - return ValueTask.FromResult(typedValue); - - return ValueTask.FromResult(default(T)); + return TryGet(key, out var value) + ? ValueTask.FromResult(value) + : ValueTask.FromResult(default(T)); } /// - public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) + public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull { if (_maxItems == 0 || _disposed) return ValueTask.CompletedTask; @@ -168,12 +129,56 @@ public ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, Cancell return ValueTask.CompletedTask; } + 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) + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull { - // Try to get from cache first - var existing = await Get(key, token); - if (existing is not null) + if (TryGet(key, out var existing)) return existing; // Cache miss — invoke the factory and store the result diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index 0bd373e2..e280ddac 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -29,10 +29,10 @@ public RedisCache(RedisCacheConfig config) { throw new ArgumentNullException(nameof(config)); } - _redis = GetRedis(config); - + try { + _redis = GetRedis(config); _db = _redis.GetDatabase(config.Database); _keyPrefix = config.KeyPrefix ?? DEFAULT_KEY_PREFIX; _ttl = config.CacheTTL ?? DEFAULT_CACHE_TTL; @@ -110,30 +110,14 @@ private ConnectionMultiplexer GetRedis(RedisCacheConfig config) } /// - public async ValueTask Get(string key, CancellationToken token = default) + public async ValueTask Get(string key, CancellationToken token = default) where T: notnull { - var redisKey = GetRedisKey(key); - var value = await _db.StringGetAsync(redisKey); - - if (value.IsNullOrEmpty) - { - return default; - } - - try - { - return JsonSerializer.Deserialize(value!, _jsonOptions); - } - catch (Exception e) - { - // If we can't deserialize, just remove the value and return null - await Delete(key); - return default; - } + var (_, value) = await TryGetInternalAsync(key); + return value; } /// - public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, CancellationToken token = default) + 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; @@ -144,14 +128,33 @@ public async ValueTask Set(string key, T val, TimeSpan? ttlOverride = null, C 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 = await _db.StringGetAsync(redisKey); + + if (value.IsNullOrEmpty) + return (false, default); + + try + { + return (true, JsonSerializer.Deserialize(value!, _jsonOptions)); + } + catch + { + // Bad payload + await Delete(key); + return (false, default); + } + } //TODO update this with stampede protection at some point. - public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) + public async ValueTask GetOrSet(string key, Func> factory, TimeSpan? ttlOverride = null, CancellationToken token = default) where T: notnull { - // Try to get from cache first - var existing = await Get(key, token); - if (existing is not null) - return existing; + var (found, existing) = await TryGetInternalAsync(key); + if (found) + return existing!; // Cache miss — invoke the factory and store the result var value = await factory(token); From f6aeccff3002bb376294a5c36f61daecd80593a3 Mon Sep 17 00:00:00 2001 From: JT Date: Sat, 2 May 2026 17:14:14 +0800 Subject: [PATCH 10/10] Align configuration API with Microsoft extensions --- src/SchematicHQ.Client/Cache/RedisCache.cs | 14 ++++-- .../Datastream/RedisCacheConfig.cs | 46 ++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/SchematicHQ.Client/Cache/RedisCache.cs b/src/SchematicHQ.Client/Cache/RedisCache.cs index e280ddac..e6df31a8 100644 --- a/src/SchematicHQ.Client/Cache/RedisCache.cs +++ b/src/SchematicHQ.Client/Cache/RedisCache.cs @@ -13,7 +13,7 @@ public class RedisCache : ICacheProvider 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; @@ -44,10 +44,16 @@ public RedisCache(RedisCacheConfig config) } } - private ConnectionMultiplexer GetRedis(RedisCacheConfig config) + private static IConnectionMultiplexer GetRedis(RedisCacheConfig config) { - if (config.RedisConnection != null) - return config.RedisConnection; + 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 diff --git a/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs b/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs index 2add3d80..f422f499 100644 --- a/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs +++ b/src/SchematicHQ.Client/Datastream/RedisCacheConfig.cs @@ -23,100 +23,112 @@ public class RedisCacheConfig /// 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; } /// - /// The redis connection multiplexer to use for this cache. + /// Gets or sets a delegate to create or return the ConnectionMultiplexer instance. + /// This is prioritized over Configuration and ConfigurationOptions. /// - public ConnectionMultiplexer? RedisConnection { get; set; } + public Func? ConnectionMultiplexerFactory { get; set; } /// /// The Redis server endpoints (host:port format) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public List Endpoints { get; set; } = new List(); /// /// Redis username for authentication (Redis 6.0+) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? Username { get; set; } /// /// Redis password for authentication /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? Password { get; set; } /// /// Use SSL/TLS for connection /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool Ssl { get; set; } = false; /// /// SSL host (defaults to endpoint if not specified) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? SslHost { get; set; } /// /// Client name for connection identification /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? ClientName { get; set; } /// /// Connection timeout in milliseconds (default: 5000ms) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int ConnectTimeout { get; set; } = 5000; /// /// Synchronous operation timeout in milliseconds (default: 5000ms) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int SyncTimeout { get; set; } = 5000; /// /// Asynchronous operation timeout in milliseconds (default: 5000ms) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int AsyncTimeout { get; set; } = 5000; /// /// Keep-alive interval in seconds (default: 60s) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int KeepAlive { get; set; } = 60; /// /// Whether to abort connection on connect failure (default: true) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool AbortOnConnectFail { get; set; } = true; /// /// Connection retry count (default: 3) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int ConnectRetry { get; set; } = 3; /// /// Allow admin operations (dangerous commands) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public bool AllowAdmin { get; set; } = false; /// /// Default database for commands (can be overridden per-operation) /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public int? DefaultDatabase { get; set; } /// /// Service name for Sentinel support /// - [Obsolete("Use RedisConnection instead")] + [Obsolete("Use Configuration, ConfigurationOptions, or ConnectionMultiplexerFactory Instead")] public string? ServiceName { get; set; } }