From 4efb8c685cdd0753b4b48bae820dee2b16b1057e Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Thu, 9 Nov 2023 10:55:38 +0100 Subject: [PATCH] Implement non-blocking synchronous evaluation (snapshot API) --- .../ConfigCacheTests.cs | 2 +- .../ConfigCatClientTests.cs | 4 +- .../ConfigEvaluatorTestsBase.cs | 3 +- .../DeserializerTests.cs | 3 +- .../Helpers/ConfigHelper.cs | 2 +- src/ConfigCat.Client.Tests/OverrideTests.cs | 5 +- src/ConfigCatClient/ConfigCatClient.cs | 152 ++++++++++------- .../ConfigCatClientSnapshot.cs | 159 ++++++++++++++++++ .../ConfigService/AutoPollConfigService.cs | 49 ++++-- .../ConfigService/ClientCacheState.cs | 27 +++ .../ConfigService/ConfigServiceBase.cs | 42 ++++- .../ConfigService/IConfigService.cs | 4 + .../ConfigService/LazyLoadConfigService.cs | 17 +- .../ConfigService/ManualPollConfigService.cs | 12 +- .../ConfigService/NullConfigService.cs | 6 +- .../Configuration/ConfigCatClientOptions.cs | 2 +- .../Extensions/SerializationExtensions.cs | 12 +- .../Hooks/ClientReadyEventArgs.cs | 19 +++ src/ConfigCatClient/Hooks/Hooks.cs | 33 +++- src/ConfigCatClient/Hooks/IProvidesHooks.cs | 2 +- src/ConfigCatClient/Hooks/SafeHooksWrapper.cs | 2 +- src/ConfigCatClient/HttpConfigFetcher.cs | 3 +- src/ConfigCatClient/IConfigCatClient.cs | 22 +++ .../Models/SettingsWithPreferences.cs | 15 ++ .../Override/IOverrideDataSource.cs | 2 +- .../Override/LocalDictionaryDataSource.cs | 2 - .../Override/LocalFileDataSource.cs | 7 +- src/ConfigCatClient/ProjectConfig.cs | 8 +- 28 files changed, 495 insertions(+), 121 deletions(-) create mode 100644 src/ConfigCatClient/ConfigCatClientSnapshot.cs create mode 100644 src/ConfigCatClient/ConfigService/ClientCacheState.cs create mode 100644 src/ConfigCatClient/Hooks/ClientReadyEventArgs.cs diff --git a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs index 8c46a2fc..5e959285 100644 --- a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs @@ -242,7 +242,7 @@ 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, SettingsWithPreferences.Deserialize(configJson.AsMemory()), timeStampDateTime, httpETag); Assert.AreEqual(expectedPayload, ProjectConfig.Serialize(pc)); } diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index b7ccb4b9..57d7a0b7 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -1732,7 +1732,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 handleConfigChanged = (s, e) => configChangedEvents.Add(e); EventHandler handleFlagEvaluated = (s, e) => flagEvaluatedEvents.Add(e); EventHandler handleError = (s, e) => errorEvents.Add(e); @@ -1862,5 +1862,7 @@ public override RefreshResult RefreshConfig() { return RefreshResult.Success(); } + + public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) => ClientCacheState.NoFlagData; } } diff --git a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs index 728188b5..4c0a5db0 100644 --- a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs +++ b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ConfigCat.Client.Tests; @@ -27,7 +28,7 @@ public ConfigEvaluatorTestsBase() { this.configEvaluator = new RolloutEvaluator(this.Logger); - this.config = GetSampleJson().Deserialize()!.Settings; + this.config = SettingsWithPreferences.Deserialize(GetSampleJson().AsMemory()).Settings; } protected virtual void AssertValue(string keyName, string expected, User? user) diff --git a/src/ConfigCat.Client.Tests/DeserializerTests.cs b/src/ConfigCat.Client.Tests/DeserializerTests.cs index ee35789c..3e167f47 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()); + Assert.IsTrue(SettingsWithPreferences.TryDeserialize("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".AsMemory(), out var config)); + Assert.IsNotNull(config); } [DataRow(false)] diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs index dea1fbff..03c15437 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs @@ -7,7 +7,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, SettingsWithPreferences.Deserialize(configJson.AsMemory()), timeStamp, httpETag); } public static ProjectConfig FromFile(string configJsonFilePath, string? httpETag, DateTime timeStamp) diff --git a/src/ConfigCat.Client.Tests/OverrideTests.cs b/src/ConfigCat.Client.Tests/OverrideTests.cs index a6d5df4b..c71db1f4 100644 --- a/src/ConfigCat.Client.Tests/OverrideTests.cs +++ b/src/ConfigCat.Client.Tests/OverrideTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; #if USE_NEWTONSOFT_JSON @@ -559,9 +560,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 0a66cf61..eb93d134 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,36 +33,43 @@ 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; + /// 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; } @@ -75,15 +81,15 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) ? DetermineConfigService(pollingMode, new HttpConfigFetcher(options.CreateUri(sdkKey), $"{pollingMode.Identifier}-{Version}", - 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 @@ -93,9 +99,9 @@ internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, 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; } /// @@ -130,7 +136,7 @@ public static IConfigCatClient Get(string sdkKey, Action if (instanceAlreadyCreated && configurationAction is not null) { - instance.logger.ClientIsAlreadyCreated(sdkKey); + instance.Logger.ClientIsAlreadyCreated(sdkKey); } return instance; @@ -156,26 +162,22 @@ private void Dispose(bool disposing) if (disposing) { - if (this.configService is IDisposable disposable) - { - disposable.Dispose(); - } - - this.overrideDataSource?.Dispose(); + (this.configService as IDisposable)?.Dispose(); + (this.overrideDataSource as IDisposable)?.Dispose(); } else { // Execution gets here when consumer forgets to dispose the client instance. // In this case we need to make sure that background work is stopped, // otherwise it would go on endlessly, that is, we'd end up with a memory leak. - var autoPollConfigService = this.configService as AutoPollConfigService; - var localFileDataSource = this.overrideDataSource as LocalFileDataSource; - if (autoPollConfigService is not null || localFileDataSource is not null) + var configService = this.configService as IDisposable; + var localFileDataSource = this.overrideDataSource as IDisposable; + if (configService is not null || localFileDataSource is not null) { Task.Run(() => { - autoPollConfigService?.StopScheduler(); - localFileDataSource?.StopWatch(); + configService?.Dispose(); + localFileDataSource?.Dispose(); }); } } @@ -244,12 +246,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); value = defaultValue; } @@ -280,7 +282,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) @@ -289,7 +291,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); value = defaultValue; } @@ -319,11 +321,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); } @@ -352,7 +354,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) { @@ -360,7 +362,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); } @@ -375,7 +377,7 @@ public IReadOnlyCollection GetAllKeys() try { var settings = GetSettings(); - if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, this.logger, defaultReturnValue)) + if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); } @@ -383,7 +385,7 @@ public IReadOnlyCollection GetAllKeys() } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllKeys), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllKeys), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } } @@ -395,7 +397,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(); } @@ -407,7 +409,7 @@ public async Task> GetAllKeysAsync(CancellationToken } catch (Exception ex) { - this.logger.SettingEvaluationError(nameof(GetAllKeysAsync), defaultReturnValue, ex); + Logger.SettingEvaluationError(nameof(GetAllKeysAsync), defaultReturnValue, ex); return ArrayUtils.EmptyArray(); } } @@ -423,18 +425,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) @@ -456,7 +458,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) @@ -465,13 +467,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) @@ -492,17 +494,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) @@ -523,7 +525,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) { @@ -531,13 +533,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) @@ -557,7 +559,7 @@ public RefreshResult ForceRefresh() } catch (Exception ex) { - this.logger.ForceRefreshError(nameof(ForceRefresh), ex); + Logger.ForceRefreshError(nameof(ForceRefresh), ex); return RefreshResult.Failure(ex.Message, ex); } } @@ -575,36 +577,38 @@ public async Task ForceRefreshAsync(CancellationToken cancellatio } catch (Exception ex) { - this.logger.ForceRefreshError(nameof(ForceRefreshAsync), ex); + Logger.ForceRefreshError(nameof(ForceRefreshAsync), ex); return RefreshResult.Failure(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); } @@ -679,6 +683,20 @@ public void SetDefaultUser(User user) this.defaultUser = user ?? throw new ArgumentNullException(nameof(user)); } + /// + public Task WaitForReadyAsync(CancellationToken cancellationToken = default) + { + return this.hooks.ClientReadyTask.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() { @@ -701,7 +719,7 @@ public void SetOffline() } /// - public event EventHandler? ClientReady + public event EventHandler? ClientReady { add { this.hooks.ClientReady += value; } remove { this.hooks.ClientReady -= value; } @@ -728,7 +746,7 @@ public event EventHandler? Error remove { this.hooks.Error -= value; } } - private readonly struct SettingsWithRemoteConfig + internal readonly struct SettingsWithRemoteConfig { public SettingsWithRemoteConfig(Dictionary? value, ProjectConfig? remoteConfig) { @@ -739,4 +757,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..9d873c4e --- /dev/null +++ b/src/ConfigCatClient/ConfigCatClientSnapshot.cs @@ -0,0 +1,159 @@ +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 +{ + private readonly EvaluationServices evaluationServices; + private readonly SettingsWithRemoteConfig settings; + private readonly User? defaultUser; + + private LoggerWrapper Logger => this.evaluationServices.Logger; + private IRolloutEvaluator ConfigEvaluator => this.evaluationServices.Evaluator; + private SafeHooksWrapper Hooks => this.evaluationServices.Hooks; + + internal ConfigCatClientSnapshot(EvaluationServices evaluationServices, SettingsWithRemoteConfig settings, User? defaultUser, ClientCacheState cacheState) + { + this.evaluationServices = evaluationServices; + this.settings = settings; + this.defaultUser = defaultUser; + CacheState = cacheState; + } + + /// + /// The state of the local cache at the time the snapshot was created. + /// + public ClientCacheState CacheState { get; } + + /// + /// The latest config which has been fetched from the remote server. + /// + public IConfig? FetchedConfig => this.settings.RemoteConfig?.Config; + + /// + /// 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. + public IReadOnlyCollection GetAllKeys() + { + return this.settings.Value is { } settings ? settings.ReadOnlyKeys() : ArrayUtils.EmptyArray(); + } + + /// + /// 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. + public T GetValue(string key, T defaultValue, User? user = null) + { + 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 = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); + value = evaluationDetails.Value; + } + catch (Exception ex) + { + 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; + } + + Hooks.RaiseFlagEvaluated(evaluationDetails); + return value; + } + + /// + /// 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. + public EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null) + { + 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 = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); + } + catch (Exception ex) + { + Logger.SettingEvaluationError($"{nameof(ConfigCatClientSnapshot)}.{nameof(GetValueDetails)}", key, nameof(defaultValue), defaultValue, ex); + evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, ex.Message, ex); + } + + Hooks.RaiseFlagEvaluated(evaluationDetails); + return evaluationDetails; + } +} diff --git a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs index 899fe22e..ec31a7e6 100644 --- a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs @@ -11,7 +11,7 @@ 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 CancellationTokenSource timerCancellationTokenSource = new(); // used for signalling background work to stop internal AutoPollConfigService( AutoPoll options, @@ -35,7 +35,12 @@ 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); + var initialCacheSyncTask = SyncUpWithCache(suppressRaiseClientReady: true); + + this.initializationCancellationTokenSource.Token.Register( + () => this.Hooks.RaiseClientReady(GetCacheState(this.ConfigCache.LocalCachedConfig)), + useSynchronizationContext: false); + if (options.MaxInitWaitTime > TimeSpan.Zero) { this.initializationCancellationTokenSource.CancelAfter(options.MaxInitWaitTime); @@ -47,19 +52,18 @@ internal AutoPollConfigService( if (!isOffline && startTimer) { - StartScheduler(); + StartScheduler(initialCacheSyncTask, this.timerCancellationTokenSource.Token); } } 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); @@ -154,7 +158,7 @@ protected override void OnConfigFetched(ProjectConfig newConfig) protected override void SetOnlineCoreSynchronized() { - StartScheduler(); + StartScheduler(null, this.timerCancellationTokenSource.Token); } protected override void SetOfflineCoreSynchronized() @@ -164,21 +168,20 @@ protected override void SetOfflineCoreSynchronized() this.timerCancellationTokenSource = new CancellationTokenSource(); } - private void StartScheduler() + private void StartScheduler(Task? initialCacheSyncTask, 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, initialCacheSyncTask, stopToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -188,7 +191,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 +204,19 @@ private void StartScheduler() } isFirstIteration = false; + initialCacheSyncTask = null; // allow GC to collect the task and its result } }); } - private async ValueTask PollCoreAsync(bool isFirstIteration, CancellationToken cancellationToken) + private async ValueTask PollCoreAsync(bool isFirstIteration, Task? initialCacheSyncTask, CancellationToken cancellationToken) { if (isFirstIteration) { - var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + var latestConfig = initialCacheSyncTask is not null + ? await initialCacheSyncTask.WaitAsync(cancellationToken).ConfigureAwait(false) + : await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + if (latestConfig.IsExpired(expiration: this.pollInterval)) { if (!IsOffline) @@ -232,12 +239,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 04f33d95..9621ce17 100644 --- a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs +++ b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs @@ -28,6 +28,7 @@ protected internal enum Status protected readonly LoggerWrapper Logger; protected readonly string CacheKey; protected readonly SafeHooksWrapper Hooks; + private CancellationTokenSource? cacheSyncCancellationTokenSource; protected ConfigServiceBase(IConfigFetcher configFetcher, CacheParameters cacheParameters, LoggerWrapper logger, bool isOffline, SafeHooksWrapper hooks) { @@ -42,7 +43,17 @@ protected ConfigServiceBase(IConfigFetcher configFetcher, CacheParameters cacheP /// /// 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 a cache sync-up operation is still in progress, it should stop. + this.cacheSyncCancellationTokenSource?.Cancel(); + + if (disposing) + { + this.cacheSyncCancellationTokenSource?.Dispose(); + this.cacheSyncCancellationTokenSource = null; + } + } protected virtual void Dispose(bool disposing) { @@ -69,6 +80,8 @@ public void Dispose() Dispose(true); } + public ProjectConfig GetInMemoryConfig() => this.ConfigCache.LocalCachedConfig; + public virtual RefreshResult RefreshConfig() { if (!IsOffline) @@ -215,11 +228,34 @@ public void SetOffline() logAction?.Invoke(this.Logger); } - protected TResult Synchronize(Func func, TState state) + public abstract ClientCacheState GetCacheState(ProjectConfig cachedConfig); + + protected async Task SyncUpWithCache(bool suppressRaiseClientReady = false) { + CancellationToken cancellationToken; lock (this.syncObj) { - return func(state); + this.cacheSyncCancellationTokenSource ??= new CancellationTokenSource(); + cancellationToken = this.cacheSyncCancellationTokenSource.Token; } + + ProjectConfig cachedConfig; + try { cachedConfig = await this.ConfigCache.GetAsync(this.CacheKey, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return this.ConfigCache.LocalCachedConfig; } + finally + { + lock (this.syncObj) + { + this.cacheSyncCancellationTokenSource?.Dispose(); + this.cacheSyncCancellationTokenSource = null; + } + } + + if (!suppressRaiseClientReady) + { + this.Hooks.RaiseClientReady(GetCacheState(cachedConfig)); + } + + return cachedConfig; } } diff --git a/src/ConfigCatClient/ConfigService/IConfigService.cs b/src/ConfigCatClient/ConfigService/IConfigService.cs index 7e7c6265..85159110 100644 --- a/src/ConfigCatClient/ConfigService/IConfigService.cs +++ b/src/ConfigCatClient/ConfigService/IConfigService.cs @@ -5,6 +5,8 @@ namespace ConfigCat.Client.ConfigService; internal interface IConfigService { + ProjectConfig GetInMemoryConfig(); + ProjectConfig GetConfig(); ValueTask GetConfigAsync(CancellationToken cancellationToken = default); @@ -18,4 +20,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 2ea94583..b372d7a2 100644 --- a/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs +++ b/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs @@ -14,7 +14,7 @@ internal LazyLoadConfigService(IConfigFetcher configFetcher, CacheParameters cac { this.cacheTimeToLive = cacheTimeToLive; - hooks.RaiseClientReady(); + _ = SyncUpWithCache(); } public ProjectConfig GetConfig() @@ -63,4 +63,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..eeec3aab 100644 --- a/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs @@ -9,7 +9,7 @@ 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(); + _ = SyncUpWithCache(); } public ProjectConfig GetConfig() @@ -21,4 +21,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 97c83327..05d018e2 100644 --- a/src/ConfigCatClient/ConfigService/NullConfigService.cs +++ b/src/ConfigCatClient/ConfigService/NullConfigService.cs @@ -11,9 +11,11 @@ public NullConfigService(LoggerWrapper logger, SafeHooksWrapper hooks = default) { this.logger = logger; - hooks.RaiseClientReady(); + hooks.RaiseClientReady(ClientCacheState.HasLocalOverrideFlagDataOnly); } + public ProjectConfig GetInMemoryConfig() => ProjectConfig.Empty; + public ProjectConfig GetConfig() => ProjectConfig.Empty; public ValueTask GetConfigAsync(CancellationToken cancellationToken = default) => new ValueTask(ProjectConfig.Empty); @@ -30,4 +32,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 dfb374c9..d4061066 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/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs index a4612c65..b78c4d1b 100644 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ b/src/ConfigCatClient/Extensions/SerializationExtensions.cs @@ -14,22 +14,20 @@ internal static class SerializationExtensions private static readonly JsonSerializer Serializer = JsonSerializer.Create(); #endif - public static T? Deserialize(this string json) => json.AsSpan().Deserialize(); - - public static T? Deserialize(this ReadOnlySpan json) + // 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) { #if USE_NEWTONSOFT_JSON using var stringReader = new StringReader(json.ToString()); using var reader = new JsonTextReader(stringReader); return Serializer.Deserialize(reader); #else - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json.Span); #endif } - public static T? DeserializeOrDefault(this string json) => json.AsSpan().DeserializeOrDefault(); - - public static T? DeserializeOrDefault(this ReadOnlySpan json) + public static T? DeserializeOrDefault(this ReadOnlyMemory json) { 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 f8f835e6..8948f6ea 100644 --- a/src/ConfigCatClient/Hooks/Hooks.cs +++ b/src/ConfigCatClient/Hooks/Hooks.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using System.Threading.Tasks; namespace ConfigCat.Client; @@ -9,10 +10,25 @@ internal class Hooks : IProvidesHooks private volatile EventHandlers eventHandlers; private IConfigCatClient? client; // should be null only in case of testing + private readonly TaskCompletionSource clientReadyTcs; protected Hooks(EventHandlers eventHandlers) { this.eventHandlers = eventHandlers; + + this.clientReadyTcs = new TaskCompletionSource( +#if !NET45 + TaskCreationOptions.RunContinuationsAsynchronously +#endif + ); + + void HandleClientReady(object? sender, ClientReadyEventArgs e) + { + ClientReady -= HandleClientReady; + this.clientReadyTcs.TrySetResult(e.CacheState); + } + + ClientReady += HandleClientReady; } public Hooks() : this(new ActualEventHandlers()) { } @@ -33,16 +49,23 @@ public virtual void SetSender(IConfigCatClient client) this.client = client; } + public Task ClientReadyTask => this.clientReadyTcs.Task +#if NET45 + // Force asynchronous continuation. + .ContinueWith(static result => result.GetAwaiter().GetResult(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default) +#endif + ; + /// - public event EventHandler? ClientReady + public event EventHandler? ClientReady { add { this.eventHandlers.ClientReady += value; } remove { this.eventHandlers.ClientReady -= value; } } - internal void RaiseClientReady() + internal void RaiseClientReady(ClientCacheState cacheState) { - this.eventHandlers.ClientReady?.Invoke(this.client, EventArgs.Empty); + this.eventHandlers.ClientReady?.Invoke(this.client, new ClientReadyEventArgs(cacheState)); } /// @@ -85,7 +108,7 @@ protected class EventHandlers { private static void Noop(Delegate? _) { /* This method is for keeping SonarQube happy. */ } - public virtual EventHandler? ClientReady { get => null; set => Noop(value); } + public virtual EventHandler? ClientReady { get => null; set => Noop(value); } public virtual EventHandler? FlagEvaluated { get => null; set => Noop(value); } public virtual EventHandler? ConfigChanged { get => null; set => Noop(value); } public virtual EventHandler? Error { get => null; set => Noop(value); } @@ -93,7 +116,7 @@ protected class EventHandlers private sealed class ActualEventHandlers : EventHandlers { - public override EventHandler? ClientReady { get; set; } + public override EventHandler? ClientReady { get; set; } public override EventHandler? FlagEvaluated { get; set; } public override EventHandler? ConfigChanged { get; set; } public override EventHandler? Error { get; set; } diff --git a/src/ConfigCatClient/Hooks/IProvidesHooks.cs b/src/ConfigCatClient/Hooks/IProvidesHooks.cs index f58d4690..6d5ec2be 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 bb763323..63161ba5 100644 --- a/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs +++ b/src/ConfigCatClient/Hooks/SafeHooksWrapper.cs @@ -21,7 +21,7 @@ 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) => Hooks.RaiseFlagEvaluated(evaluationDetails); diff --git a/src/ConfigCatClient/HttpConfigFetcher.cs b/src/ConfigCatClient/HttpConfigFetcher.cs index 4d812b3c..b67f67d8 100644 --- a/src/ConfigCatClient/HttpConfigFetcher.cs +++ b/src/ConfigCatClient/HttpConfigFetcher.cs @@ -177,8 +177,7 @@ private async ValueTask FetchRequestAsync(string? httpETag, Ur var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - var config = responseBody.DeserializeOrDefault(); - if (config is null) + if (!SettingsWithPreferences.TryDeserialize(responseBody.AsMemory(), out var config)) { return new ResponseWithBody(response, null, null); } diff --git a/src/ConfigCatClient/IConfigCatClient.cs b/src/ConfigCatClient/IConfigCatClient.cs index 47468aa1..654425cf 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; @@ -163,6 +164,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.
+ /// Because of this, it is recommended to use this operation with the Auto Polling mode. In the case of other polling modes, + /// the in-memory stored config data needs to be updated manually using the / methods. + ///
+ /// The snapshot object. + ConfigCatClientSnapshot Snapshot(); + /// /// Sets the default user. /// diff --git a/src/ConfigCatClient/Models/SettingsWithPreferences.cs b/src/ConfigCatClient/Models/SettingsWithPreferences.cs index 6f7ed835..660d680a 100644 --- a/src/ConfigCatClient/Models/SettingsWithPreferences.cs +++ b/src/ConfigCatClient/Models/SettingsWithPreferences.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; #if USE_NEWTONSOFT_JSON @@ -22,6 +24,19 @@ public interface IConfig internal sealed class SettingsWithPreferences : IConfig { + public static bool TryDeserialize(ReadOnlyMemory configJson, [NotNullWhen(true)] out SettingsWithPreferences? config) + { + try { config = configJson.Deserialize(); } + catch { config = null; } + return config is not null; + } + + public static SettingsWithPreferences Deserialize(ReadOnlyMemory configJson) + { + return configJson.Deserialize() + ?? throw new ArgumentException("Invalid config JSON content: " + configJson, nameof(configJson)); + } + private Dictionary? settings; #if USE_NEWTONSOFT_JSON diff --git a/src/ConfigCatClient/Override/IOverrideDataSource.cs b/src/ConfigCatClient/Override/IOverrideDataSource.cs index 31ea4f0e..a85cc094 100644 --- a/src/ConfigCatClient/Override/IOverrideDataSource.cs +++ b/src/ConfigCatClient/Override/IOverrideDataSource.cs @@ -5,7 +5,7 @@ namespace ConfigCat.Client.Override; -internal interface IOverrideDataSource : IDisposable +internal interface IOverrideDataSource { Dictionary GetOverrides(); diff --git a/src/ConfigCatClient/Override/LocalDictionaryDataSource.cs b/src/ConfigCatClient/Override/LocalDictionaryDataSource.cs index 55af9791..fcdfe511 100644 --- a/src/ConfigCatClient/Override/LocalDictionaryDataSource.cs +++ b/src/ConfigCatClient/Override/LocalDictionaryDataSource.cs @@ -20,8 +20,6 @@ public LocalDictionaryDataSource(IDictionary overrideValues, boo } } - public void Dispose() { /* no need to dispose anything */ } - public Dictionary GetOverrides() => GetSettingsFromSource(); public Task> GetOverridesAsync(CancellationToken cancellationToken = default) => Task.FromResult(GetSettingsFromSource()); diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index a9eaa3e3..b8959d78 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -8,7 +8,7 @@ namespace ConfigCat.Client.Override; -internal sealed class LocalFileDataSource : IOverrideDataSource +internal sealed class LocalFileDataSource : IOverrideDataSource, IDisposable { private const int WAIT_TIME_FOR_UNLOCK = 200; // ms private const int MAX_WAIT_ITERATIONS = 50; // ms @@ -104,15 +104,14 @@ private async Task ReloadFileAsync(bool isAsync, CancellationToken cancellationT try { var content = File.ReadAllText(this.fullPath); - var simplified = content.DeserializeOrDefault(); + var simplified = content.AsMemory().DeserializeOrDefault(); if (simplified?.Entries is not null) { this.overrideValues = simplified.Entries.ToDictionary(kv => kv.Key, kv => kv.Value.ToSetting()); break; } - var deserialized = content.Deserialize() - ?? throw new InvalidOperationException("Invalid config JSON content: " + content); + var deserialized = SettingsWithPreferences.Deserialize(content.AsMemory()); this.overrideValues = deserialized.Settings; break; } diff --git a/src/ConfigCatClient/ProjectConfig.cs b/src/ConfigCatClient/ProjectConfig.cs index 0b57bda9..4a7c042b 100644 --- a/src/ConfigCatClient/ProjectConfig.cs +++ b/src/ConfigCatClient/ProjectConfig.cs @@ -80,17 +80,13 @@ public static ProjectConfig Deserialize(string value) var httpETagSpan = value.AsSpan(index, endIndex - index); index = endIndex + 1; - var configJsonSpan = value.AsSpan(index); + var configJsonSpan = value.AsMemory(index); SettingsWithPreferences? config; string? configJson; if (configJsonSpan.Length > 0) { - config = configJsonSpan.DeserializeOrDefault(); - if (config is null) - { - throw new FormatException("Invalid config JSON content: " + configJsonSpan.ToString()); - } + config = SettingsWithPreferences.Deserialize(configJsonSpan); configJson = configJsonSpan.ToString(); } else