Skip to content

Commit

Permalink
Introduce ConfigFetched hook
Browse files Browse the repository at this point in the history
  • Loading branch information
adams85 committed Apr 16, 2024
1 parent acc00da commit 73ab47d
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 26 deletions.
23 changes: 23 additions & 0 deletions src/ConfigCat.Client.Tests/ConfigCatClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1768,12 +1768,14 @@ public async Task Hooks_MockedClientRaisesEvents()
var configJsonFilePath = Path.Combine("data", "sample_variationid_v5.json");

var clientReadyEventCount = 0;
var configFetchedEvents = new List<ConfigFetchedEventArgs>();
var configChangedEvents = new List<ConfigChangedEventArgs>();
var flagEvaluatedEvents = new List<FlagEvaluatedEventArgs>();
var errorEvents = new List<ConfigCatClientErrorEventArgs>();

var hooks = new Hooks();
hooks.ClientReady += (s, e) => clientReadyEventCount++;
hooks.ConfigFetched += (s, e) => configFetchedEvents.Add(e);
hooks.ConfigChanged += (s, e) => configChangedEvents.Add(e);
hooks.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e);
hooks.Error += (s, e) => errorEvents.Add(e);
Expand All @@ -1799,13 +1801,18 @@ public async Task Hooks_MockedClientRaisesEvents()
var client = new ConfigCatClient(configService, this.loggerMock.Object, new RolloutEvaluator(loggerWrapper), hooks);

Assert.AreEqual(1, clientReadyEventCount);
Assert.AreEqual(0, configFetchedEvents.Count);
Assert.AreEqual(0, configChangedEvents.Count);
Assert.AreEqual(0, flagEvaluatedEvents.Count);
Assert.AreEqual(0, errorEvents.Count);

// 2. Fetch fails
await client.ForceRefreshAsync();

Assert.AreEqual(1, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents[0].IsInitiatedByUser);
Assert.IsFalse(configFetchedEvents[0].Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.HttpRequestFailure, configFetchedEvents[0].Result.ErrorCode);
Assert.AreEqual(0, configChangedEvents.Count);
Assert.AreEqual(1, errorEvents.Count);
Assert.IsNotNull(errorEvents[0].Message);
Expand All @@ -1820,6 +1827,10 @@ public async Task Hooks_MockedClientRaisesEvents()

await client.ForceRefreshAsync();

Assert.AreEqual(2, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents[1].IsInitiatedByUser);
Assert.IsTrue(configFetchedEvents[1].Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvents[1].Result.ErrorCode);
Assert.AreEqual(1, configChangedEvents.Count);
Assert.AreSame(config.Config, configChangedEvents[0].NewConfig);

Expand All @@ -1838,6 +1849,7 @@ public async Task Hooks_MockedClientRaisesEvents()
client.Dispose();

