Skip to content

Commit

Permalink
Implement non-blocking synchronous evaluation (snapshot API)
Browse files Browse the repository at this point in the history
  • Loading branch information
adams85 committed Nov 17, 2023
1 parent d2aa885 commit 4efb8c6
Show file tree
Hide file tree
Showing 28 changed files with 495 additions and 121 deletions.
2 changes: 1 addition & 1 deletion src/ConfigCat.Client.Tests/ConfigCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsWithPreferences>(), timeStampDateTime, httpETag);
var pc = new ProjectConfig(configJson, SettingsWithPreferences.Deserialize(configJson.AsMemory()), timeStampDateTime, httpETag);

Assert.AreEqual(expectedPayload, ProjectConfig.Serialize(pc));
}
Expand Down
4 changes: 3 additions & 1 deletion src/ConfigCat.Client.Tests/ConfigCatClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1732,7 +1732,7 @@ public async Task Hooks_RealClientRaisesEvents(bool subscribeViaOptions)
var flagEvaluatedEvents = new List<FlagEvaluatedEventArgs>();
var errorEvents = new List<ConfigCatClientErrorEventArgs>();

EventHandler handleClientReady = (s, e) => clientReadyCallCount++;
EventHandler<ClientReadyEventArgs> handleClientReady = (s, e) => clientReadyCallCount++;
EventHandler<ConfigChangedEventArgs> handleConfigChanged = (s, e) => configChangedEvents.Add(e);
EventHandler<FlagEvaluatedEventArgs> handleFlagEvaluated = (s, e) => flagEvaluatedEvents.Add(e);
EventHandler<ConfigCatClientErrorEventArgs> handleError = (s, e) => errorEvents.Add(e);
Expand Down Expand Up @@ -1862,5 +1862,7 @@ public override RefreshResult RefreshConfig()
{
return RefreshResult.Success();
}

public override ClientCacheState GetCacheState(ProjectConfig cachedConfig) => ClientCacheState.NoFlagData;
}
}
3 changes: 2 additions & 1 deletion src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +28,7 @@ public ConfigEvaluatorTestsBase()
{
this.configEvaluator = new RolloutEvaluator(this.Logger);

this.config = GetSampleJson().Deserialize<SettingsWithPreferences>()!.Settings;
this.config = SettingsWithPreferences.Deserialize(GetSampleJson().AsMemory()).Settings;
}

protected virtual void AssertValue(string keyName, string expected, User? user)
Expand Down
3 changes: 2 additions & 1 deletion src/ConfigCat.Client.Tests/DeserializerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public void Ensure_Global_Settings_Doesnt_Interfere()
return settings;
};

Assert.IsNotNull("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".DeserializeOrDefault<SettingsWithPreferences>());
Assert.IsTrue(SettingsWithPreferences.TryDeserialize("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}".AsMemory(), out var config));
Assert.IsNotNull(config);
}

[DataRow(false)]
Expand Down
2 changes: 1 addition & 1 deletion src/ConfigCat.Client.Tests/Helpers/ConfigHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal static class ConfigHelper
{
public static ProjectConfig FromString(string configJson, string? httpETag, DateTime timeStamp)
{
return new ProjectConfig(configJson, configJson.Deserialize<SettingsWithPreferences>(), timeStamp, httpETag);
return new ProjectConfig(configJson, SettingsWithPreferences.Deserialize(configJson.AsMemory()), timeStamp, httpETag);
}

public static ProjectConfig FromFile(string configJsonFilePath, string? httpETag, DateTime timeStamp)
Expand Down
5 changes: 3 additions & 2 deletions src/ConfigCat.Client.Tests/OverrideTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -559,9 +560,9 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s
const string key = "flag";
var overrideValue =
#if USE_NEWTONSOFT_JSON
overrideValueJson.Deserialize<Newtonsoft.Json.Linq.JToken>();
overrideValueJson.AsMemory().Deserialize<Newtonsoft.Json.Linq.JToken>();
#else
overrideValueJson.Deserialize<System.Text.Json.JsonElement>();
overrideValueJson.AsMemory().Deserialize<System.Text.Json.JsonElement>();
#endif

var filePath = Path.GetTempFileName();
Expand Down
152 changes: 92 additions & 60 deletions src/ConfigCatClient/ConfigCatClient.cs

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions src/ConfigCatClient/ConfigCatClientSnapshot.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents the state of <see cref="IConfigCatClient"/> captured at a specific point in time.
/// </summary>
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;
}

