Skip to content

Commit

Permalink
Fix cache expiration-related issues in Auto Polling mode (#98)
Browse files Browse the repository at this point in the history
* Check for expiration on every iteration in Auto Polling mode + allow a tolerance of 500ms to prevent missing fetches due to timer inaccuracy + sync with cache even in offline mode

* Fix broken badge in README.md

* Minor correction to README.md

* Bump version
  • Loading branch information
adams85 authored Sep 6, 2024
1 parent a7efdbd commit 06408f5
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 28 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ ConfigCat is a feature flag and configuration management service that lets you s

ConfigCat is a [hosted feature flag service](https://configcat.com). Manage feature toggles across frontend, backend, mobile, desktop apps. [Alternative to LaunchDarkly](https://configcat.com). Management app + feature flag SDKs.

[![Build status](https://ci.appveyor.com/api/projects/status/3kygp783vc2uv9xr?svg=true)](https://ci.appveyor.com/project/ConfigCat/net-sdk) [![NuGet Version](https://buildstats.info/nuget/ConfigCat.Client)](https://www.nuget.org/packages/ConfigCat.Client/)
[![Build status](https://ci.appveyor.com/api/projects/status/3kygp783vc2uv9xr?svg=true)](https://ci.appveyor.com/project/ConfigCat/net-sdk)
[![NuGet Version](https://img.shields.io/nuget/v/ConfigCat.Client)](https://www.nuget.org/packages/ConfigCat.Client/)
[![Sonar Coverage](https://img.shields.io/sonar/coverage/net-sdk?logo=SonarCloud&server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/project/overview?id=net-sdk)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=net-sdk&metric=alert_status)](https://sonarcloud.io/dashboard?id=net-sdk)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/configcat/.net-sdk/blob/master/LICENSE)
Expand Down Expand Up @@ -106,7 +107,7 @@ Based on our tests, the SDK is compatible with the following runtimes/deployment
<sup><small>*</small></sup>Unity WebGL also works but needs a bit of extra effort: you will need to enable WebGL compatibility by calling the `ConfigCatClient.PlatformCompatibilityOptions.EnableUnityWebGLCompatibility` method. For more details, see [Sample Scripts](https://github.com/configcat/.net-sdk/tree/master/samples/UnityWebGL).<br/>
<sup><small>**</small></sup>To make the SDK work in Release builds on UWP, you will need to add `<Namespace Name="System.Text.Json.Serialization.Converters" Browse="Required All"/>` to your application's [.rd.xml](https://learn.microsoft.com/en-us/windows/uwp/dotnet-native/runtime-directives-rd-xml-configuration-file-reference) file. See also [this discussion](https://github.com/dotnet/runtime/issues/29912#issuecomment-638471351).

We strive to provide an extensive support for the various .NET runtimes and versions. If you still encounter an issue with the SDK on some platform, please open a [GitHub issue](https://github.com/configcat/.net-sdk/issues/new/choose) or contact support.
> We strive to provide an extensive support for the various .NET runtimes and versions. If you still encounter an issue with the SDK on some platform, please open a [GitHub issue](https://github.com/configcat/.net-sdk/issues/new/choose) or contact support.
## Need help?
https://configcat.com/support
Expand Down
2 changes: 1 addition & 1 deletion appveyor.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
environment:
build_version: 9.3.0
build_version: 9.3.1
version: $(build_version)-{build}
image: Visual Studio 2022
configuration: Release
Expand Down
103 changes: 99 additions & 4 deletions src/ConfigCat.Client.Tests/ConfigServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using ConfigCat.Client.Cache;
Expand All @@ -13,11 +14,19 @@ namespace ConfigCat.Client.Tests;
[TestClass]
public class ConfigServiceTests
{
private static ProjectConfig CreateExpiredPc(DateTime timeStamp, TimeSpan expiration, string configJson = "{}", string httpETag = "\"67890\"") =>
ConfigHelper.FromString(configJson, httpETag, timeStamp - expiration - TimeSpan.FromSeconds(1));
private static ProjectConfig CreateExpiredPc(DateTime timeStamp, TimeSpan expiration, string configJson = "{}", string httpETag = "\"67890\"")
{
var offset = TimeSpan.FromSeconds(1);
Debug.Assert(offset.TotalMilliseconds > AutoPollConfigService.PollExpirationToleranceMs * 1.5);
return ConfigHelper.FromString(configJson, httpETag, timeStamp - expiration - offset);
}

private static ProjectConfig CreateUpToDatePc(DateTime timeStamp, TimeSpan expiration, string configJson = "{}", string httpETag = "\"abcdef\"") =>
ConfigHelper.FromString(configJson, httpETag, timeStamp - expiration + TimeSpan.FromSeconds(1));
private static ProjectConfig CreateUpToDatePc(DateTime timeStamp, TimeSpan expiration, string configJson = "{}", string httpETag = "\"abcdef\"")
{
var offset = TimeSpan.FromSeconds(1);
Debug.Assert(offset.TotalMilliseconds > AutoPollConfigService.PollExpirationToleranceMs * 1.5);
return ConfigHelper.FromString(configJson, httpETag, timeStamp - expiration + offset);
}

private static ProjectConfig CreateFreshPc(DateTime timeStamp, string configJson = "{}", string httpETag = "\"12345\"") =>
ConfigHelper.FromString(configJson, httpETag, timeStamp);
Expand Down Expand Up @@ -300,6 +309,92 @@ public async Task AutoPollConfigService_GetConfigAsync_WithTimer_ShouldInvokeFet
this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()), Times.Once);
}

[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public async Task AutoPollConfigService_GetConfig_ShouldReturnCachedConfigWhenCachedConfigIsNotExpired(bool isAsync)
{
// Arrange

var pollInterval = TimeSpan.FromSeconds(2);

var timeStamp = ProjectConfig.GenerateTimeStamp();
var fetchedPc = CreateFreshPc(timeStamp);
var cachedPc = fetchedPc.With(fetchedPc.TimeStamp - pollInterval + TimeSpan.FromMilliseconds(1.5 * AutoPollConfigService.PollExpirationToleranceMs));

const string cacheKey = "";
var cache = new InMemoryConfigCache();
cache.Set(cacheKey, cachedPc);

this.fetcherMock
.Setup(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()))
.ReturnsAsync(FetchResult.Success(fetchedPc));

var config = PollingModes.AutoPoll(pollInterval, maxInitWaitTime: Timeout.InfiniteTimeSpan);
using var service = new AutoPollConfigService(config,
this.fetcherMock.Object,
new CacheParameters(cache, cacheKey),
this.loggerMock.Object.AsWrapper(),
startTimer: true);

// Act

// Give a bit of time to the polling loop to do the first iteration.
await Task.Delay(TimeSpan.FromTicks(pollInterval.Ticks / 4));

var actualPc = isAsync ? await service.GetConfigAsync() : service.GetConfig();

// Assert

Assert.AreSame(cachedPc, actualPc);

this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()), Times.Never);
}

[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public async Task AutoPollConfigService_GetConfig_ShouldWaitForFetchWhenCachedConfigIsExpired(bool isAsync)
{
// Arrange

var pollInterval = TimeSpan.FromSeconds(2);

var timeStamp = ProjectConfig.GenerateTimeStamp();
var fetchedPc = CreateFreshPc(timeStamp);
var cachedPc = fetchedPc.With(fetchedPc.TimeStamp - pollInterval + TimeSpan.FromMilliseconds(0.5 * AutoPollConfigService.PollExpirationToleranceMs));

const string cacheKey = "";
var cache = new InMemoryConfigCache();
cache.Set(cacheKey, cachedPc);

this.fetcherMock
.Setup(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()))
.ReturnsAsync(FetchResult.Success(fetchedPc));

var config = PollingModes.AutoPoll(pollInterval, maxInitWaitTime: Timeout.InfiniteTimeSpan);
using var service = new AutoPollConfigService(config,
this.fetcherMock.Object,
new CacheParameters(cache, cacheKey),
this.loggerMock.Object.AsWrapper(),
startTimer: true);

// Act

// Give a bit of time to the polling loop to do the first iteration.
await Task.Delay(TimeSpan.FromTicks(pollInterval.Ticks / 4));

var actualPc = isAsync ? await service.GetConfigAsync() : service.GetConfig();

// Assert

Assert.AreNotSame(cachedPc, actualPc);
Assert.AreEqual(cachedPc.HttpETag, actualPc.HttpETag);
Assert.AreEqual(cachedPc.ConfigJson, actualPc.ConfigJson);

this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny<CancellationToken>()), Times.Once);
}

[TestMethod]
public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCacheGetAndFetchAndCacheSet()
{
Expand Down
38 changes: 17 additions & 21 deletions src/ConfigCatClient/ConfigService/AutoPollConfigService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ namespace ConfigCat.Client.ConfigService;

internal sealed class AutoPollConfigService : ConfigServiceBase, IConfigService
{
internal const int PollExpirationToleranceMs = 500;

private readonly TimeSpan pollInterval;
private readonly TimeSpan pollExpiration;
private readonly TimeSpan maxInitWaitTime;
private readonly CancellationTokenSource initSignalCancellationTokenSource = new(); // used for signalling initialization ready
private CancellationTokenSource timerCancellationTokenSource = new(); // used for signalling background work to stop
Expand All @@ -34,6 +37,10 @@ internal AutoPollConfigService(
SafeHooksWrapper hooks = default) : base(configFetcher, cacheParameters, logger, isOffline, hooks)
{
this.pollInterval = options.PollInterval;
// Due to the inaccuracy of the timer, some tolerance should be allowed when checking for
// cache expiration in the polling loop, otherwise some fetch operations may be missed.
this.pollExpiration = options.PollInterval - TimeSpan.FromMilliseconds(PollExpirationToleranceMs);

this.maxInitWaitTime = options.MaxInitWaitTime >= TimeSpan.Zero ? options.MaxInitWaitTime : Timeout.InfiniteTimeSpan;

var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken);
Expand Down Expand Up @@ -214,34 +221,23 @@ private void StartScheduler(Task<ProjectConfig>? initialCacheSyncUpTask, Cancell
}, stopToken);
}

private async ValueTask PollCoreAsync(bool isFirstIteration, Task<ProjectConfig>? initialCacheSyncUpTask, CancellationToken cancellationToken)
private async ValueTask PollCoreAsync(bool isFirstIteration, Task<ProjectConfig>? initialCacheSyncUpTask, CancellationToken stopToken)
{
if (isFirstIteration)
{
var latestConfig = initialCacheSyncUpTask is not null
? await initialCacheSyncUpTask.WaitAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext)
: await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);
var latestConfig = initialCacheSyncUpTask is not null
? await initialCacheSyncUpTask.WaitAsync(stopToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext)
: await this.ConfigCache.GetAsync(base.CacheKey, stopToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);

if (latestConfig.IsExpired(expiration: this.pollInterval))
{
if (!IsOffline)
{
await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);
}
}
else
{
SignalInitialization();
}
}
else
if (latestConfig.IsExpired(expiration: this.pollExpiration))
{
if (!IsOffline)
{
var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);
await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);
await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, stopToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext);
}
}
else if (isFirstIteration)
{
SignalInitialization();
}
}

public override ClientCacheState GetCacheState(ProjectConfig cachedConfig)
Expand Down

0 comments on commit 06408f5

Please sign in to comment.