Assert.AreEqual(1, clientReadyEventCount);
Assert.AreEqual(2, configFetchedEvents.Count);
Assert.AreEqual(1, configChangedEvents.Count);
Assert.AreEqual(evaluationDetails.Count, flagEvaluatedEvents.Count);
Assert.AreEqual(1, errorEvents.Count);
Expand All @@ -1850,18 +1862,21 @@ public async Task Hooks_MockedClientRaisesEvents()
public async Task Hooks_RealClientRaisesEvents(bool subscribeViaOptions)
{
var clientReadyCallCount = 0;
var configFetchedEvents = new List<ConfigFetchedEventArgs>();
var configChangedEvents = new List<ConfigChangedEventArgs>();
var flagEvaluatedEvents = new List<FlagEvaluatedEventArgs>();
var errorEvents = new List<ConfigCatClientErrorEventArgs>();

EventHandler handleClientReady = (s, e) => clientReadyCallCount++;
EventHandler<ConfigFetchedEventArgs> handleConfigFetched = (s, e) => configFetchedEvents.Add(e);
EventHandler<ConfigChangedEventArgs> handleConfigChanged = (s, e) => configChangedEvents.Add(e);
EventHandler<FlagEvaluatedEventArgs> handleFlagEvaluated = (s, e) => flagEvaluatedEvents.Add(e);
EventHandler<ConfigCatClientErrorEventArgs> handleError = (s, e) => errorEvents.Add(e);

void Subscribe(IProvidesHooks hooks)
{
hooks.ClientReady += handleClientReady;
hooks.ConfigFetched += handleConfigFetched;
hooks.ConfigChanged += handleConfigChanged;
hooks.FlagEvaluated += handleFlagEvaluated;
hooks.Error += handleError;
Expand All @@ -1870,6 +1885,7 @@ void Subscribe(IProvidesHooks hooks)
void Unsubscribe(IProvidesHooks hooks)
{
hooks.ClientReady -= handleClientReady;
hooks.ConfigFetched -= handleConfigFetched;
hooks.ConfigChanged -= handleConfigChanged;
hooks.FlagEvaluated -= handleFlagEvaluated;
hooks.Error -= handleError;
Expand Down Expand Up @@ -1898,13 +1914,19 @@ void Unsubscribe(IProvidesHooks hooks)
}

Assert.AreEqual(subscribeViaOptions ? 2 : 0, clientReadyCallCount);
Assert.AreEqual(0, configFetchedEvents.Count);
Assert.AreEqual(0, configChangedEvents.Count);
Assert.AreEqual(0, flagEvaluatedEvents.Count);
Assert.AreEqual(0, errorEvents.Count);

// 2. Fetch succeeds
await client.ForceRefreshAsync();

Assert.AreEqual(2, configFetchedEvents.Count);
Assert.AreSame(configFetchedEvents[0], configFetchedEvents[1]);
Assert.IsTrue(configFetchedEvents[0].IsInitiatedByUser);
Assert.IsTrue(configFetchedEvents[0].Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvents[1].Result.ErrorCode);
Assert.AreEqual(2, configChangedEvents.Count);
Assert.IsTrue(configChangedEvents[0].NewConfig.Settings.Any());
Assert.AreSame(configChangedEvents[0], configChangedEvents[1]);
Expand Down Expand Up @@ -1945,6 +1967,7 @@ void Unsubscribe(IProvidesHooks hooks)
client.Dispose();

Assert.AreEqual(subscribeViaOptions ? 2 : 0, clientReadyCallCount);
Assert.AreEqual(2, configFetchedEvents.Count);
Assert.AreEqual(2, configChangedEvents.Count);
Assert.AreEqual(evaluationDetails.Count * 2, flagEvaluatedEvents.Count);
Assert.AreEqual(2, errorEvents.Count);
Expand Down
85 changes: 81 additions & 4 deletions src/ConfigCat.Client.Tests/ConfigServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ public async Task LazyLoadConfigService_RefreshConfigAsync_ConfigChanged_ShouldR
var configChangedEvents = new ConcurrentQueue<ConfigChangedEventArgs>();
hooks.ConfigChanged += (s, e) => configChangedEvents.Enqueue(e);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

this.cacheMock
.Setup(m => m.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedPc);
Expand Down Expand Up @@ -196,6 +199,12 @@ public async Task LazyLoadConfigService_RefreshConfigAsync_ConfigChanged_ShouldR
Assert.IsTrue(configChangedEvents.TryDequeue(out var configChangedEvent));
Assert.AreSame(fetchedPc.Config, configChangedEvent.NewConfig);
Assert.AreEqual(0, configChangedEvents.Count);

Assert.AreEqual(1, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsTrue(configFetchedEvent.IsInitiatedByUser);
Assert.IsTrue(configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}

[TestMethod]
Expand Down Expand Up @@ -416,6 +425,9 @@ public async Task ManualPollConfigService_GetConfigAsync_ShouldInvokeCacheGet()
var clientReadyEventCount = 0;
hooks.ClientReady += (s, e) => Interlocked.Increment(ref clientReadyEventCount);

var configFetchedEventCount = 0;
hooks.ConfigFetched += (s, e) => Interlocked.Increment(ref configFetchedEventCount);

this.cacheMock
.Setup(m => m.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedPc);
Expand All @@ -439,6 +451,8 @@ public async Task ManualPollConfigService_GetConfigAsync_ShouldInvokeCacheGet()
this.cacheMock.Verify(m => m.SetAsync(It.IsAny<string>(), It.IsAny<ProjectConfig>(), It.IsAny<CancellationToken>()), Times.Never);

Assert.AreEqual(1, Volatile.Read(ref clientReadyEventCount));

Assert.AreEqual(0, Volatile.Read(ref configFetchedEventCount));
}

[TestMethod]
Expand All @@ -455,6 +469,9 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe
var clientReadyEventCount = 0;
hooks.ClientReady += (s, e) => Interlocked.Increment(ref clientReadyEventCount);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

byte callOrder = 1;

this.cacheMock
Expand Down Expand Up @@ -488,6 +505,12 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe
this.cacheMock.Verify(m => m.SetAsync(It.IsAny<string>(), It.IsAny<ProjectConfig>(), It.IsAny<CancellationToken>()), Times.Once);

Assert.AreEqual(1, Volatile.Read(ref clientReadyEventCount));

Assert.AreEqual(1, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsTrue(configFetchedEvent.IsInitiatedByUser);
Assert.IsTrue(configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}

[TestMethod]
Expand All @@ -504,6 +527,9 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ConfigChanged_Shoul
var configChangedEvents = new ConcurrentQueue<ConfigChangedEventArgs>();
hooks.ConfigChanged += (s, e) => configChangedEvents.Enqueue(e);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

this.cacheMock
.Setup(m => m.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(cachedPc);
Expand Down Expand Up @@ -531,6 +557,12 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ConfigChanged_Shoul
Assert.IsTrue(configChangedEvents.TryDequeue(out var configChangedEvent));
Assert.AreSame(fetchedPc.Config, configChangedEvent.NewConfig);
Assert.AreEqual(0, configChangedEvents.Count);

Assert.AreEqual(1, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsTrue(configFetchedEvent.IsInitiatedByUser);
Assert.IsTrue(configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}

[TestMethod]
Expand Down Expand Up @@ -624,6 +656,9 @@ public async Task AutoPollConfigService_GetConfig_ReturnsCachedConfigWhenCachedC
var clientReadyTcs = new TaskCompletionSource<object?>();
hooks.ClientReady += (s, e) => clientReadyTcs.TrySetResult(default);

var configFetchedEventCount = 0;
hooks.ConfigFetched += (s, e) => Interlocked.Increment(ref configFetchedEventCount);

var cache = new InMemoryConfigCache();
cache.Set(null!, cachedPc);

Expand Down Expand Up @@ -667,6 +702,8 @@ public async Task AutoPollConfigService_GetConfig_ReturnsCachedConfigWhenCachedC
{
Assert.IsTrue(clientReadyCalled);
}

Assert.AreEqual(0, Volatile.Read(ref configFetchedEventCount));
}

[DataRow(false)]
Expand All @@ -687,6 +724,9 @@ public async Task AutoPollConfigService_GetConfig_FetchesConfigWhenCachedConfigI
var clientReadyTcs = new TaskCompletionSource<object?>();
hooks.ClientReady += (s, e) => clientReadyTcs.TrySetResult(default);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

var cache = new InMemoryConfigCache();
cache.Set(null!, cachedPc);

Expand All @@ -710,9 +750,13 @@ public async Task AutoPollConfigService_GetConfig_FetchesConfigWhenCachedConfigI

// 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));
var clientReadyTask = Task.Run(async () => await clientReadyTcs.Task);
var task = await Task.WhenAny(clientReadyTask, Task.Delay(maxInitWaitTime, cts.Token));
cts.Cancel();
clientReadyCalled = task == clientReadyTcs.Task && task.Status == TaskStatus.RanToCompletion;
clientReadyCalled = task == clientReadyTask && task.Status == TaskStatus.RanToCompletion;

// Wait for the hook event handlers to execute (as that might not happen if the service got disposed immediately).
SpinWait.SpinUntil(() => configFetchedEvents.TryPeek(out _), TimeSpan.FromSeconds(1));
}

// Assert
Expand All @@ -722,6 +766,12 @@ public async Task AutoPollConfigService_GetConfig_FetchesConfigWhenCachedConfigI
this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()), Times.Once);

Assert.IsTrue(clientReadyCalled);

Assert.AreEqual(1, configFetchedEvents.Count);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsFalse(configFetchedEvent.IsInitiatedByUser);
Assert.IsTrue(configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}

[DataRow(false, false, true)]
Expand All @@ -746,6 +796,9 @@ public async Task AutoPollConfigService_GetConfig_ReturnsExpiredConfigWhenCantRe
var clientReadyTcs = new TaskCompletionSource<object?>();
hooks.ClientReady += (s, e) => clientReadyTcs.TrySetResult(default);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

var cache = new InMemoryConfigCache();
cache.Set(null!, cachedPc);

Expand All @@ -770,9 +823,13 @@ public async Task AutoPollConfigService_GetConfig_ReturnsExpiredConfigWhenCantRe

// 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));
var clientReadyTask = Task.Run(async () => await clientReadyTcs.Task);
var task = await Task.WhenAny(clientReadyTask, Task.Delay(maxInitWaitTime, cts.Token));
cts.Cancel();
clientReadyCalled = task == clientReadyTcs.Task && task.Status == TaskStatus.RanToCompletion;
clientReadyCalled = task == clientReadyTask && task.Status == TaskStatus.RanToCompletion;

// Wait for the hook event handlers to execute (as that might not happen if the service got disposed immediately).
SpinWait.SpinUntil(() => configFetchedEvents.TryPeek(out _), TimeSpan.FromSeconds(1));
}

