From 718440b1039af7a1bc204ee522ca843719da8f62 Mon Sep 17 00:00:00 2001 From: adams85 <31276480+adams85@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:03:20 +0200 Subject: [PATCH] Implement non-blocking synchronous evaluation (snapshot API) (#81) * Implement non-blocking synchronous evaluation (snapshot API) * Simplify maxInitWaitTime handling in AutoPollConfigService * Add warning about the dangers of the blocking client methods * Fix occasionally failing tests (increase tolerance of timing checks) * Deprecate block waiting synchronous methods of IConfigCatClient --- .github/workflows/linux-macOS-CI.yml | 27 +- .../Controllers/BackdoorController.cs | 6 +- .../Controllers/HomeController.cs | 8 +- samples/ConsoleApp/Program.cs | 4 +- samples/FileLoggerSample.cs | 5 +- .../ConfigCacheTests.cs | 41 +- .../ConfigCat.Client.Tests.csproj | 1 + .../ConfigCatClientSnapshotTests.cs | 71 +++ .../ConfigCatClientTests.cs | 522 ++++++++++++++++-- .../ConfigServiceTests.cs | 150 ++++- .../DeserializerTests.cs | 3 +- .../EvaluationLogTests.cs | 8 +- .../Fakes/FakeExternalCache.cs | 37 ++ .../Helpers/ConfigHelper.cs | 2 +- .../Helpers/ConfigLocation.LocalFile.cs | 2 +- src/ConfigCat.Client.Tests/OverrideTests.cs | 4 +- src/ConfigCatClient/ConfigCatClient.cs | 146 +++-- .../ConfigCatClientSnapshot.cs | 145 +++++ .../ConfigService/AutoPollConfigService.cs | 99 ++-- .../ConfigService/ClientCacheState.cs | 27 + .../ConfigService/ConfigServiceBase.cs | 41 +- .../ConfigService/IConfigService.cs | 6 + .../ConfigService/LazyLoadConfigService.cs | 20 +- .../ConfigService/ManualPollConfigService.cs | 15 +- .../ConfigService/NullConfigService.cs | 8 +- .../Configuration/ConfigCatClientOptions.cs | 2 +- .../Evaluation/RolloutEvaluator.cs | 2 +- .../Extensions/SerializationExtensions.cs | 4 - .../Hooks/ClientReadyEventArgs.cs | 19 + src/ConfigCatClient/Hooks/Hooks.cs | 16 +- src/ConfigCatClient/Hooks/IProvidesHooks.cs | 2 +- src/ConfigCatClient/Hooks/SafeHooksWrapper.cs | 4 +- src/ConfigCatClient/IConfigCatClient.cs | 66 +++ .../IConfigCatClientSnapshot.cs | 73 +++ .../Override/LocalFileDataSource.cs | 2 +- 35 files changed, 1337 insertions(+), 251 deletions(-) create mode 100644 src/ConfigCat.Client.Tests/ConfigCatClientSnapshotTests.cs create mode 100644 src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs create mode 100644 src/ConfigCatClient/ConfigCatClientSnapshot.cs create mode 100644 src/ConfigCatClient/ConfigService/ClientCacheState.cs create mode 100644 src/ConfigCatClient/Hooks/ClientReadyEventArgs.cs create mode 100644 src/ConfigCatClient/IConfigCatClientSnapshot.cs diff --git a/.github/workflows/linux-macOS-CI.yml b/.github/workflows/linux-macOS-CI.yml index b7babcfb..d58be07c 100644 --- a/.github/workflows/linux-macOS-CI.yml +++ b/.github/workflows/linux-macOS-CI.yml @@ -13,12 +13,9 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: - build-test: - name: Build & test - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ macos-latest, ubuntu-latest ] + build-test-ubuntu: + name: Build & test (Ubuntu) + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-dotnet@v1 @@ -35,4 +32,20 @@ jobs: dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f netcoreapp3.1 --no-restore dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net5.0 --no-restore dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net6.0 --no-restore - dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net8.0 --no-restore \ No newline at end of file + dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net8.0 --no-restore + build-test-macos: + name: Build & test (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-dotnet@v1 + with: + dotnet-version: | + 6.0.x + 8.0.x + - name: Restore + run: dotnet restore src + - name: Test + run: | + dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net6.0 --no-restore + dotnet test src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj -c Release -f net8.0 --no-restore diff --git a/samples/ASP.NETCore/WebApplication/Controllers/BackdoorController.cs b/samples/ASP.NETCore/WebApplication/Controllers/BackdoorController.cs index edc927ce..ebb02caf 100644 --- a/samples/ASP.NETCore/WebApplication/Controllers/BackdoorController.cs +++ b/samples/ASP.NETCore/WebApplication/Controllers/BackdoorController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -23,9 +23,9 @@ public BackdoorController(IConfigCatClient configCatClient) // This endpoint can be called by Configcat Webhooks https://configcat.com/docs/advanced/notifications-webhooks [HttpGet] [Route("configcatchanged")] - public IActionResult ConfigCatChanged() + public async Task ConfigCatChanged() { - this.configCatClient.ForceRefresh(); + await this.configCatClient.ForceRefreshAsync(); return Ok("configCatClient.ForceRefresh() invoked"); } diff --git a/samples/ASP.NETCore/WebApplication/Controllers/HomeController.cs b/samples/ASP.NETCore/WebApplication/Controllers/HomeController.cs index 2aee4bf6..ae99171d 100644 --- a/samples/ASP.NETCore/WebApplication/Controllers/HomeController.cs +++ b/samples/ASP.NETCore/WebApplication/Controllers/HomeController.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using System.Diagnostics; +using System.Threading.Tasks; using ConfigCat.Client; using Microsoft.AspNetCore.Mvc; using WebApplication.Models; @@ -15,9 +15,9 @@ public HomeController(IConfigCatClient configCatClient) this.configCatClient = configCatClient; } - public IActionResult Index() + public async Task Index() { - ViewData["Message1"] = this.configCatClient.GetValue("isAwesomeFeatureEnabled", false); + ViewData["Message1"] = await this.configCatClient.GetValueAsync("isAwesomeFeatureEnabled", false); var userObject = new User("") { @@ -30,7 +30,7 @@ public IActionResult Index() } }; - ViewData["Message2"] = this.configCatClient.GetValue("isPOCFeatureEnabled", false, userObject); + ViewData["Message2"] = await this.configCatClient.GetValueAsync("isPOCFeatureEnabled", false, userObject); return View(); } diff --git a/samples/ConsoleApp/Program.cs b/samples/ConsoleApp/Program.cs index 5c653783..a3270953 100644 --- a/samples/ConsoleApp/Program.cs +++ b/samples/ConsoleApp/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using ConfigCat.Client; // Creating the ConfigCat client instance using the SDK Key @@ -21,5 +21,5 @@ }; // Accessing feature flag or setting value -var value = client.GetValue("isPOCFeatureEnabled", false, user); +var value = await client.GetValueAsync("isPOCFeatureEnabled", false, user); Console.WriteLine($"isPOCFeatureEnabled: {value}"); diff --git a/samples/FileLoggerSample.cs b/samples/FileLoggerSample.cs index 21ff4878..d6047009 100644 --- a/samples/FileLoggerSample.cs +++ b/samples/FileLoggerSample.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using System.Threading.Tasks; using ConfigCat.Client; namespace SampleApplication @@ -56,7 +57,7 @@ public void Log(LogLevel level, LogEventId eventId, ref FormattableLogMessage me } } - static void Main(string[] args) + static async Task Main(string[] args) { var filePath = Path.Combine(Environment.CurrentDirectory, "configcat.log"); var logLevel = LogLevel.Warning; // Log only WARNING and higher entries (warnings and errors). @@ -67,7 +68,7 @@ static void Main(string[] args) options.PollingMode = PollingModes.AutoPoll(pollInterval: TimeSpan.FromSeconds(5)); }); - var feature = client.GetValue("keyNotExists", "N/A"); + var feature = await client.GetValueAsync("keyNotExists", "N/A"); Console.ReadKey(); } diff --git a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs index 4aeaa894..41d26adc 100644 --- a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; +using ConfigCat.Client.Tests.Fakes; using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -70,20 +71,20 @@ public async Task ConfigCache_Override_ManualPoll_Works() }); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Never); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once); var actual = await client.GetValueAsync("stringDefaultCat", "N/A"); Assert.AreEqual("N/A", actual); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); await client.ForceRefreshAsync(); actual = await client.GetValueAsync("stringDefaultCat", "N/A"); Assert.AreEqual("Cat", actual); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(4)); } [TestMethod] @@ -242,40 +243,8 @@ public void CacheKeyGeneration_ShouldBePlatformIndependent(string sdkKey, string public void CachePayloadSerialization_ShouldBePlatformIndependent(string configJson, string timeStamp, string httpETag, string expectedPayload) { var timeStampDateTime = DateTimeOffset.ParseExact(timeStamp, "o", CultureInfo.InvariantCulture).UtcDateTime; - var pc = new ProjectConfig(configJson, configJson.Deserialize(), timeStampDateTime, httpETag); + var pc = new ProjectConfig(configJson, Config.Deserialize(configJson.AsMemory()), timeStampDateTime, httpETag); Assert.AreEqual(expectedPayload, ProjectConfig.Serialize(pc)); } - - private sealed class FakeExternalCache : IConfigCatCache - { - public volatile string? CachedValue = null; - - public string? Get(string key) => this.CachedValue; - - public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); - - public void Set(string key, string value) => this.CachedValue = value; - - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - { - Set(key, value); - return Task.FromResult(0); - } - } - - private sealed class FaultyFakeExternalCache : IConfigCatCache - { - public string? Get(string key) => throw new ApplicationException("Operation failed :("); - - public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); - - public void Set(string key, string value) => throw new ApplicationException("Operation failed :("); - - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - { - Set(key, value); - return Task.FromResult(0); - } - } } diff --git a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj index c3933b03..3195e042 100644 --- a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj +++ b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj @@ -8,6 +8,7 @@ 10.0 enable nullable + CS0618 ..\ConfigCatClient.snk diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientSnapshotTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientSnapshotTests.cs new file mode 100644 index 00000000..03734ea0 --- /dev/null +++ b/src/ConfigCat.Client.Tests/ConfigCatClientSnapshotTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using ConfigCat.Client.Cache; +using ConfigCat.Client.ConfigService; +using ConfigCat.Client.Configuration; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Override; +using ConfigCat.Client.Tests.Fakes; +using ConfigCat.Client.Tests.Helpers; +using ConfigCat.Client.Utils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class ConfigCatClientSnapshotTests +{ + [TestMethod] + public void DefaultInstanceDoesNotThrow() + { + const string key = "key"; + const string defaultValue = ""; + + var snapshot = default(ConfigCatClientSnapshot); + + Assert.AreEqual(ClientCacheState.NoFlagData, snapshot.CacheState); + Assert.IsNull(snapshot.FetchedConfig); + CollectionAssert.AreEqual(ArrayUtils.EmptyArray(), snapshot.GetAllKeys().ToArray()); + Assert.AreEqual("", snapshot.GetValue(key, defaultValue)); + var evaluationDetails = snapshot.GetValueDetails(key, defaultValue); + Assert.IsNotNull(evaluationDetails); + Assert.AreEqual(key, evaluationDetails.Key); + Assert.AreEqual(defaultValue, evaluationDetails.Value); + Assert.IsTrue(evaluationDetails.IsDefaultValue); + Assert.IsNotNull(evaluationDetails.ErrorMessage); + } + + [TestMethod] + public void CanMockSnapshot() + { + const ClientCacheState cacheState = ClientCacheState.HasUpToDateFlagData; + var fetchedConfig = new Config(); + var keys = new[] { "key1", "key2" }; + var evaluationDetails = new EvaluationDetails("key1", "value"); + + var mock = new Mock(); + mock.SetupGet(m => m.CacheState).Returns(cacheState); + mock.SetupGet(m => m.FetchedConfig).Returns(fetchedConfig); + mock.Setup(m => m.GetAllKeys()).Returns(keys); + mock.Setup(m => m.GetValue(evaluationDetails.Key, It.IsAny(), It.IsAny())).Returns(evaluationDetails.Value); + mock.Setup(m => m.GetValueDetails(evaluationDetails.Key, It.IsAny(), It.IsAny())).Returns(evaluationDetails); + + var snapshot = new ConfigCatClientSnapshot(mock.Object); + + Assert.AreEqual(cacheState, snapshot.CacheState); + Assert.AreEqual(fetchedConfig, snapshot.FetchedConfig); + CollectionAssert.AreEqual(keys, snapshot.GetAllKeys().ToArray()); + Assert.AreEqual(evaluationDetails.Value, snapshot.GetValue(evaluationDetails.Key, "")); + Assert.AreSame(evaluationDetails, snapshot.GetValueDetails(evaluationDetails.Key, "")); + } +} diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index ecec60c2..022cec61 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -12,6 +13,8 @@ using ConfigCat.Client.ConfigService; using ConfigCat.Client.Configuration; using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Override; +using ConfigCat.Client.Tests.Fakes; using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -136,6 +139,371 @@ public void CreateAnInstance_WithSdkKey_ShouldCreateAnInstance() using var _ = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds3/GsdrTr4sxbHdSgdhHRZds3"); } + [TestMethod] + public async Task Initialization_AutoPoll_ConfigChangedInEveryFetch_ShouldFireConfigChangedEveryPollingIteration() + { + var pollInterval = TimeSpan.FromSeconds(1); + + var fetchCounter = 0; + var configChangedEventCount = 0; + + var hooks = new Hooks(); + hooks.ConfigChanged += delegate { configChangedEventCount++; }; + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.Success(ConfigHelper.FromString("{}", httpETag: $"\"{(++fetchCounter).ToString(CultureInfo.InvariantCulture)}\"", timeStamp: ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + await Task.Delay(TimeSpan.FromMilliseconds(2.5 * pollInterval.TotalMilliseconds)); + + Assert.AreEqual(3, configChangedEventCount); + } + + [TestMethod] + public async Task Initialization_AutoPoll_ConfigNotChanged_ShouldFireConfigChangedOnlyOnce() + { + var pollInterval = TimeSpan.FromSeconds(1); + + var fetchCounter = 0; + var configChangedEventCount = 0; + + var hooks = new Hooks(); + hooks.ConfigChanged += delegate { configChangedEventCount++; }; + + var pc = ConfigHelper.FromString("{}", httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => fetchCounter++ > 0 + ? FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())) + : FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + await Task.Delay(TimeSpan.FromMilliseconds(2.5 * pollInterval.TotalMilliseconds)); + + Assert.AreEqual(1, configChangedEventCount); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Initialization_AutoPoll_WithMaxInitWaitTime_GetValueShouldWait(bool isAsync) + { + var maxInitWaitTime = TimeSpan.FromSeconds(2); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds / 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: _ => throw new NotImplementedException(), + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks: null, out _ + ); + + var actualValue = isAsync + ? await client.GetValueAsync("boolDefaultTrue", false) + : client.GetValue("boolDefaultTrue", false); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= delay - TimeSpan.FromMilliseconds(50), $"Elapsed time: {sw.Elapsed}"); // 50ms for tolerance + Assert.IsTrue(sw.Elapsed <= delay + TimeSpan.FromMilliseconds(250), $"Elapsed time: {sw.Elapsed}"); // 250ms for tolerance + Assert.IsTrue(actualValue); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Initialization_AutoPoll_WithMaxInitWaitTime_GetValueShouldWaitForMaxInitWaitTimeOnlyAndReturnDefaultValue(bool isAsync) + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: _ => throw new NotImplementedException(), + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks: null, out _ + ); + + var actualValue = isAsync + ? await client.GetValueAsync("boolDefaultTrue", false) + : client.GetValue("boolDefaultTrue", false); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(50), $"Elapsed time: {sw.Elapsed}"); // 50ms for tolerance + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(250), $"Elapsed time: {sw.Elapsed}"); // 250ms for tolerance + + Assert.IsFalse(actualValue); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWait() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasUpToDateFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWaitForMaxInitWaitTime() + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var hooks = new Hooks(); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: _ => throw new NotImplementedException(), + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(50), $"Elapsed time: {sw.Elapsed}"); // 50ms for tolerance + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(250), $"Elapsed time: {sw.Elapsed}"); // 250ms for tolerance + + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsFalse(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsTrue(evaluationDetails.IsDefaultValue); + Assert.IsFalse(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWaitForMaxInitWaitTimeAndReturnCached() + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var pollInterval = TimeSpan.FromSeconds(5); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp() - pollInterval - TimeSpan.FromSeconds(1)); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: _ => throw new NotImplementedException(), + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval, maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(50), $"Elapsed time: {sw.Elapsed}"); // 50ms for tolerance + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(250), $"Elapsed time: {sw.Elapsed}"); // 250ms for tolerance + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LazyLoad_ReturnCached_UpToDate() + { + var cacheTimeToLive = TimeSpan.FromSeconds(2); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new LazyLoadConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, cacheTimeToLive, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasUpToDateFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LazyLoad_ReturnCached_Expired() + { + var cacheTimeToLive = TimeSpan.FromSeconds(2); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp() - cacheTimeToLive - TimeSpan.FromSeconds(1)); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new LazyLoadConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, cacheTimeToLive, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_ManualPoll_ReturnCached() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LocalOnlyFlagOverride() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: delegate { throw new InvalidOperationException(); }, + onFetchAsync: delegate { throw new InvalidOperationException(); }, + configServiceFactory: (_, _, loggerWrapper, hooks) => new NullConfigService(loggerWrapper, hooks: hooks), + evaluatorFactory: null, configCacheFactory: null, + overrideDataSourceFactory: logger => Tuple.Create(OverrideBehaviour.LocalOnly, (IOverrideDataSource)new LocalFileDataSource(configJsonFilePath, autoReload: false, logger)), + hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasLocalOverrideFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + [TestMethod] public void GetValue_ConfigServiceThrowException_ShouldReturnDefaultValue() { @@ -195,7 +563,7 @@ public void GetValue_EvaluateServiceThrowException_ShouldReturnDefaultValue() .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(); - var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -228,7 +596,7 @@ public async Task GetValueAsync_EvaluateServiceThrowException_ShouldReturnDefaul .Setup(m => m.Evaluate(It.IsAny(), ref It.Ref.IsAny, out It.Ref.IsAny)) .Throws(); - var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -574,7 +942,7 @@ public async Task GetValueDetails_EvaluateServiceThrowException_ShouldReturnDefa var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, @@ -633,12 +1001,11 @@ public async Task GetAllValueDetails_ShouldReturnCorrectEvaluationDetails(bool i var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, - evaluatorFactory: loggerWrapper => new RolloutEvaluator(loggerWrapper), new Hooks(), - out var configService, out _); + evaluatorFactory: null, new Hooks(), out var configService, out _); if (isAsync) { @@ -704,7 +1071,7 @@ public async Task GetAllValueDetails_DeserializeFailed_ShouldReturnWithEmptyArra this.configServiceMock.Setup(m => m.GetConfigAsync(It.IsAny())).ReturnsAsync(ProjectConfig.Empty); var o = new Config(); - using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -738,7 +1105,7 @@ public async Task GetAllValueDetails_ConfigServiceThrowException_ShouldReturnEmp .Setup(m => m.GetConfigAsync(It.IsAny())) .Throws(); - using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -775,7 +1142,7 @@ public async Task GetAllValueDetails_EvaluateServiceThrowException_ShouldReturnD var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, @@ -1461,47 +1828,6 @@ static void CreateClients(out int instanceCount) Assert.AreEqual(0, instanceCount2); } - private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, - Mock loggerMock, - Mock fetcherMock, - Func onFetch, - Func configServiceFactory, - out IConfigService configService, - out ConfigCache configCache) - { - return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, configServiceFactory, - evaluatorFactory: loggerWrapper => new RolloutEvaluator(loggerWrapper), hooks: null, - out configService, out configCache); - } - - private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, - Mock loggerMock, - Mock fetcherMock, - Func onFetch, - Func configServiceFactory, - Func evaluatorFactory, - Hooks? hooks, - out IConfigService configService, - out ConfigCache configCache) - { - fetcherMock.Setup(m => m.Fetch(It.IsAny())).Returns(onFetch); - fetcherMock.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ProjectConfig pc, CancellationToken _) => onFetch(pc)); - - var loggerWrapper = loggerMock.Object.AsWrapper(); - - configCache = new InMemoryConfigCache(); - - var cacheParams = new CacheParameters(configCache, cacheKey); - - configService = configServiceFactory(fetcherMock.Object, cacheParams, loggerWrapper); - return new ConfigCatClient(configService, loggerMock.Object, evaluatorFactory(loggerWrapper), hooks); - } - - private static int ParseETagAsInt32(string? etag) - { - return int.TryParse(etag, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 0; - } - [DataRow(nameof(AutoPoll))] [DataRow(nameof(LazyLoad))] [DataRow(nameof(ManualPoll))] @@ -1558,7 +1884,7 @@ public async Task OfflineMode_OfflineToOnlineTransition(string pollingMode) if (pollingMode == nameof(AutoPoll)) { - Assert.IsTrue(((AutoPollConfigService)configService).WaitForInitialization()); + Assert.IsTrue(await ((AutoPollConfigService)configService).InitializationTask); expectedFetchAsyncCount++; } @@ -1675,7 +2001,8 @@ public async Task OfflineMode_OnlineToOfflineTransition(string pollingMode) if (pollingMode == nameof(AutoPoll)) { - Assert.IsTrue(((AutoPollConfigService)configService).WaitForInitialization()); + var x = await ((AutoPollConfigService)configService).InitializationTask; + Assert.IsTrue(x); expectedFetchAsyncCount++; } @@ -1798,7 +2125,10 @@ public async Task Hooks_MockedClientRaisesEvents() var configService = new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks); // 1. Client gets created - var client = new ConfigCatClient(configService, this.loggerMock.Object, new RolloutEvaluator(loggerWrapper), hooks); + var client = new ConfigCatClient(configService, this.loggerMock.Object, new RolloutEvaluator(loggerWrapper), hooks: hooks); + + var cacheState = await client.WaitForReadyAsync(); + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); Assert.AreEqual(1, clientReadyEventCount); Assert.AreEqual(0, configFetchedEvents.Count); @@ -1867,7 +2197,7 @@ public async Task Hooks_RealClientRaisesEvents(bool subscribeViaOptions) var flagEvaluatedEvents = new List(); var errorEvents = new List(); - EventHandler handleClientReady = (s, e) => clientReadyCallCount++; + EventHandler handleClientReady = (s, e) => clientReadyCallCount++; EventHandler handleConfigFetched = (s, e) => configFetchedEvents.Add(e); EventHandler handleConfigChanged = (s, e) => configChangedEvents.Add(e); EventHandler handleFlagEvaluated = (s, e) => flagEvaluatedEvents.Add(e); @@ -1913,6 +2243,9 @@ void Unsubscribe(IProvidesHooks hooks) Subscribe(client); } + var cacheState = await client.WaitForReadyAsync(); + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); + Assert.AreEqual(subscribeViaOptions ? 2 : 0, clientReadyCallCount); Assert.AreEqual(0, configFetchedEvents.Count); Assert.AreEqual(0, configChangedEvents.Count); @@ -1973,6 +2306,83 @@ void Unsubscribe(IProvidesHooks hooks) Assert.AreEqual(2, errorEvents.Count); } + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + out IConfigService configService, + out ConfigCache configCache) + { + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, + configServiceFactory: (fetcher, cacheParams, logger, hooks) => configServiceFactory(fetcher, cacheParams, logger), + hooks: null, out configService, out configCache); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + Hooks? hooks, + out IConfigService configService, + out ConfigCache configCache) + { + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, configServiceFactory, + evaluatorFactory: null, hooks, out configService, out configCache); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + Func? evaluatorFactory, + Hooks? hooks, + out IConfigService configService, + out ConfigCache configCache) + { + var configCacheLocal = configCache = new InMemoryConfigCache(); + + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, + onFetch, onFetchAsync: (pc, _) => Task.FromResult(onFetch(pc)), + configServiceFactory, evaluatorFactory, + configCacheFactory: _ => configCacheLocal, + overrideDataSourceFactory: null, hooks, out configService); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func> onFetchAsync, + Func configServiceFactory, + Func? evaluatorFactory, + Func? configCacheFactory, + Func>? overrideDataSourceFactory, + Hooks? hooks, + out IConfigService configService) + { + fetcherMock.Setup(m => m.Fetch(It.IsAny())).Returns(onFetch); + fetcherMock.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny())).Returns((ProjectConfig pc, CancellationToken ct) => onFetchAsync(pc, ct)); + + var loggerWrapper = loggerMock.Object.AsWrapper(); + + var configCache = configCacheFactory is not null ? configCacheFactory(loggerWrapper) : new InMemoryConfigCache(); + var cacheParams = new CacheParameters(configCache, cacheKey); + + var evaluator = evaluatorFactory is not null ? evaluatorFactory(loggerWrapper) : new RolloutEvaluator(loggerWrapper); + var overrideDataSource = overrideDataSourceFactory?.Invoke(loggerWrapper); + + configService = configServiceFactory(fetcherMock.Object, cacheParams, loggerWrapper, hooks); + return new ConfigCatClient(configService, loggerMock.Object, evaluator, overrideDataSource?.Item1, overrideDataSource?.Item2, hooks); + } + + private static int ParseETagAsInt32(string? etag) + { + return int.TryParse(etag, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 0; + } + internal class FakeConfigService : ConfigServiceBase, IConfigService { public byte DisposeCount { get; private set; } @@ -1988,6 +2398,8 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public Task ReadyTask => Task.FromResult(ClientCacheState.NoFlagData); + public ValueTask GetConfigAsync(CancellationToken cancellationToken = default) { return new ValueTask(ProjectConfig.Empty); @@ -2007,5 +2419,7 @@ public override RefreshResult RefreshConfig() { return RefreshResult.Success(); } + + public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) => ClientCacheState.NoFlagData; } } diff --git a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs index 0b545cd6..feb462c3 100644 --- a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs @@ -122,19 +122,19 @@ public async Task LazyLoadConfigService_RefreshConfigAsync_ShouldNotInvokeCacheG this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc) - .Callback(() => Assert.AreEqual(1, callOrder++)) + .Callback(() => Assert.IsTrue(callOrder++ is 1 or 2)) .Verifiable(); this.fetcherMock .Setup(m => m.FetchAsync(cachedPc, It.IsAny())) .ReturnsAsync(FetchResult.Success(fetchedPc)) - .Callback(() => Assert.AreEqual(2, callOrder++)) + .Callback(() => Assert.AreEqual(3, callOrder++)) .Verifiable(); this.cacheMock .Setup(m => m.SetAsync(It.IsAny(), fetchedPc, It.IsAny())) .Returns(default(ValueTask)) - .Callback(() => Assert.AreEqual(3, callOrder)) + .Callback(() => Assert.AreEqual(4, callOrder)) .Verifiable(); using var service = new LazyLoadConfigService( @@ -237,6 +237,8 @@ public async Task AutoPollConfigService_GetConfigAsync_WithoutTimerWithCachedCon this.loggerMock.Object.AsWrapper(), startTimer: false); + this.cacheMock.Invocations.Clear(); + // Act await service.GetConfigAsync(); @@ -260,6 +262,8 @@ public async Task AutoPollConfigService_GetConfigAsync_WithTimer_ShouldInvokeFet var wd = new ManualResetEventSlim(false); + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -304,6 +308,8 @@ public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCache var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); var fetchedPc = CreateFreshPc(timeStamp); + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -323,6 +329,8 @@ public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCache this.loggerMock.Object.AsWrapper(), startTimer: false); + this.cacheMock.Invocations.Clear(); + // Act await service.RefreshConfigAsync(); @@ -346,6 +354,8 @@ public async Task AutoPollConfigService_Dispose_ShouldStopTimer() long counter = 0; long e1, e2; + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -387,6 +397,8 @@ public async Task AutoPollConfigService_WithoutTimer_InvokeDispose_ShouldDispose long counter = -1; long e1; + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -438,6 +450,8 @@ public async Task ManualPollConfigService_GetConfigAsync_ShouldInvokeCacheGet() this.loggerMock.Object.AsWrapper(), hooks: hooks); + this.cacheMock.Invocations.Clear(); + // Act var projectConfig = await service.GetConfigAsync(); @@ -477,16 +491,16 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc) - .Callback(() => Assert.AreEqual(1, callOrder++)); + .Callback(() => Assert.IsTrue(callOrder++ is 1 or 2)); this.fetcherMock .Setup(m => m.FetchAsync(cachedPc, It.IsAny())) .ReturnsAsync(FetchResult.Success(fetchedPc)) - .Callback(() => Assert.AreEqual(2, callOrder++)); + .Callback(() => Assert.AreEqual(3, callOrder++)); this.cacheMock .Setup(m => m.SetAsync(It.IsAny(), fetchedPc, It.IsAny())) - .Callback(() => Assert.AreEqual(3, callOrder++)) + .Callback(() => Assert.AreEqual(4, callOrder++)) .Returns(default(ValueTask)); using var service = new ManualPollConfigService( @@ -500,7 +514,7 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe // Assert - this.cacheMock.Verify(m => m.GetAsync(It.IsAny(), It.IsAny()), Times.Once); + this.cacheMock.Verify(m => m.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); this.fetcherMock.Verify(m => m.FetchAsync(It.IsAny(), It.IsAny()), Times.Once); this.cacheMock.Verify(m => m.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -653,8 +667,8 @@ public async Task AutoPollConfigService_GetConfig_ReturnsCachedConfigWhenCachedC var hooks = new Hooks(); - var clientReadyTcs = new TaskCompletionSource(); - hooks.ClientReady += (s, e) => clientReadyTcs.TrySetResult(default); + var clientReadyCalled = false; + hooks.ClientReady += (s, e) => Volatile.Write(ref clientReadyCalled, true); var configFetchedEventCount = 0; hooks.ConfigFetched += (s, e) => Interlocked.Increment(ref configFetchedEventCount); @@ -674,19 +688,12 @@ public async Task AutoPollConfigService_GetConfig_ReturnsCachedConfigWhenCachedC // Act - var clientReadyCalled = false; ProjectConfig actualPc; using (service) { if (waitForClientReady) { - await service.WaitForInitializationAsync(); - - // Allow some time for other initalization callbacks to execute. - using var cts = new CancellationTokenSource(); - var task = await Task.WhenAny(clientReadyTcs.Task, Task.Delay(maxInitWaitTime, cts.Token)); - cts.Cancel(); - clientReadyCalled = task == clientReadyTcs.Task && task.Status == TaskStatus.RanToCompletion; + await service.ReadyTask; } actualPc = isAsync ? await service.GetConfigAsync() : service.GetConfig(); @@ -978,4 +985,113 @@ public async Task LazyLoadConfigService_GetConfig_FetchesConfigWhenCachedConfigI Assert.IsTrue(configFetchedEvent.Result.IsSuccess); Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode); } + + [DataTestMethod] + [DataRow(nameof(PollingModes.AutoPoll))] + [DataRow(nameof(PollingModes.LazyLoad))] + [DataRow(nameof(PollingModes.ManualPoll))] + public async Task GetInMemoryConfig_ImmediatelyReturnsEmptyConfig_WhenExternalCacheIsTrulyAsynchronous(string pollingMode) + { + // Arrange + + var pollInterval = TimeSpan.FromSeconds(30); + var timeStamp = ProjectConfig.GenerateTimeStamp(); + var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); + var cachedPcSerialized = ProjectConfig.Serialize(cachedPc); + + var delay = TimeSpan.FromSeconds(1); + + var externalCache = new Mock(); + externalCache + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) + .Returns(async (string _, CancellationToken _) => { await Task.Delay(delay); return cachedPcSerialized; }); + + var logger = this.loggerMock.Object.AsWrapper(); + + var service = CreateConfigService(pollingMode, pollInterval, maxInitWaitTime: TimeSpan.Zero, cacheTimeToLive: pollInterval, + new CacheParameters(new ExternalConfigCache(externalCache.Object, logger), cacheKey: null!), + this.fetcherMock.Object, logger); + + using var _ = service as IDisposable; + + // Act + + var inMemoryConfig = service.GetInMemoryConfig(); + + await Task.Delay(delay + delay); + + var inMemoryConfig2 = service.GetInMemoryConfig(); + + // Assert + + Assert.IsTrue(inMemoryConfig.IsEmpty); + + Assert.IsFalse(inMemoryConfig2.IsEmpty); + Assert.AreEqual(cachedPcSerialized, ProjectConfig.Serialize(inMemoryConfig2)); + } + + [DataTestMethod] + [DataRow(nameof(PollingModes.AutoPoll))] + [DataRow(nameof(PollingModes.LazyLoad))] + [DataRow(nameof(PollingModes.ManualPoll))] + public void GetInMemoryConfig_ImmediatelyReturnsCachedConfig_WhenExternalCacheIsNotTrulyAsynchronous(string pollingMode) + { + // Arrange + + var pollInterval = TimeSpan.FromSeconds(30); + var timeStamp = ProjectConfig.GenerateTimeStamp(); + var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); + var cachedPcSerialized = ProjectConfig.Serialize(cachedPc); + + var externalCache = new Mock(); + externalCache + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(cachedPcSerialized); + + var logger = this.loggerMock.Object.AsWrapper(); + + ProjectConfig.Serialize(cachedPc); + + var service = CreateConfigService(pollingMode, pollInterval, maxInitWaitTime: TimeSpan.Zero, cacheTimeToLive: pollInterval, + new CacheParameters(new ExternalConfigCache(externalCache.Object, logger), cacheKey: null!), + this.fetcherMock.Object, logger); + + using var _ = service as IDisposable; + + // Act + + var inMemoryConfig = service.GetInMemoryConfig(); + + // Assert + + Assert.IsFalse(inMemoryConfig.IsEmpty); + Assert.AreEqual(cachedPcSerialized, ProjectConfig.Serialize(inMemoryConfig)); + } + + private static IConfigService CreateConfigService(string pollingMode, TimeSpan pollInterval, TimeSpan maxInitWaitTime, TimeSpan cacheTimeToLive, CacheParameters cacheParams, + IConfigFetcher configFetcher, LoggerWrapper logger) + { + return pollingMode switch + { + nameof(PollingModes.AutoPoll) => new AutoPollConfigService( + PollingModes.AutoPoll(pollInterval, maxInitWaitTime), + configFetcher, + cacheParams, + logger, + startTimer: false), + + nameof(PollingModes.LazyLoad) => new LazyLoadConfigService( + configFetcher, + cacheParams, + logger, + cacheTimeToLive), + + nameof(PollingModes.ManualPoll) => new ManualPollConfigService( + configFetcher, + cacheParams, + logger), + + _ => throw new InvalidOperationException(), + }; + } } diff --git a/src/ConfigCat.Client.Tests/DeserializerTests.cs b/src/ConfigCat.Client.Tests/DeserializerTests.cs index 3205f8ee..8d64ded2 100644 --- a/src/ConfigCat.Client.Tests/DeserializerTests.cs +++ b/src/ConfigCat.Client.Tests/DeserializerTests.cs @@ -19,7 +19,8 @@ public void Ensure_Global_Settings_Doesnt_Interfere() return settings; }; - Assert.IsNotNull("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".DeserializeOrDefault()); + var config = Config.Deserialize("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".AsMemory(), tolerant: false); + Assert.IsNotNull(config); } [DataRow(false)] diff --git a/src/ConfigCat.Client.Tests/EvaluationLogTests.cs b/src/ConfigCat.Client.Tests/EvaluationLogTests.cs index 87852101..f48fde20 100644 --- a/src/ConfigCat.Client.Tests/EvaluationLogTests.cs +++ b/src/ConfigCat.Client.Tests/EvaluationLogTests.cs @@ -180,7 +180,7 @@ public void ListTruncationTests(string testSetName, string? sdkKey, string? base { var filePath = Path.Combine(TestDataRootPath, testSetName + ".json"); var fileContent = File.ReadAllText(filePath); - var testSet = SerializationExtensions.Deserialize(fileContent); + var testSet = fileContent.AsMemory().Deserialize(); foreach (var testCase in testSet!.tests ?? ArrayUtils.EmptyArray()) { @@ -200,10 +200,10 @@ public void ListTruncationTests(string testSetName, string? sdkKey, string? base private static void RunTest(string testSetName, string? sdkKey, string? baseUrlOrOverrideFileName, string key, string? defaultValue, string? userObject, string? expectedReturnValue, string expectedLogFileName) { - var defaultValueParsed = defaultValue?.Deserialize()!.ToSettingValue(out var settingType).GetValue(); - var expectedReturnValueParsed = expectedReturnValue?.Deserialize()!.ToSettingValue(out _).GetValue(); + var defaultValueParsed = defaultValue?.AsMemory().Deserialize()!.ToSettingValue(out var settingType).GetValue(); + var expectedReturnValueParsed = expectedReturnValue?.AsMemory().Deserialize()!.ToSettingValue(out _).GetValue(); - var userObjectParsed = userObject?.Deserialize?>(); + var userObjectParsed = userObject?.AsMemory().Deserialize?>(); User? user; if (userObjectParsed is not null) { diff --git a/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs b/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs new file mode 100644 index 00000000..e05d173e --- /dev/null +++ b/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ConfigCat.Client.Tests.Fakes; + +internal sealed class FakeExternalCache : IConfigCatCache +{ + public volatile string? CachedValue = null; + + public string? Get(string key) => this.CachedValue; + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); + + public void Set(string key, string value) => this.CachedValue = value; + + public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) + { + Set(key, value); + return Task.FromResult(0); + } +} + +internal sealed class FaultyFakeExternalCache : IConfigCatCache +{ + public string? Get(string key) => throw new ApplicationException("Operation failed :("); + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); + + public void Set(string key, string value) => throw new ApplicationException("Operation failed :("); + + public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) + { + Set(key, value); + return Task.FromResult(0); + } +} diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs index d01c2df9..00b4a0a5 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs @@ -8,7 +8,7 @@ internal static class ConfigHelper { public static ProjectConfig FromString(string configJson, string? httpETag, DateTime timeStamp) { - return new ProjectConfig(configJson, configJson.Deserialize(), timeStamp, httpETag); + return new ProjectConfig(configJson, Config.Deserialize(configJson.AsMemory()), timeStamp, httpETag); } public static ProjectConfig FromFile(string configJsonFilePath, string? httpETag, DateTime timeStamp) diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs index ef6bd60f..d4fd8c8f 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs @@ -22,7 +22,7 @@ internal override Config FetchConfig() using Stream stream = File.OpenRead(FilePath); using StreamReader reader = new(stream); var configJson = reader.ReadToEnd(); - return configJson.Deserialize() ?? throw new InvalidOperationException("Invalid config JSON content: " + configJson); + return Config.Deserialize(configJson.AsMemory()); } } } diff --git a/src/ConfigCat.Client.Tests/OverrideTests.cs b/src/ConfigCat.Client.Tests/OverrideTests.cs index 0dbd4369..5b43b7c6 100644 --- a/src/ConfigCat.Client.Tests/OverrideTests.cs +++ b/src/ConfigCat.Client.Tests/OverrideTests.cs @@ -647,9 +647,9 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s const string key = "flag"; var overrideValue = #if USE_NEWTONSOFT_JSON - overrideValueJson.Deserialize()!; + overrideValueJson.AsMemory().Deserialize(); #else - overrideValueJson.Deserialize(); + overrideValueJson.AsMemory().Deserialize(); #endif var filePath = Path.GetTempFileName(); diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index d27563c0..d2fa305a 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -23,8 +23,7 @@ public sealed class ConfigCatClient : IConfigCatClient internal static readonly ConfigCatClientCache Instances = new(); private readonly string? sdkKey; // may be null in case of testing - private readonly LoggerWrapper logger; - private readonly IRolloutEvaluator configEvaluator; + private readonly EvaluationServices evaluationServices; private readonly IConfigService configService; private readonly IOverrideDataSource? overrideDataSource; private readonly OverrideBehaviour? overrideBehaviour; @@ -34,6 +33,9 @@ public sealed class ConfigCatClient : IConfigCatClient // which is good enough in these cases. private volatile User? defaultUser; + private LoggerWrapper Logger => this.evaluationServices.Logger; + private IRolloutEvaluator ConfigEvaluator => this.evaluationServices.Evaluator; + private static bool IsValidSdkKey(string sdkKey, bool customBaseUrl) { const string proxyPrefix = "configcat-proxy/"; @@ -68,33 +70,37 @@ internal static string GetCacheKey(string sdkKey) /// public LogLevel LogLevel { - get => this.logger.LogLevel; - set => this.logger.LogLevel = value; + get => Logger.LogLevel; + set => Logger.LogLevel = value; } internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) { this.sdkKey = sdkKey; + this.hooks = options.YieldHooks(); this.hooks.SetSender(this); - // To avoid possible memory leaks, the components of the client should not hold a strong reference to the hooks object (see also SafeHooksWrapper). + // To avoid possible memory leaks, the components of the client or client snapshots should not + // hold a strong reference to the hooks object (see also SafeHooksWrapper). var hooksWrapper = new SafeHooksWrapper(this.hooks); - this.logger = new LoggerWrapper(options.Logger ?? ConfigCatClientOptions.CreateDefaultLogger(), hooksWrapper); - this.configEvaluator = new RolloutEvaluator(this.logger); + var logger = new LoggerWrapper(options.Logger ?? ConfigCatClientOptions.CreateDefaultLogger(), hooksWrapper); + var evaluator = new RolloutEvaluator(logger); + + this.evaluationServices = new EvaluationServices(evaluator, hooksWrapper, logger); var cacheParameters = new CacheParameters ( configCache: options.ConfigCache is not null - ? new ExternalConfigCache(options.ConfigCache, this.logger) + ? new ExternalConfigCache(options.ConfigCache, logger) : ConfigCatClientOptions.CreateDefaultConfigCache(), cacheKey: GetCacheKey(sdkKey) ); if (options.FlagOverrides is not null) { - this.overrideDataSource = options.FlagOverrides.BuildDataSource(this.logger); + this.overrideDataSource = options.FlagOverrides.BuildDataSource(logger); this.overrideBehaviour = options.FlagOverrides.OverrideBehaviour; } @@ -106,27 +112,31 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) ? DetermineConfigService(pollingMode, new HttpConfigFetcher(options.CreateUri(sdkKey), GetProductVersion(pollingMode), - this.logger, + logger, options.HttpClientHandler, options.IsCustomBaseUrl, options.HttpTimeout), cacheParameters, - this.logger, + logger, options.Offline, hooksWrapper) - : new NullConfigService(this.logger, hooksWrapper); + : new NullConfigService(logger, hooksWrapper); } // For test purposes only - internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, IRolloutEvaluator evaluator, Hooks? hooks = null) + internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, IRolloutEvaluator evaluator, + OverrideBehaviour? overrideBehaviour = null, IOverrideDataSource? overrideDataSource = null, Hooks? hooks = null) { this.hooks = hooks ?? NullHooks.Instance; this.hooks.SetSender(this); var hooksWrapper = new SafeHooksWrapper(this.hooks); + this.evaluationServices = new EvaluationServices(evaluator, hooksWrapper, new LoggerWrapper(logger, hooks)); + this.configService = configService; - this.logger = new LoggerWrapper(logger, hooksWrapper); - this.configEvaluator = evaluator; + + this.overrideBehaviour = overrideBehaviour; + this.overrideDataSource = overrideDataSource; } /// @@ -166,7 +176,7 @@ public static IConfigCatClient Get(string sdkKey, Action if (instanceAlreadyCreated && configurationAction is not null) { - instance.logger.ClientIsAlreadyCreated(sdkKey); + instance.Logger.ClientIsAlreadyCreated(sdkKey); } return instance; @@ -255,6 +265,7 @@ public static void DisposeAll() } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public T GetValue(string key, T defaultValue, User? user = null) { if (key is null) @@ -276,12 +287,12 @@ public T GetValue(string key, T defaultValue, User? user = null) try { settings = GetSettings(); - evaluationDetails = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.logger); + evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); value = evaluationDetails.Value; } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetValue), key, nameof(defaultValue), defaultValue, ex); + Logger.SettingEvaluationError(nameof(GetValue), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); value = defaultValue; @@ -313,7 +324,7 @@ public async Task GetValueAsync(string key, T defaultValue, User? user = n try { settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - evaluationDetails = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.logger); + evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); value = evaluationDetails.Value; } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) @@ -322,7 +333,7 @@ public async Task GetValueAsync(string key, T defaultValue, User? user = n } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetValueAsync), key, nameof(defaultValue), defaultValue, ex); + Logger.SettingEvaluationError(nameof(GetValueAsync), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); value = defaultValue; @@ -333,6 +344,7 @@ public async Task GetValueAsync(string key, T defaultValue, User? user = n } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null) { if (key is null) @@ -353,11 +365,11 @@ public EvaluationDetails GetValueDetails(string key, T defaultValue, User? try { settings = GetSettings(); - evaluationDetails = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.logger); + evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetValueDetails), key, nameof(defaultValue), defaultValue, ex); + Logger.SettingEvaluationError(nameof(GetValueDetails), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); } @@ -387,7 +399,7 @@ public async Task> GetValueDetailsAsync(string key, T de try { settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - evaluationDetails = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.logger); + evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) { @@ -395,7 +407,7 @@ public async Task> GetValueDetailsAsync(string key, T de } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetValueDetailsAsync), key, nameof(defaultValue), defaultValue, ex); + Logger.SettingEvaluationError(nameof(GetValueDetailsAsync), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); } @@ -405,13 +417,14 @@ public async Task> GetValueDetailsAsync(string key, T de } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public IReadOnlyCollection GetAllKeys() { const string defaultReturnValue = "empty collection"; try { var settings = GetSettings(); - if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, this.logger, defaultReturnValue)) + if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); } @@ -419,7 +432,7 @@ public IReadOnlyCollection GetAllKeys() } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllKeys), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllKeys), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } } @@ -431,7 +444,7 @@ public async Task> GetAllKeysAsync(CancellationToken try { var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, this.logger, defaultReturnValue)) + if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); } @@ -443,12 +456,13 @@ public async Task> GetAllKeysAsync(CancellationToken } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllKeysAsync), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllKeysAsync), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public IReadOnlyDictionary GetAllValues(User? user = null) { const string defaultReturnValue = "empty dictionary"; @@ -459,18 +473,18 @@ public async Task> GetAllKeysAsync(CancellationToken try { var settings = GetSettings(); - evaluationDetailsArray = this.configEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, this.logger, defaultReturnValue, out evaluationExceptions); + evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); result = evaluationDetailsArray.ToDictionary(details => details.Key, details => details.Value); } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllValues), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllValues), defaultReturnValue, ex); return new Dictionary(); } if (evaluationExceptions is { Count: > 0 }) { - this.logger.SettingEvaluationError(nameof(GetAllValues), "evaluation result", new AggregateException(evaluationExceptions)); + Logger.SettingEvaluationError(nameof(GetAllValues), "evaluation result", new AggregateException(evaluationExceptions)); } foreach (var evaluationDetails in evaluationDetailsArray) @@ -492,7 +506,7 @@ public async Task> GetAllKeysAsync(CancellationToken try { var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - evaluationDetailsArray = this.configEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, this.logger, defaultReturnValue, out evaluationExceptions); + evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); result = evaluationDetailsArray.ToDictionary(details => details.Key, details => details.Value); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) @@ -501,13 +515,13 @@ public async Task> GetAllKeysAsync(CancellationToken } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllValuesAsync), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllValuesAsync), defaultReturnValue, ex); return new Dictionary(); } if (evaluationExceptions is { Count: > 0 }) { - this.logger.SettingEvaluationError(nameof(GetAllValuesAsync), "evaluation result", new AggregateException(evaluationExceptions)); + Logger.SettingEvaluationError(nameof(GetAllValuesAsync), "evaluation result", new AggregateException(evaluationExceptions)); } foreach (var evaluationDetails in evaluationDetailsArray) @@ -519,6 +533,7 @@ public async Task> GetAllKeysAsync(CancellationToken } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public IReadOnlyList GetAllValueDetails(User? user = null) { const string defaultReturnValue = "empty list"; @@ -528,17 +543,17 @@ public IReadOnlyList GetAllValueDetails(User? user = null) try { var settings = GetSettings(); - evaluationDetailsArray = this.configEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, this.logger, defaultReturnValue, out evaluationExceptions); + evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllValueDetails), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllValueDetails), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } if (evaluationExceptions is { Count: > 0 }) { - this.logger.SettingEvaluationError(nameof(GetAllValueDetails), "evaluation result", new AggregateException(evaluationExceptions)); + Logger.SettingEvaluationError(nameof(GetAllValueDetails), "evaluation result", new AggregateException(evaluationExceptions)); } foreach (var evaluationDetails in evaluationDetailsArray) @@ -559,7 +574,7 @@ public async Task> GetAllValueDetailsAsync(User try { var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - evaluationDetailsArray = this.configEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, this.logger, defaultReturnValue, out evaluationExceptions); + evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) { @@ -567,13 +582,13 @@ public async Task> GetAllValueDetailsAsync(User } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllValueDetailsAsync), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllValueDetailsAsync), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } if (evaluationExceptions is { Count: > 0 }) { - this.logger.SettingEvaluationError(nameof(GetAllValueDetailsAsync), "evaluation result", new AggregateException(evaluationExceptions)); + Logger.SettingEvaluationError(nameof(GetAllValueDetailsAsync), "evaluation result", new AggregateException(evaluationExceptions)); } foreach (var evaluationDetails in evaluationDetailsArray) @@ -585,6 +600,7 @@ public async Task> GetAllValueDetailsAsync(User } /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public RefreshResult ForceRefresh() { try @@ -593,7 +609,7 @@ public RefreshResult ForceRefresh() } catch (Exception ex) { - this.logger.ForceRefreshError(nameof(ForceRefresh), ex); + Logger.ForceRefreshError(nameof(ForceRefresh), ex); return RefreshResult.Failure(RefreshErrorCode.UnexpectedError, ex.Message, ex); } } @@ -611,36 +627,38 @@ public async Task ForceRefreshAsync(CancellationToken cancellatio } catch (Exception ex) { - this.logger.ForceRefreshError(nameof(ForceRefreshAsync), ex); + Logger.ForceRefreshError(nameof(ForceRefreshAsync), ex); return RefreshResult.Failure(RefreshErrorCode.UnexpectedError, ex.Message, ex); } } - private SettingsWithRemoteConfig GetSettings() + private SettingsWithRemoteConfig GetSettings(bool syncWithExternalCache = true) { Dictionary local; SettingsWithRemoteConfig remote; switch (this.overrideBehaviour) { case null: - return GetRemoteConfig(); + return GetRemoteConfig(syncWithExternalCache); case OverrideBehaviour.LocalOnly: return new SettingsWithRemoteConfig(this.overrideDataSource!.GetOverrides(), remoteConfig: null); case OverrideBehaviour.LocalOverRemote: local = this.overrideDataSource!.GetOverrides(); - remote = GetRemoteConfig(); + remote = GetRemoteConfig(syncWithExternalCache); return new SettingsWithRemoteConfig(remote.Value.MergeOverwriteWith(local), remote.RemoteConfig); case OverrideBehaviour.RemoteOverLocal: local = this.overrideDataSource!.GetOverrides(); - remote = GetRemoteConfig(); + remote = GetRemoteConfig(syncWithExternalCache); return new SettingsWithRemoteConfig(local.MergeOverwriteWith(remote.Value), remote.RemoteConfig); default: throw new InvalidOperationException(); // execution should never get here } - SettingsWithRemoteConfig GetRemoteConfig() + SettingsWithRemoteConfig GetRemoteConfig(bool syncWithExternalCache = true) { - var config = this.configService.GetConfig(); + var config = syncWithExternalCache + ? this.configService.GetConfig() + : this.configService.GetInMemoryConfig(); var settings = !config.IsEmpty ? config.Config.Settings : null; return new SettingsWithRemoteConfig(settings, config); } @@ -709,6 +727,20 @@ public void SetDefaultUser(User user) this.defaultUser = user ?? throw new ArgumentNullException(nameof(user)); } + /// + public Task WaitForReadyAsync(CancellationToken cancellationToken = default) + { + return this.configService.ReadyTask.WaitAsync(cancellationToken); + } + + /// + public ConfigCatClientSnapshot Snapshot() + { + var settings = GetSettings(syncWithExternalCache: false); + var cacheState = this.configService.GetCacheState(settings.RemoteConfig ?? ProjectConfig.Empty); + return new ConfigCatClientSnapshot(this.evaluationServices, settings, this.defaultUser, cacheState); + } + /// public void ClearDefaultUser() { @@ -731,7 +763,7 @@ public void SetOffline() } /// - public event EventHandler? ClientReady + public event EventHandler? ClientReady { add { this.hooks.ClientReady += value; } remove { this.hooks.ClientReady -= value; } @@ -765,7 +797,7 @@ public event EventHandler? Error remove { this.hooks.Error -= value; } } - private readonly struct SettingsWithRemoteConfig + internal readonly struct SettingsWithRemoteConfig { public SettingsWithRemoteConfig(Dictionary? value, ProjectConfig? remoteConfig) { @@ -776,4 +808,18 @@ public SettingsWithRemoteConfig(Dictionary? value, ProjectConfi public Dictionary? Value { get; } public ProjectConfig? RemoteConfig { get; } } + + internal sealed class EvaluationServices + { + public EvaluationServices(IRolloutEvaluator evaluator, SafeHooksWrapper hooks, LoggerWrapper logger) + { + this.Evaluator = evaluator; + this.Hooks = hooks; + this.Logger = logger; + } + + public readonly IRolloutEvaluator Evaluator; + public readonly SafeHooksWrapper Hooks; + public readonly LoggerWrapper Logger; + } } diff --git a/src/ConfigCatClient/ConfigCatClientSnapshot.cs b/src/ConfigCatClient/ConfigCatClientSnapshot.cs new file mode 100644 index 00000000..f546cb31 --- /dev/null +++ b/src/ConfigCatClient/ConfigCatClientSnapshot.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Utils; + +using static ConfigCat.Client.ConfigCatClient; + +namespace ConfigCat.Client; + +/// +/// Represents the state of captured at a specific point in time. +/// +public readonly struct ConfigCatClientSnapshot : IConfigCatClientSnapshot +{ + private readonly object? evaluationServicesOrFakeImpl; // an instance of either EvaluationServices or IConfigCatClientSnapshot + private readonly SettingsWithRemoteConfig settings; + private readonly User? defaultUser; + private readonly ClientCacheState cacheState; + + internal ConfigCatClientSnapshot(EvaluationServices evaluationServices, SettingsWithRemoteConfig settings, User? defaultUser, ClientCacheState cacheState) + { + this.evaluationServicesOrFakeImpl = evaluationServices; + this.settings = settings; + this.defaultUser = defaultUser; + this.cacheState = cacheState; + } + + /// + /// For testing purposes. This constructor allows you to create an instance + /// which will use the fake implementation you provide instead of executing the built-in logic. + /// + public ConfigCatClientSnapshot(IConfigCatClientSnapshot impl) + { + this.evaluationServicesOrFakeImpl = impl; + this.settings = default; + this.defaultUser = default; + this.cacheState = default; + } + + /// > + public ClientCacheState CacheState => this.evaluationServicesOrFakeImpl is EvaluationServices + ? this.cacheState + : ((IConfigCatClientSnapshot?)this.evaluationServicesOrFakeImpl)?.CacheState ?? ClientCacheState.NoFlagData; + + /// > + public IConfig? FetchedConfig => this.evaluationServicesOrFakeImpl is EvaluationServices + ? this.settings.RemoteConfig?.Config + : ((IConfigCatClientSnapshot?)this.evaluationServicesOrFakeImpl)?.FetchedConfig ?? null; + + /// > + public IReadOnlyCollection GetAllKeys() + { + if (this.evaluationServicesOrFakeImpl is not EvaluationServices) + { + return this.evaluationServicesOrFakeImpl is not null + ? ((IConfigCatClientSnapshot)this.evaluationServicesOrFakeImpl).GetAllKeys() + : ArrayUtils.EmptyArray(); + } + + return this.settings.Value is { } settings ? settings.ReadOnlyKeys() : ArrayUtils.EmptyArray(); + } + + /// > + public T GetValue(string key, T defaultValue, User? user = null) + { + if (this.evaluationServicesOrFakeImpl is not EvaluationServices evaluationServices) + { + return this.evaluationServicesOrFakeImpl is not null + ? ((IConfigCatClientSnapshot)this.evaluationServicesOrFakeImpl).GetValue(key, defaultValue, user) + : defaultValue; + } + + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (key.Length == 0) + { + throw new ArgumentException("Key cannot be empty.", nameof(key)); + } + + typeof(T).EnsureSupportedSettingClrType(nameof(T)); + + T value; + EvaluationDetails evaluationDetails; + SettingsWithRemoteConfig settings = default; + user ??= this.defaultUser; + try + { + settings = this.settings; + evaluationDetails = evaluationServices.Evaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, evaluationServices.Logger); + value = evaluationDetails.Value; + } + catch (Exception ex) + { + evaluationServices.Logger.SettingEvaluationError($"{nameof(ConfigCatClientSnapshot)}.{nameof(GetValue)}", key, nameof(defaultValue), defaultValue, ex); + evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex); + value = defaultValue; + } + + evaluationServices.Hooks.RaiseFlagEvaluated(evaluationDetails); + return value; + } + + /// > + public EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null) + { + if (this.evaluationServicesOrFakeImpl is not EvaluationServices evaluationServices) + { + return this.evaluationServicesOrFakeImpl is not null + ? ((IConfigCatClientSnapshot)this.evaluationServicesOrFakeImpl).GetValueDetails(key, defaultValue, user) + : EvaluationDetails.FromDefaultValue(key, defaultValue, null, user, $"{nameof(GetValueDetails)} was called on the default instance of {nameof(ConfigCatClientSnapshot)}."); + } + + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (key.Length == 0) + { + throw new ArgumentException("Key cannot be empty.", nameof(key)); + } + + typeof(T).EnsureSupportedSettingClrType(nameof(T)); + + EvaluationDetails evaluationDetails; + SettingsWithRemoteConfig settings = default; + user ??= this.defaultUser; + try + { + settings = this.settings; + evaluationDetails = evaluationServices.Evaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, evaluationServices.Logger); + } + catch (Exception ex) + { + evaluationServices.Logger.SettingEvaluationError($"{nameof(ConfigCatClientSnapshot)}.{nameof(GetValueDetails)}", key, nameof(defaultValue), defaultValue, ex); + evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex); + } + + evaluationServices.Hooks.RaiseFlagEvaluated(evaluationDetails); + return evaluationDetails; + } +} diff --git a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs index 0214a3f6..aeb3b19b 100644 --- a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs @@ -10,8 +10,8 @@ internal sealed class AutoPollConfigService : ConfigServiceBase, IConfigService { private readonly TimeSpan pollInterval; private readonly TimeSpan maxInitWaitTime; - private readonly CancellationTokenSource initializationCancellationTokenSource = new(); // used for signalling initialization - private CancellationTokenSource? timerCancellationTokenSource = new(); // used for signalling background work to stop + private readonly CancellationTokenSource initSignalCancellationTokenSource = new(); // used for signalling initialization ready + private CancellationTokenSource timerCancellationTokenSource = new(); // used for signalling background work to stop internal AutoPollConfigService( AutoPoll options, @@ -35,31 +35,38 @@ internal AutoPollConfigService( this.pollInterval = options.PollInterval; this.maxInitWaitTime = options.MaxInitWaitTime >= TimeSpan.Zero ? options.MaxInitWaitTime : Timeout.InfiniteTimeSpan; - this.initializationCancellationTokenSource.Token.Register(this.Hooks.RaiseClientReady, useSynchronizationContext: false); - if (options.MaxInitWaitTime > TimeSpan.Zero) - { - this.initializationCancellationTokenSource.CancelAfter(options.MaxInitWaitTime); - } - else if (options.MaxInitWaitTime == TimeSpan.Zero) + var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken); + + // This task will complete when either initalization ready is signalled by cancelling initializationCancellationTokenSource or maxInitWaitTime passes. + // If the service gets disposed before any of these events happen, the task will also complete, but with a canceled status. + InitializationTask = WaitForInitializationAsync(WaitForReadyCancellationToken); + + ReadyTask = GetReadyTask(InitializationTask, async initializationTask => { - this.initializationCancellationTokenSource.Cancel(); - } + // In Auto Polling mode, maxInitWaitTime takes precedence over waiting for initial cache sync-up, that is, + // ClientReady is always raised after maxInitWaitTime has passed, regardless of whether initial cache sync-up has finished or not. + await initializationTask.ConfigureAwait(false); + return GetCacheState(this.ConfigCache.LocalCachedConfig); + }); if (!isOffline && startTimer) { - StartScheduler(); + StartScheduler(initialCacheSyncUpTask, this.timerCancellationTokenSource.Token); } } + internal Task InitializationTask { get; } + + public Task ReadyTask { get; } + protected override void DisposeSynchronized(bool disposing) { // Background work should stop under all circumstances - this.timerCancellationTokenSource!.Cancel(); + this.timerCancellationTokenSource.Cancel(); if (disposing) { this.timerCancellationTokenSource.Dispose(); - this.timerCancellationTokenSource = null; } base.DisposeSynchronized(disposing); @@ -69,19 +76,23 @@ protected override void Dispose(bool disposing) { if (disposing) { - this.initializationCancellationTokenSource.Dispose(); + this.initSignalCancellationTokenSource.Dispose(); } base.Dispose(disposing); } - private bool IsInitialized => this.initializationCancellationTokenSource.IsCancellationRequested; + private bool IsInitialized => InitializationTask.Status == TaskStatus.RanToCompletion; private void SignalInitialization() { try { - this.initializationCancellationTokenSource.Cancel(); + if (!this.initSignalCancellationTokenSource.IsCancellationRequested) + { + this.initSignalCancellationTokenSource.Cancel(); + this.initSignalCancellationTokenSource.Dispose(); + } } catch (ObjectDisposedException) { @@ -92,18 +103,11 @@ private void SignalInitialization() } } - internal bool WaitForInitialization() - { - // An infinite timeout would also work but we limit waiting to MaxInitWaitTime for maximum safety. - return this.initializationCancellationTokenSource.Token.WaitHandle.WaitOne(this.maxInitWaitTime); - } - - internal async Task WaitForInitializationAsync(CancellationToken cancellationToken = default) + private async Task WaitForInitializationAsync(CancellationToken cancellationToken = default) { try { - // An infinite timeout would also work but we limit waiting to MaxInitWaitTime for maximum safety. - await Task.Delay(this.maxInitWaitTime, this.initializationCancellationTokenSource.Token) + await Task.Delay(this.maxInitWaitTime, this.initSignalCancellationTokenSource.Token) .WaitAsync(cancellationToken).ConfigureAwait(false); return false; @@ -124,7 +128,9 @@ public ProjectConfig GetConfig() return cachedConfig; } - WaitForInitialization(); + // NOTE: We go sync over async here, however it's safe to do that in this case as + // the task will be completed on a thread pool thread (either by the polling loop or a timer callback). + InitializationTask.GetAwaiter().GetResult(); } return this.ConfigCache.Get(base.CacheKey); @@ -140,7 +146,7 @@ public async ValueTask GetConfigAsync(CancellationToken cancellat return cachedConfig; } - await WaitForInitializationAsync(cancellationToken).ConfigureAwait(false); + await InitializationTask.WaitAsync(cancellationToken).ConfigureAwait(false); } return await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); @@ -154,31 +160,30 @@ protected override void OnConfigFetched(in FetchResult fetchResult, bool isIniti protected override void SetOnlineCoreSynchronized() { - StartScheduler(); + StartScheduler(null, this.timerCancellationTokenSource.Token); } protected override void SetOfflineCoreSynchronized() { - this.timerCancellationTokenSource!.Cancel(); + this.timerCancellationTokenSource.Cancel(); this.timerCancellationTokenSource.Dispose(); this.timerCancellationTokenSource = new CancellationTokenSource(); } - private void StartScheduler() + private void StartScheduler(Task? initialCacheSyncUpTask, CancellationToken stopToken) { Task.Run(async () => { var isFirstIteration = true; - while (Synchronize(static @this => @this.timerCancellationTokenSource?.Token, this) is { } cancellationToken - && !cancellationToken.IsCancellationRequested) + while (!stopToken.IsCancellationRequested) { try { var scheduledNextTime = DateTime.UtcNow.Add(this.pollInterval); try { - await PollCoreAsync(isFirstIteration, cancellationToken).ConfigureAwait(false); + await PollCoreAsync(isFirstIteration, initialCacheSyncUpTask, stopToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -188,7 +193,7 @@ private void StartScheduler() var realNextTime = scheduledNextTime.Subtract(DateTime.UtcNow); if (realNextTime > TimeSpan.Zero) { - await Task.Delay(realNextTime, cancellationToken).ConfigureAwait(false); + await Task.Delay(realNextTime, stopToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -201,15 +206,19 @@ private void StartScheduler() } isFirstIteration = false; + initialCacheSyncUpTask = null; // allow GC to collect the task and its result } - }); + }, stopToken); } - private async ValueTask PollCoreAsync(bool isFirstIteration, CancellationToken cancellationToken) + private async ValueTask PollCoreAsync(bool isFirstIteration, Task? initialCacheSyncUpTask, CancellationToken cancellationToken) { if (isFirstIteration) { - var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + var latestConfig = initialCacheSyncUpTask is not null + ? await initialCacheSyncUpTask.WaitAsync(cancellationToken).ConfigureAwait(false) + : await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + if (latestConfig.IsExpired(expiration: this.pollInterval)) { if (!IsOffline) @@ -232,12 +241,18 @@ private async ValueTask PollCoreAsync(bool isFirstIteration, CancellationToken c } } - internal void StopScheduler() + public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) { - Synchronize(static @this => + if (cachedConfig.IsEmpty) { - @this.timerCancellationTokenSource?.Cancel(); - return default(object); - }, this); + return ClientCacheState.NoFlagData; + } + + if (cachedConfig.IsExpired(this.pollInterval)) + { + return ClientCacheState.HasCachedFlagDataOnly; + } + + return ClientCacheState.HasUpToDateFlagData; } } diff --git a/src/ConfigCatClient/ConfigService/ClientCacheState.cs b/src/ConfigCatClient/ConfigService/ClientCacheState.cs new file mode 100644 index 00000000..06c8e8ee --- /dev/null +++ b/src/ConfigCatClient/ConfigService/ClientCacheState.cs @@ -0,0 +1,27 @@ +namespace ConfigCat.Client; + +/// +/// Specifies the possible states of the local cache. +/// +public enum ClientCacheState +{ + /// + /// No feature flag data is available in the local cache. + /// + NoFlagData, + + /// + /// Feature flag data provided by local flag override is only available in the local cache. + /// + HasLocalOverrideFlagDataOnly, + + /// + /// Out-of-date feature flag data downloaded from the remote server is available in the local cache. + /// + HasCachedFlagDataOnly, + + /// + /// Up-to-date feature flag data downloaded from the remote server is available in the local cache. + /// + HasUpToDateFlagData, +} diff --git a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs index 7afb130b..2746cbad 100644 --- a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs +++ b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs @@ -29,6 +29,9 @@ protected internal enum Status protected readonly string CacheKey; protected readonly SafeHooksWrapper Hooks; + private CancellationTokenSource? waitForReadyCancellationTokenSource; + protected CancellationToken WaitForReadyCancellationToken => this.waitForReadyCancellationTokenSource?.Token ?? new CancellationToken(canceled: true); + protected ConfigServiceBase(IConfigFetcher configFetcher, CacheParameters cacheParameters, LoggerWrapper logger, bool isOffline, SafeHooksWrapper hooks) { this.ConfigFetcher = configFetcher; @@ -37,12 +40,23 @@ protected ConfigServiceBase(IConfigFetcher configFetcher, CacheParameters cacheP this.Logger = logger; this.Hooks = hooks; this.status = isOffline ? Status.Offline : Status.Online; + this.waitForReadyCancellationTokenSource = new CancellationTokenSource(); } /// /// Note for inheritors. Beware, this method is called within a lock statement. /// - protected virtual void DisposeSynchronized(bool disposing) { } + protected virtual void DisposeSynchronized(bool disposing) + { + // If waiting for ready state is still in progress, it should stop. + this.waitForReadyCancellationTokenSource?.Cancel(); + + if (disposing) + { + this.waitForReadyCancellationTokenSource?.Dispose(); + this.waitForReadyCancellationTokenSource = null; + } + } protected virtual void Dispose(bool disposing) { @@ -69,6 +83,8 @@ public void Dispose() Dispose(true); } + public ProjectConfig GetInMemoryConfig() => this.ConfigCache.LocalCachedConfig; + public virtual RefreshResult RefreshConfig() { if (!IsOffline) @@ -218,11 +234,28 @@ public void SetOffline() logAction?.Invoke(this.Logger); } - protected TResult Synchronize(Func func, TState state) + public abstract ClientCacheState GetCacheState(ProjectConfig cachedConfig); + + protected Task SyncUpWithCacheAsync(CancellationToken cancellationToken) { - lock (this.syncObj) + return this.ConfigCache.GetAsync(this.CacheKey, cancellationToken).AsTask(); + } + + protected async Task GetReadyTask(TState state, Func> waitForReadyAsync) + { + ClientCacheState cacheState; + try { cacheState = await waitForReadyAsync(state).ConfigureAwait(false); } + finally { - return func(state); + lock (this.syncObj) + { + this.waitForReadyCancellationTokenSource?.Dispose(); + this.waitForReadyCancellationTokenSource = null; + } } + + this.Hooks.RaiseClientReady(cacheState); + + return cacheState; } } diff --git a/src/ConfigCatClient/ConfigService/IConfigService.cs b/src/ConfigCatClient/ConfigService/IConfigService.cs index 7e7c6265..1844b314 100644 --- a/src/ConfigCatClient/ConfigService/IConfigService.cs +++ b/src/ConfigCatClient/ConfigService/IConfigService.cs @@ -5,6 +5,10 @@ namespace ConfigCat.Client.ConfigService; internal interface IConfigService { + Task ReadyTask { get; } + + ProjectConfig GetInMemoryConfig(); + ProjectConfig GetConfig(); ValueTask GetConfigAsync(CancellationToken cancellationToken = default); @@ -18,4 +22,6 @@ internal interface IConfigService void SetOnline(); void SetOffline(); + + ClientCacheState GetCacheState(ProjectConfig config); } diff --git a/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs b/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs index b07fba05..d74a39c5 100644 --- a/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs +++ b/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs @@ -14,9 +14,12 @@ internal LazyLoadConfigService(IConfigFetcher configFetcher, CacheParameters cac { this.cacheTimeToLive = cacheTimeToLive; - hooks.RaiseClientReady(); + var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken); + ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(false))); } + public Task ReadyTask { get; } + public ProjectConfig GetConfig() { var cachedConfig = this.ConfigCache.Get(base.CacheKey); @@ -63,4 +66,19 @@ private void OnConfigExpired() { this.Logger.Debug("config expired"); } + + public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) + { + if (cachedConfig.IsEmpty) + { + return ClientCacheState.NoFlagData; + } + + if (cachedConfig.IsExpired(this.cacheTimeToLive)) + { + return ClientCacheState.HasCachedFlagDataOnly; + } + + return ClientCacheState.HasUpToDateFlagData; + } } diff --git a/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs b/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs index 25057295..1dcb816e 100644 --- a/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs @@ -9,9 +9,12 @@ internal sealed class ManualPollConfigService : ConfigServiceBase, IConfigServic internal ManualPollConfigService(IConfigFetcher configFetcher, CacheParameters cacheParameters, LoggerWrapper logger, bool isOffline = false, SafeHooksWrapper hooks = default) : base(configFetcher, cacheParameters, logger, isOffline, hooks) { - hooks.RaiseClientReady(); + var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken); + ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(false))); } + public Task ReadyTask { get; } + public ProjectConfig GetConfig() { return this.ConfigCache.Get(base.CacheKey); @@ -21,4 +24,14 @@ public ValueTask GetConfigAsync(CancellationToken cancellationTok { return this.ConfigCache.GetAsync(base.CacheKey, cancellationToken); } + + public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) + { + if (cachedConfig.IsEmpty) + { + return ClientCacheState.NoFlagData; + } + + return ClientCacheState.HasCachedFlagDataOnly; + } } diff --git a/src/ConfigCatClient/ConfigService/NullConfigService.cs b/src/ConfigCatClient/ConfigService/NullConfigService.cs index 4ae232b0..5344ccce 100644 --- a/src/ConfigCatClient/ConfigService/NullConfigService.cs +++ b/src/ConfigCatClient/ConfigService/NullConfigService.cs @@ -11,9 +11,13 @@ public NullConfigService(LoggerWrapper logger, SafeHooksWrapper hooks = default) { this.logger = logger; - hooks.RaiseClientReady(); + hooks.RaiseClientReady(ClientCacheState.HasLocalOverrideFlagDataOnly); } + public Task ReadyTask => Task.FromResult(ClientCacheState.HasLocalOverrideFlagDataOnly); + + public ProjectConfig GetInMemoryConfig() => ProjectConfig.Empty; + public ProjectConfig GetConfig() => ProjectConfig.Empty; public ValueTask GetConfigAsync(CancellationToken cancellationToken = default) => new ValueTask(ProjectConfig.Empty); @@ -34,4 +38,6 @@ public void SetOnline() { this.logger.ConfigServiceMethodHasNoEffectDueToOverrideBehavior(nameof(OverrideBehaviour.LocalOnly), nameof(SetOnline)); } + + public ClientCacheState GetCacheState(ProjectConfig config) => ClientCacheState.HasLocalOverrideFlagDataOnly; } diff --git a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs index 51b17edb..6911dc10 100644 --- a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs +++ b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs @@ -114,7 +114,7 @@ internal Uri CreateUri(string sdkKey) } /// - public event EventHandler? ClientReady + public event EventHandler? ClientReady { add { this.hooks.ClientReady += value; } remove { this.hooks.ClientReady -= value; } diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index 7e610e99..feaf47d1 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -958,7 +958,7 @@ private double GetUserAttributeValueAsUnixTimeSeconds(string attributeName, obje private string[]? GetUserAttributeValueAsStringArray(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) { if (attributeValue is string[] stringArray - || attributeValue is string json && (stringArray = json.DeserializeOrDefault()!) is not null) + || attributeValue is string json && (stringArray = json.AsMemory().DeserializeOrDefault()!) is not null) { if (!Array.Exists(stringArray, item => item is null)) { diff --git a/src/ConfigCatClient/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs index 645a01e1..b325b0f9 100644 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ b/src/ConfigCatClient/Extensions/SerializationExtensions.cs @@ -23,8 +23,6 @@ internal static class SerializationExtensions }; #endif - public static T? Deserialize(this string json, bool tolerant = false) => json.AsMemory().Deserialize(tolerant); - // NOTE: It would be better to use ReadOnlySpan, however when the full string is wrapped in a span, json.ToString() result in a copy of the string. // This is not the case with ReadOnlyMemory, so we use that until support for .NET 4.5 support is dropped. public static T? Deserialize(this ReadOnlyMemory json, bool tolerant = false) @@ -38,8 +36,6 @@ internal static class SerializationExtensions #endif } - public static T? DeserializeOrDefault(this string json, bool tolerant = false) => json.AsMemory().DeserializeOrDefault(tolerant); - public static T? DeserializeOrDefault(this ReadOnlyMemory json, bool tolerant = false) { try diff --git a/src/ConfigCatClient/Hooks/ClientReadyEventArgs.cs b/src/ConfigCatClient/Hooks/ClientReadyEventArgs.cs new file mode 100644 index 00000000..3c67e175 --- /dev/null +++ b/src/ConfigCatClient/Hooks/ClientReadyEventArgs.cs @@ -0,0 +1,19 @@ +using System; + +namespace ConfigCat.Client; + +/// +/// Provides data for the event. +/// +public class ClientReadyEventArgs : EventArgs +{ + internal ClientReadyEventArgs(ClientCacheState cacheState) + { + CacheState = cacheState; + } + + /// + /// The state of the local cache at the time the initialization was completed. + /// + public ClientCacheState CacheState { get; } +} diff --git a/src/ConfigCatClient/Hooks/Hooks.cs b/src/ConfigCatClient/Hooks/Hooks.cs index 51fae7e2..75b55fcc 100644 --- a/src/ConfigCatClient/Hooks/Hooks.cs +++ b/src/ConfigCatClient/Hooks/Hooks.cs @@ -33,8 +33,8 @@ public virtual void SetSender(IConfigCatClient client) this.client = client; } - public void RaiseClientReady() - => this.events.RaiseClientReady(this.client); + public void RaiseClientReady(ClientCacheState cacheState) + => this.events.RaiseClientReady(this.client, cacheState); public void RaiseFlagEvaluated(EvaluationDetails evaluationDetails) => this.events.RaiseFlagEvaluated(this.client, evaluationDetails); @@ -48,7 +48,7 @@ public void RaiseConfigChanged(IConfig newConfig) public void RaiseError(string message, Exception? exception) => this.events.RaiseError(this.client, message, exception); - public event EventHandler? ClientReady + public event EventHandler? ClientReady { add { this.events.ClientReady += value; } remove { this.events.ClientReady -= value; } @@ -80,13 +80,13 @@ public event EventHandler? Error public class Events : IProvidesHooks { - public virtual void RaiseClientReady(IConfigCatClient? client) { /* intentional no-op */ } + public virtual void RaiseClientReady(IConfigCatClient? client, ClientCacheState cacheState) { /* intentional no-op */ } public virtual void RaiseFlagEvaluated(IConfigCatClient? client, EvaluationDetails evaluationDetails) { /* intentional no-op */ } public virtual void RaiseConfigFetched(IConfigCatClient? client, RefreshResult result, bool isInitiatedByUser) { /* intentional no-op */ } public virtual void RaiseConfigChanged(IConfigCatClient? client, IConfig newConfig) { /* intentional no-op */ } public virtual void RaiseError(IConfigCatClient? client, string message, Exception? exception) { /* intentional no-op */ } - public virtual event EventHandler? ClientReady { add { /* intentional no-op */ } remove { /* intentional no-op */ } } + public virtual event EventHandler? ClientReady { add { /* intentional no-op */ } remove { /* intentional no-op */ } } public virtual event EventHandler? FlagEvaluated { add { /* intentional no-op */ } remove { /* intentional no-op */ } } public virtual event EventHandler? ConfigFetched { add { /* intentional no-op */ } remove { /* intentional no-op */ } } public virtual event EventHandler? ConfigChanged { add { /* intentional no-op */ } remove { /* intentional no-op */ } } @@ -95,9 +95,9 @@ public virtual event EventHandler? Error { add { private sealed class RealEvents : Events { - public override void RaiseClientReady(IConfigCatClient? client) + public override void RaiseClientReady(IConfigCatClient? client, ClientCacheState cacheState) { - ClientReady?.Invoke(client, EventArgs.Empty); + ClientReady?.Invoke(client, new ClientReadyEventArgs(cacheState)); } public override void RaiseFlagEvaluated(IConfigCatClient? client, EvaluationDetails evaluationDetails) @@ -120,7 +120,7 @@ public override void RaiseError(IConfigCatClient? client, string message, Except Error?.Invoke(client, new ConfigCatClientErrorEventArgs(message, exception)); } - public override event EventHandler? ClientReady; + public override event EventHandler? ClientReady; public override event EventHandler? FlagEvaluated; public override event EventHandler? ConfigFetched; public override event EventHandler? ConfigChanged; diff --git a/src/ConfigCatClient/Hooks/IProvidesHooks.cs b/src/ConfigCatClient/Hooks/IProvidesHooks.cs index 2f81b859..9237f785 100644 --- a/src/ConfigCatClient/Hooks/IProvidesHooks.cs +++ b/src/ConfigCatClient/Hooks/IProvidesHooks.cs @@ -10,7 +10,7 @@ public interface IProvidesHooks /// /// Occurs when the client is ready to provide the actual value of feature flags or settings. /// - event EventHandler? ClientReady; + event EventHandler? ClientReady; /// /// Occurs after the value of a feature flag of setting has been evaluated. diff --git a/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs b/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs index b53366ea..e5bc275a 100644 --- a/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs +++ b/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs @@ -21,8 +21,8 @@ public SafeHooksWrapper(Hooks hooks) } [MethodImpl(MethodImplOptions.NoInlining)] - public void RaiseClientReady() - => Hooks.RaiseClientReady(); + public void RaiseClientReady(ClientCacheState cacheState) + => Hooks.RaiseClientReady(cacheState); [MethodImpl(MethodImplOptions.NoInlining)] public void RaiseFlagEvaluated(EvaluationDetails evaluationDetails) diff --git a/src/ConfigCatClient/IConfigCatClient.cs b/src/ConfigCatClient/IConfigCatClient.cs index 47468aa1..410125bc 100644 --- a/src/ConfigCatClient/IConfigCatClient.cs +++ b/src/ConfigCatClient/IConfigCatClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Configuration; namespace ConfigCat.Client; @@ -22,6 +23,11 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// It is important to provide an argument for the parameter, specifically for the generic type parameter, /// that matches the type of the feature flag or setting you are evaluating.
/// Please refer to this table for the corresponding types. + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// /// /// /// The type of the value. Only the following types are allowed: @@ -35,6 +41,7 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// is . /// is an empty string. /// is not an allowed type. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] T GetValue(string key, T defaultValue, User? user = null); /// @@ -68,6 +75,11 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// It is important to provide an argument for the parameter, specifically for the generic type parameter, /// that matches the type of the feature flag or setting you are evaluating.
/// Please refer to this table for the corresponding types. + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// /// /// /// The type of the value. Only the following types are allowed: @@ -81,6 +93,7 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// is . /// is an empty string. /// is not an allowed type. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null); /// @@ -110,7 +123,15 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// /// Returns all setting keys synchronously. /// + /// + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// + /// /// The collection of keys. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] IReadOnlyCollection GetAllKeys(); /// @@ -123,8 +144,16 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// /// Returns the keys and values of all feature flags and settings synchronously. /// + /// + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// + /// /// The User Object to use for evaluating targeting rules and percentage options. /// The dictionary containing the keys and values. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] IReadOnlyDictionary GetAllValues(User? user = null); /// @@ -138,8 +167,16 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// /// Returns the values along with evaluation details of all feature flags and settings synchronously. /// + /// + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// + /// /// The User Object to use for evaluating targeting rules and percentage options. /// The list of values along with evaluation details. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] IReadOnlyList GetAllValueDetails(User? user = null); /// @@ -153,7 +190,15 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// /// Refreshes the locally cached config by fetching the latest version from the remote server synchronously. /// + /// + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is not safe as + /// execution involves I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to either use the async version of the method or call the method on a dedicated background thread. + /// + /// /// The refresh result. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] RefreshResult ForceRefresh(); /// @@ -163,6 +208,27 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// A task that represents the asynchronous operation. The task result contains the refresh result. Task ForceRefreshAsync(CancellationToken cancellationToken = default); + /// + /// Waits for the client to initialize (i.e. to raise the event). + /// + /// A to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the state of the local cache at the time the initialization was completed. + Task WaitForReadyAsync(CancellationToken cancellationToken = default); + + /// + /// Captures the current state of the client. + /// The resulting snapshot can be used to evaluate feature flags and settings based on the captured state synchronously, + /// without any underlying I/O-bound operations, which could block the executing thread for a longer period of time. + /// + /// + /// The operation captures the in-memory stored config data. It does not attempt to update it by contacting the remote server. + /// It does not synchronize with the user-provided custom cache (see ) either.
+ /// Therefore, it is recommended to use snapshots in conjunction with the Auto Polling mode, where the SDK automatically updates the local cache in the background.
+ /// For other polling modes, you'll need to manually initiate a cache refresh by invoking or . + ///
+ /// The snapshot object. + ConfigCatClientSnapshot Snapshot(); + /// /// Sets the default user. /// diff --git a/src/ConfigCatClient/IConfigCatClientSnapshot.cs b/src/ConfigCatClient/IConfigCatClientSnapshot.cs new file mode 100644 index 00000000..1c02557a --- /dev/null +++ b/src/ConfigCatClient/IConfigCatClientSnapshot.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace ConfigCat.Client; + +/// +/// Defines the public interface of the struct. +/// +public interface IConfigCatClientSnapshot +{ + /// + /// The state of the local cache at the time the snapshot was created. + /// + ClientCacheState CacheState { get; } + + /// + /// The latest config which has been fetched from the remote server. + /// + IConfig? FetchedConfig { get; } + + /// + /// Returns the available setting keys. + /// + /// + /// In case the client is configured to use flag override, this will also include the keys provided by the flag override. + /// + /// The collection of keys. + IReadOnlyCollection GetAllKeys(); + + /// + /// Returns the value of a feature flag or setting identified by synchronously, based on the snapshot. + /// + /// + /// It is important to provide an argument for the parameter, specifically for the generic type parameter, + /// that matches the type of the feature flag or setting you are evaluating.
+ /// Please refer to this table for the corresponding types. + ///
+ /// + /// The type of the value. Only the following types are allowed: + /// , , , , and (both nullable and non-nullable).
+ /// The type must correspond to the setting type, otherwise will be returned. + ///
+ /// Key of the feature flag or setting. + /// In case of failure, this value will be returned. + /// The User Object to use for evaluating targeting rules and percentage options. + /// The value of the feature flag or setting. + /// is . + /// is an empty string. + /// is not an allowed type. + T GetValue(string key, T defaultValue, User? user = null); + + /// + /// Returns the value along with evaluation details of a feature flag or setting identified by synchronously, based on the snapshot. + /// + /// + /// It is important to provide an argument for the parameter, specifically for the generic type parameter, + /// that matches the type of the feature flag or setting you are evaluating.
+ /// Please refer to this table for the corresponding types. + ///
+ /// + /// The type of the value. Only the following types are allowed: + /// , , , , and (both nullable and non-nullable).
+ /// The type must correspond to the setting type, otherwise will be returned. + ///
+ /// Key of the feature flag or setting. + /// In case of failure, this value will be returned. + /// The User Object to use for evaluating targeting rules and percentage options. + /// The value along with the details of evaluation of the feature flag or setting. + /// is . + /// is an empty string. + /// is not an allowed type. + EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null); +} diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index bb436e02..45fd9897 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -104,7 +104,7 @@ private async Task ReloadFileAsync(bool isAsync, CancellationToken cancellationT try { var content = File.ReadAllText(this.fullPath); - var simplified = content.DeserializeOrDefault(tolerant: true); + var simplified = content.AsMemory().DeserializeOrDefault(tolerant: true); if (simplified?.Entries is not null) { this.overrideValues = simplified.Entries.ToDictionary(kv => kv.Key, kv => kv.Value.ToSetting());