/// <summary>
/// The state of the local cache at the time the snapshot was created.
/// </summary>
public ClientCacheState CacheState { get; }

/// <summary>
/// The latest config which has been fetched from the remote server.
/// </summary>
public IConfig? FetchedConfig => this.settings.RemoteConfig?.Config;

/// <summary>
/// Returns the available setting keys.
/// </summary>
/// <remarks>
/// In case the client is configured to use flag override, this will also include the keys provided by the flag override.
/// </remarks>
/// <returns>The collection of keys.</returns>
public IReadOnlyCollection<string> GetAllKeys()
{
return this.settings.Value is { } settings ? settings.ReadOnlyKeys() : ArrayUtils.EmptyArray<string>();
}

/// <summary>
/// Returns the value of a feature flag or setting identified by <paramref name="key"/> synchronously, based on the snapshot.
/// </summary>
/// <remarks>
/// It is important to provide an argument for the <paramref name="defaultValue"/> parameter, specifically for the <typeparamref name="T"/> generic type parameter,
/// that matches the type of the feature flag or setting you are evaluating.<br/>
/// Please refer to <see href="https://configcat.com/docs/sdk-reference/dotnet/#setting-type-mapping">this table</see> for the corresponding types.
/// </remarks>
/// <typeparam name="T">
/// The type of the value. Only the following types are allowed:
/// <see cref="string"/>, <see cref="bool"/>, <see cref="int"/>, <see cref="long"/>, <see cref="double"/> and <see cref="object"/> (both nullable and non-nullable).<br/>
/// The type must correspond to the setting type, otherwise <paramref name="defaultValue"/> will be returned.
/// </typeparam>
/// <param name="key">Key of the feature flag or setting.</param>
/// <param name="defaultValue">In case of failure, this value will be returned.</param>
/// <param name="user">The User Object to use for evaluating targeting rules and percentage options.</param>
/// <returns>The value of the feature flag or setting.</returns>
/// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="key"/> is an empty string.</exception>
/// <exception cref="ArgumentException"><typeparamref name="T"/> is not an allowed type.</exception>
public T GetValue<T>(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<T> 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;
}

/// <summary>
/// Returns the value along with evaluation details of a feature flag or setting identified by <paramref name="key"/> synchronously, based on the snapshot.
/// </summary>
/// <remarks>
/// It is important to provide an argument for the <paramref name="defaultValue"/> parameter, specifically for the <typeparamref name="T"/> generic type parameter,
/// that matches the type of the feature flag or setting you are evaluating.<br/>
/// Please refer to <see href="https://configcat.com/docs/sdk-reference/dotnet/#setting-type-mapping">this table</see> for the corresponding types.
/// </remarks>
/// <typeparam name="T">
/// The type of the value. Only the following types are allowed:
/// <see cref="string"/>, <see cref="bool"/>, <see cref="int"/>, <see cref="long"/>, <see cref="double"/> and <see cref="object"/> (both nullable and non-nullable).<br/>
/// The type must correspond to the setting type, otherwise <paramref name="defaultValue"/> will be returned.
/// </typeparam>
/// <param name="key">Key of the feature flag or setting.</param>
/// <param name="defaultValue">In case of failure, this value will be returned.</param>
/// <param name="user">The User Object to use for evaluating targeting rules and percentage options.</param>
/// <returns>The value along with the details of evaluation of the feature flag or setting.</returns>
/// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="key"/> is an empty string.</exception>
/// <exception cref="ArgumentException"><typeparamref name="T"/> is not an allowed type.</exception>
public EvaluationDetails<T> GetValueDetails<T>(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<T> 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;
}
}
49 changes: 31 additions & 18 deletions src/ConfigCatClient/ConfigService/AutoPollConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -154,7 +158,7 @@ protected override void OnConfigFetched(ProjectConfig newConfig)

protected override void SetOnlineCoreSynchronized()
{
StartScheduler();
StartScheduler(null, this.timerCancellationTokenSource.Token);
}

protected override void SetOfflineCoreSynchronized()
Expand All @@ -164,21 +168,20 @@ protected override void SetOfflineCoreSynchronized()
this.timerCancellationTokenSource = new CancellationTokenSource();
}

private void StartScheduler()
private void StartScheduler(Task<ProjectConfig>? 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)
{
Expand All @@ -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)
Expand All @@ -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<ProjectConfig>? 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)
Expand All @@ -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;
}
}
Loading

0 comments on commit 4efb8c6

Please sign in to comment.