// Assert
Expand All @@ -782,6 +839,12 @@ public async Task AutoPollConfigService_GetConfig_ReturnsExpiredConfigWhenCantRe
this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()), Times.Once);

Assert.IsTrue(clientReadyCalled);

Assert.IsTrue(configFetchedEvents.Count > 0);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsFalse(configFetchedEvent.IsInitiatedByUser);
Assert.AreEqual(failure, !configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(failure ? RefreshErrorCode.HttpRequestFailure : RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}

[DataRow(false)]
Expand All @@ -801,6 +864,9 @@ public async Task LazyLoadConfigService_GetConfig_ReturnsCachedConfigWhenCachedC
var clientReadyEventCount = 0;
hooks.ClientReady += (s, e) => Interlocked.Increment(ref clientReadyEventCount);

var configFetchedEventCount = 0;
hooks.ConfigFetched += (s, e) => Interlocked.Increment(ref configFetchedEventCount);

var cache = new InMemoryConfigCache();
cache.Set(null!, cachedPc);

Expand Down Expand Up @@ -841,6 +907,8 @@ public async Task LazyLoadConfigService_GetConfig_ReturnsCachedConfigWhenCachedC
}

Assert.AreEqual(1, Volatile.Read(ref clientReadyEventCount));

Assert.AreEqual(0, Volatile.Read(ref configFetchedEventCount));
}

