Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix cache expiration-related issues in Auto Polling mode #98

Merged
merged 5 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading