diff --git a/README.md b/README.md index 95a7d474..e78cabb6 100644 --- a/README.md +++ b/README.md @@ -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) @@ -106,7 +107,7 @@ Based on our tests, the SDK is compatible with the following runtimes/deployment *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).
**To make the SDK work in Release builds on UWP, you will need to add `` 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 diff --git a/appveyor.yml b/appveyor.yml index 12cd852c..aa08bb3c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -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 diff --git a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs index f9d39139..d00a1184 100644 --- a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; @@ -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); @@ -300,6 +309,92 @@ public async Task AutoPollConfigService_GetConfigAsync_WithTimer_ShouldInvokeFet this.fetcherMock.Verify(m => m.FetchAsync(cachedPc, It.IsAny()), 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())) + .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()), 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())) + .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()), Times.Once); + } + [TestMethod] public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCacheGetAndFetchAndCacheSet() { diff --git a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs index 0cdb4ac8..a42e78da 100644 --- a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs @@ -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 @@ -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); @@ -214,34 +221,23 @@ private void StartScheduler(Task? initialCacheSyncUpTask, Cancell }, stopToken); } - private async ValueTask PollCoreAsync(bool isFirstIteration, Task? initialCacheSyncUpTask, CancellationToken cancellationToken) + private async ValueTask PollCoreAsync(bool isFirstIteration, Task? 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)