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; }