diff --git a/pkgs/sdk/server/src/Integrations/PersistentDataStoreBuilder.cs b/pkgs/sdk/server/src/Integrations/PersistentDataStoreBuilder.cs index 867e24b9..7098c059 100644 --- a/pkgs/sdk/server/src/Integrations/PersistentDataStoreBuilder.cs +++ b/pkgs/sdk/server/src/Integrations/PersistentDataStoreBuilder.cs @@ -25,6 +25,17 @@ namespace LaunchDarkly.Sdk.Server.Integrations /// .DataStore(myStore) /// .Build(); /// + /// + /// Note: under the FDv2 data system, the cache settings configured here + /// (, , + /// , , + /// , ) only govern the + /// brief bootstrap window before the in-memory store has received its + /// first full payload. Once the in-memory store takes over as the active + /// read source, the persistent-store cache is released and these settings + /// have no further effect. These options are kept for backward compatibility + /// and may be deprecated in a future major version. + /// /// public class PersistentDataStoreBuilder : IComponentConfigurer, IDiagnosticDescription { diff --git a/pkgs/sdk/server/src/Internal/DataStores/PersistentStoreWrapper.cs b/pkgs/sdk/server/src/Internal/DataStores/PersistentStoreWrapper.cs index dfd842b2..a7e07a9f 100644 --- a/pkgs/sdk/server/src/Internal/DataStores/PersistentStoreWrapper.cs +++ b/pkgs/sdk/server/src/Internal/DataStores/PersistentStoreWrapper.cs @@ -22,7 +22,7 @@ namespace LaunchDarkly.Sdk.Server.Internal.DataStores /// class adds the caching behavior that we normally want for any persistent data store. /// /// - internal sealed class PersistentStoreWrapper : IDataStore, ISettableCache + internal sealed class PersistentStoreWrapper : IDataStore, ISettableCache, IDisableableCache { private readonly IPersistentDataStore _core; private readonly DataStoreCacheConfig _caching; @@ -39,6 +39,13 @@ internal sealed class PersistentStoreWrapper : IDataStore, ISettableCache private ICacheExporter _externalCache; private volatile bool _inited; + + // Once true, the cache is bypassed on reads and writes; entries already in + // the cache have been Clear()-ed by DisableCache(). The cache objects + // themselves remain alive until Dispose() so that any reader currently + // holding a reference does not observe a disposed cache. Volatile so the + // bypass becomes visible to other threads without an explicit lock. + private volatile bool _cacheDisabled; internal PersistentStoreWrapper( IPersistentDataStoreAsync coreAsync, @@ -112,7 +119,7 @@ public bool Initialized() return true; } bool result; - if (_initCache != null) + if (_initCache != null && !_cacheDisabled) { result = _initCache.Get(); } @@ -163,7 +170,7 @@ public void Init(FullDataSet items) kindAndItems => PersistentDataStoreConverter.SerializeAll(kindAndItems.Key, kindAndItems.Value.Items) ); Exception failure = InitCore(new FullDataSet(serializedItems)); - if (_itemCache != null && _allCache != null) + if (_itemCache != null && _allCache != null && !_cacheDisabled) { _itemCache.Clear(); _allCache.Clear(); @@ -199,7 +206,7 @@ public void Init(FullDataSet items) { try { - var ret = _itemCache is null ? GetAndDeserializeItem(kind, key) : + var ret = (_itemCache is null || _cacheDisabled) ? GetAndDeserializeItem(kind, key) : _itemCache.Get(new CacheKey(kind, key)); ProcessError(null); return ret; @@ -215,7 +222,7 @@ public KeyedItems GetAll(DataKind kind) { try { - var ret = new KeyedItems(_allCache is null ? + var ret = new KeyedItems((_allCache is null || _cacheDisabled) ? GetAllAndDeserialize(kind) : _allCache.Get(kind)); ProcessError(null); return ret; @@ -250,7 +257,7 @@ public bool Upsert(DataKind kind, string key, ItemDescriptor item) } failure = e; } - if (_itemCache != null) + if (_itemCache != null && !_cacheDisabled) { var cacheKey = new CacheKey(kind, key); if (failure is null) @@ -284,7 +291,7 @@ public bool Upsert(DataKind kind, string key, ItemDescriptor item) } } } - if (_allCache != null) + if (_allCache != null && !_cacheDisabled) { // If the cache has a finite TTL, then we should remove the "all items" cache entry to force // a reread the next time All is called. However, if it's an infinite TTL, we need to just @@ -315,6 +322,24 @@ public bool Upsert(DataKind kind, string key, ItemDescriptor item) return updated; } + /// + /// Disables the internal cache. After this call, reads and writes go straight + /// to the underlying persistent store; subsequent invocations are no-ops. + /// + /// + /// At the time of writing, it is likely that the cache was disabled when + /// another store took over as the active store and so reads may not even get + /// to this layer anymore. If they do, they do go straight to persistence if + /// this layer's DisableCache method was called. + /// + public void DisableCache() + { + if (_cacheDisabled) return; + _cacheDisabled = true; + _itemCache?.Clear(); + _allCache?.Clear(); + } + public void Dispose() { Dispose(true); @@ -437,8 +462,9 @@ private bool PollAvailabilityAfterOutage() } // Fall back to cache-based recovery if external store is not available/initialized - // and we're in infinite cache mode - if (_cacheIndefinitely && _allCache != null) + // and we're in infinite cache mode. Under FDv2 this branch is dead once + // DisableCache has run: the ICacheExporter path above supersedes it. + if (_cacheIndefinitely && _allCache != null && !_cacheDisabled) { // If we're in infinite cache mode, then we can assume the cache has a full set of current // flag data (since presumably the data source has still been running) and we can just diff --git a/pkgs/sdk/server/src/Internal/DataSystem/WriteThroughStore.cs b/pkgs/sdk/server/src/Internal/DataSystem/WriteThroughStore.cs index ff6457df..a44f51f7 100644 --- a/pkgs/sdk/server/src/Internal/DataSystem/WriteThroughStore.cs +++ b/pkgs/sdk/server/src/Internal/DataSystem/WriteThroughStore.cs @@ -127,6 +127,10 @@ private void MaybeSwitchStore() lock (_activeStoreLock) { _activeReadStore = _memoryStore; + if (_persistentStore is IDisableableCache disableable) + { + disableable.DisableCache(); + } } } diff --git a/pkgs/sdk/server/src/Subsystems/IDisableableCache.cs b/pkgs/sdk/server/src/Subsystems/IDisableableCache.cs new file mode 100644 index 00000000..7f2ee9e6 --- /dev/null +++ b/pkgs/sdk/server/src/Subsystems/IDisableableCache.cs @@ -0,0 +1,22 @@ +namespace LaunchDarkly.Sdk.Server.Subsystems +{ + /// + /// Optional interface for data stores that can disable their internal cache. + /// + /// + /// This is currently for internal implementations only. + /// + internal interface IDisableableCache + { + /// + /// Disables the internal cache. After this call, the cache is no longer + /// consulted on reads and no longer populated by writes. + /// + /// + /// Implementations should clear, dispose, and dereference cache instances + /// so the memory can be reclaimed. The call must be idempotent: subsequent + /// invocations should be safe and have no further effect. + /// + void DisableCache(); + } +} diff --git a/pkgs/sdk/server/test/Internal/DataStores/PersistentStoreWrapperTestBase.cs b/pkgs/sdk/server/test/Internal/DataStores/PersistentStoreWrapperTestBase.cs index dbaa87aa..9a0598eb 100644 --- a/pkgs/sdk/server/test/Internal/DataStores/PersistentStoreWrapperTestBase.cs +++ b/pkgs/sdk/server/test/Internal/DataStores/PersistentStoreWrapperTestBase.cs @@ -640,6 +640,109 @@ public void StatusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() } } + [Fact] + public void DisableCacheIsIdempotent() + { + var wrapper = MakeWrapper(Cached); + wrapper.DisableCache(); + wrapper.DisableCache(); // second call must not throw + } + + [Fact] + public void DisableCacheIsSafeOnUncachedWrapper() + { + var wrapper = MakeWrapper(Uncached); + wrapper.DisableCache(); // no caches to release, must not throw + } + + [Fact] + public void GetAfterDisableCacheReturnsCurrentCoreState() + { + var wrapper = MakeWrapper(Cached); + var key = "flag"; + var itemv1 = new TestItem("itemv1"); + var itemv2 = new TestItem("itemv2"); + + _core.ForceSet(TestDataKind, key, 1, itemv1); + // Prime the cache. + Assert.Equal(itemv1.WithVersion(1), wrapper.Get(TestDataKind, key)); + + wrapper.DisableCache(); + + // Mutate the core behind the wrapper's back. If the cache is still + // serving reads we would see the stale itemv1. + _core.ForceSet(TestDataKind, key, 2, itemv2); + Assert.Equal(itemv2.WithVersion(2), wrapper.Get(TestDataKind, key)); + } + + [Fact] + public void GetAllAfterDisableCacheReturnsCurrentCoreState() + { + var wrapper = MakeWrapper(Cached); + var itemA = new TestItem("itemA"); + var itemB = new TestItem("itemB"); + + _core.ForceSet(TestDataKind, "keyA", 1, itemA); + // Prime the cache. + var primed = wrapper.GetAll(TestDataKind); + Assert.Single(primed.Items); + + wrapper.DisableCache(); + + _core.ForceSet(TestDataKind, "keyB", 1, itemB); + var afterDrop = wrapper.GetAll(TestDataKind); + Assert.Equal(2, afterDrop.Items.Count()); + } + + [Fact] + public void UpsertAfterDisableCacheWritesThroughToCoreOnly() + { + var wrapper = MakeWrapper(Cached); + var key = "flag"; + var itemv1 = new TestItem("itemv1"); + + wrapper.DisableCache(); + + Assert.True(wrapper.Upsert(TestDataKind, key, itemv1.WithVersion(1))); + // The write must have landed in the core, and a subsequent Get + // must reach the core (not a repopulated cache). + Assert.True(_core.Data[TestDataKind].ContainsKey(key)); + Assert.Equal(itemv1.WithVersion(1), wrapper.Get(TestDataKind, key)); + } + + [Fact] + public void InitAfterDisableCacheWritesThroughToCoreWithoutRepopulatingCache() + { + var wrapper = MakeWrapper(Cached); + var itemA = new TestItem("itemA"); + var itemB = new TestItem("itemB"); + + wrapper.DisableCache(); + + var allData = new TestDataBuilder() + .Add(TestDataKind, "keyA", 1, itemA) + .Build(); + wrapper.Init(allData); + + // The init must have written to the core. + Assert.True(_core.Data[TestDataKind].ContainsKey("keyA")); + + // If the cache had repopulated, the next ForceRemove behind the + // wrapper's back would still yield itemA on Get. With cache + // disabled, the new core state is what we observe. + _core.ForceRemove(TestDataKind, "keyA"); + _core.ForceSet(TestDataKind, "keyA", 2, itemB); + Assert.Equal(itemB.WithVersion(2), wrapper.Get(TestDataKind, "keyA")); + } + + [Fact] + public void DisposeIsSafeAfterDisableCache() + { + var wrapper = MakeWrapper(Cached); + wrapper.DisableCache(); + wrapper.Dispose(); // must not throw, even though caches are already gone + } + private PersistentStoreWrapper MakeWrapper(CacheMode cacheMode) => MakeWrapper(new TestParams { CacheMode = cacheMode, PersistMode = PersistWithMetadata }); diff --git a/pkgs/sdk/server/test/Internal/DataSystem/WriteThroughStoreTest.cs b/pkgs/sdk/server/test/Internal/DataSystem/WriteThroughStoreTest.cs index 9a68081d..0daa7896 100644 --- a/pkgs/sdk/server/test/Internal/DataSystem/WriteThroughStoreTest.cs +++ b/pkgs/sdk/server/test/Internal/DataSystem/WriteThroughStoreTest.cs @@ -497,6 +497,115 @@ public void StoreSwitching_HappensOnlyOnce() #endregion + #region Cache Disable Tests + + [Fact] + public void Apply_FirstBasis_CallsDisableCacheOnPersistentStore() + { + var memoryStore = new InMemoryDataStore(); + var persistentStore = new MockDisableableCachePersistentStore(); + + using (var store = new WriteThroughStore(memoryStore, persistentStore, + DataSystemConfiguration.DataStoreMode.ReadWrite)) + { + store.Apply(CreateFullChangeSet()); + + Assert.Equal(1, persistentStore.DisableCacheCallCount); + } + } + + [Fact] + public void Apply_SubsequentApply_DoesNotCallDisableCacheAgain() + { + var memoryStore = new InMemoryDataStore(); + var persistentStore = new MockDisableableCachePersistentStore(); + + using (var store = new WriteThroughStore(memoryStore, persistentStore, + DataSystemConfiguration.DataStoreMode.ReadWrite)) + { + store.Apply(CreateFullChangeSet()); + + // Build a delta changeset to apply on top. + var item3 = new TestItem("item3"); + var deltaData = ImmutableList.Create( + new KeyValuePair>( + TestDataKind, + new KeyedItems(ImmutableDictionary.Empty + .Add("key3", new ItemDescriptor(30, item3))) + ) + ); + var delta = new ChangeSet( + ChangeSetType.Partial, + Selector.Make(2, "state2"), + deltaData, + null); + store.Apply(delta); + + Assert.Equal(1, persistentStore.DisableCacheCallCount); + } + } + + [Fact] + public void Init_FirstCall_CallsDisableCacheOnPersistentStore() + { + var memoryStore = new InMemoryDataStore(); + var persistentStore = new MockDisableableCachePersistentStore(); + + using (var store = new WriteThroughStore(memoryStore, persistentStore, + DataSystemConfiguration.DataStoreMode.ReadWrite)) + { + store.Init(CreateTestDataSet()); + + Assert.Equal(1, persistentStore.DisableCacheCallCount); + } + } + + [Fact] + public void Apply_ReadOnlyMode_StillCallsDisableCache() + { + var memoryStore = new InMemoryDataStore(); + var persistentStore = new MockDisableableCachePersistentStore(); + + using (var store = new WriteThroughStore(memoryStore, persistentStore, + DataSystemConfiguration.DataStoreMode.ReadOnly)) + { + store.Apply(CreateFullChangeSet()); + + // Reads bypass the persistent store in both modes post-switch, + // so the cache is dead weight regardless of mode. + Assert.Equal(1, persistentStore.DisableCacheCallCount); + } + } + + [Fact] + public void Apply_NonDisablerStore_DoesNotThrow() + { + var memoryStore = new InMemoryDataStore(); + var persistentStore = new MockPersistentStore(); // does not implement IDisableableCache + + using (var store = new WriteThroughStore(memoryStore, persistentStore, + DataSystemConfiguration.DataStoreMode.ReadWrite)) + { + // The probe must be a no-op for stores that don't implement the interface. + store.Apply(CreateFullChangeSet()); + Assert.True(persistentStore.WasInitCalled); + } + } + + [Fact] + public void Apply_WithoutPersistence_DoesNotThrow() + { + var memoryStore = new InMemoryDataStore(); + + using (var store = new WriteThroughStore(memoryStore, null, + DataSystemConfiguration.DataStoreMode.ReadWrite)) + { + store.Apply(CreateFullChangeSet()); // null _persistentStore -- probe must short-circuit + } + } + + #endregion + #region Selector Tests [Fact] @@ -959,6 +1068,16 @@ public void Dispose() } } + private class MockDisableableCachePersistentStore : MockTransactionalPersistentStore, IDisableableCache + { + public int DisableCacheCallCount { get; private set; } + + public void DisableCache() + { + DisableCacheCallCount++; + } + } + private class MockTransactionalPersistentStore : MockPersistentStore, ITransactionalDataStore { public bool WasApplyCalled { get; private set; }