[DataRow(false)]
Expand All @@ -860,6 +928,9 @@ public async Task LazyLoadConfigService_GetConfig_FetchesConfigWhenCachedConfigI
var clientReadyEventCount = 0;
hooks.ClientReady += (s, e) => Interlocked.Increment(ref clientReadyEventCount);

var configFetchedEvents = new ConcurrentQueue<ConfigFetchedEventArgs>();
hooks.ConfigFetched += (s, e) => configFetchedEvents.Enqueue(e);

var cache = new InMemoryConfigCache();
cache.Set(null!, cachedPc);

Expand Down Expand Up @@ -900,5 +971,11 @@ public async Task LazyLoadConfigService_GetConfig_FetchesConfigWhenCachedConfigI
}

Assert.AreEqual(1, Volatile.Read(ref clientReadyEventCount));

Assert.IsTrue(configFetchedEvents.Count > 0);
Assert.IsTrue(configFetchedEvents.TryDequeue(out var configFetchedEvent));
Assert.IsFalse(configFetchedEvent.IsInitiatedByUser);
Assert.IsTrue(configFetchedEvent.Result.IsSuccess);
Assert.AreEqual(RefreshErrorCode.None, configFetchedEvent.Result.ErrorCode);
}
}
7 changes: 7 additions & 0 deletions src/ConfigCatClient/ConfigCatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,13 @@ public event EventHandler<FlagEvaluatedEventArgs>? FlagEvaluated
remove { this.hooks.FlagEvaluated -= value; }
}

/// <inheritdoc/>
public event EventHandler<ConfigFetchedEventArgs>? ConfigFetched
{
add { this.hooks.ConfigFetched += value; }
remove { this.hooks.ConfigFetched -= value; }
}

/// <inheritdoc/>
public event EventHandler<ConfigChangedEventArgs>? ConfigChanged
{
Expand Down
8 changes: 4 additions & 4 deletions src/ConfigCatClient/ConfigService/AutoPollConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ public async ValueTask<ProjectConfig> GetConfigAsync(CancellationToken cancellat
return await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false);
}

protected override void OnConfigFetched(ProjectConfig newConfig)
protected override void OnConfigFetched(in FetchResult fetchResult, bool isInitiatedByUser)
{
base.OnConfigFetched(newConfig);
SignalInitialization();
base.OnConfigFetched(fetchResult, isInitiatedByUser);
}

protected override void SetOnlineCoreSynchronized()
Expand Down Expand Up @@ -214,7 +214,7 @@ private async ValueTask PollCoreAsync(bool isFirstIteration, CancellationToken c
{
if (!IsOffline)
{
await RefreshConfigCoreAsync(latestConfig, cancellationToken).ConfigureAwait(false);
await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(false);
}
}
else
Expand All @@ -227,7 +227,7 @@ private async ValueTask PollCoreAsync(bool isFirstIteration, CancellationToken c
if (!IsOffline)
{
var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false);
await RefreshConfigCoreAsync(latestConfig, cancellationToken).ConfigureAwait(false);
await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(false);
}
}
}
Expand Down
Loading

0 comments on commit 73ab47d

Please sign in to comment.