diff --git a/.github/workflows/linux-macOS-CI.yml b/.github/workflows/linux-macOS-CI.yml index acc5eb72..6dbd7d80 100644 --- a/.github/workflows/linux-macOS-CI.yml +++ b/.github/workflows/linux-macOS-CI.yml @@ -1,6 +1,7 @@ name: Build on Linux and macOS on: push: + branches: [ master ] paths-ignore: - '**.md' - 'appveyor*' diff --git a/.github/workflows/sonar-analysis.yml b/.github/workflows/sonar-analysis.yml index 6400fe0c..5b9f2387 100644 --- a/.github/workflows/sonar-analysis.yml +++ b/.github/workflows/sonar-analysis.yml @@ -1,6 +1,7 @@ name: SonarCloud Analysis on: push: + branches: [ master ] paths-ignore: - '**.md' - 'appveyor*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e72062..58a3642d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### 6.5.0 +- Replace `FileSystemWatcher` with file polling in local file override data source. + +### 6.4.12 +- Fix various local file override data source issues. + ### 6.4.9 - Move the PollingMode option to public scope. diff --git a/DEPLOY.md b/DEPLOY.md index 15fd6d27..97f83679 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,6 +1,6 @@ # Steps to Deploy 1. Run tests -2. Set version in `appveyor.yml` (e.g: from `version: 2.5.{build}` to `version: 2.6.{build}`) +2. Set version in `appveyor.yml` (e.g: from `build_version: 6.5.0` to `build_version: 6.5.1`) 3. Update release notes in ConfigCatClient.csproj (PackageReleaseNotes) 4. Push to `master` 5. Deploy to NuGet.org diff --git a/appveyor.yml b/appveyor.yml index 77e7936c..1aaa4235 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,6 @@ -version: 6.4.{build} +environment: + build_version: 6.5.0 +version: $(build_version)-{build} image: Visual Studio 2022 configuration: Release skip_commits: @@ -8,11 +10,11 @@ skip_commits: dotnet_csproj: patch: true file: '**\*.csproj' - version: '{version}' - package_version: '{version}' - assembly_version: '{version}' - file_version: '{version}' - informational_version: '{version}' + version: $(build_version) + package_version: $(build_version) + assembly_version: $(build_version) + file_version: $(build_version) + informational_version: $(build_version) install: - cmd: dotnet tool install -g InheritDocTool build_script: diff --git a/src/ConfigCat.Client.Tests/OverrideTests.cs b/src/ConfigCat.Client.Tests/OverrideTests.cs index d9c26082..61f31022 100644 --- a/src/ConfigCat.Client.Tests/OverrideTests.cs +++ b/src/ConfigCat.Client.Tests/OverrideTests.cs @@ -121,7 +121,7 @@ public void LocalFile_Default_WhenErrorOccures() } [TestMethod] - public void LocalFile_Reload() + public void LocalFile_Read() { using var client = new ConfigCatClient(options => { @@ -137,7 +137,7 @@ public void LocalFile_Reload() } [TestMethod] - public async Task LocalFileAsync_Reload() + public async Task LocalFileAsync_Read() { using var client = new ConfigCatClient(options => { @@ -360,15 +360,37 @@ public async Task LocalFile_Watcher_Reload() }); Assert.AreEqual("initial", await client.GetValueAsync("fakeKey", string.Empty)); - + await Task.Delay(100); await WriteContent(SampleFileToCreate, "modified"); - await Task.Delay(400); + await Task.Delay(1500); Assert.AreEqual("modified", await client.GetValueAsync("fakeKey", string.Empty)); File.Delete(SampleFileToCreate); } + [TestMethod] + public async Task LocalFile_Watcher_Reload_Sync() + { + await CreateFileAndWriteContent(SampleFileToCreate, "initial"); + + using var client = new ConfigCatClient(options => + { + options.SdkKey = "localhost"; + options.FlagOverrides = FlagOverrides.LocalFile(SampleFileToCreate, true, OverrideBehaviour.LocalOnly); + options.Logger.LogLevel = LogLevel.Info; + }); + + Assert.AreEqual("initial", client.GetValue("fakeKey", string.Empty)); + await Task.Delay(100); + await WriteContent(SampleFileToCreate, "modified"); + await Task.Delay(1500); + + Assert.AreEqual("modified", client.GetValue("fakeKey", string.Empty)); + + File.Delete(SampleFileToCreate); + } + private static string GetJsonContent(string value) { return $"{{ \"f\": {{ \"fakeKey\": {{ \"v\": \"{value}\", \"p\": [] ,\"r\": [] }} }} }}"; diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index ece7955f..dae02667 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -11,80 +11,81 @@ namespace ConfigCat.Client.Override internal sealed class LocalFileDataSource : IOverrideDataSource { const int WAIT_TIME_FOR_UNLOCK = 200; // ms - const int MAX_WAIT_ITERATIONS = 50; + const int MAX_WAIT_ITERATIONS = 50; // ms + const int FILE_POLL_INTERVAL = 1000; // ms - private int isReading; + private DateTime fileLastWriteTime; private readonly string fullPath; private readonly ILogger logger; - private readonly FileSystemWatcher fileSystemWatcher; - private readonly TaskCompletionSource asyncInit = new(); - private readonly ManualResetEvent syncInit = new(false); + private readonly CancellationTokenSource cancellationTokenSource = new(); private volatile IDictionary overrideValues; public LocalFileDataSource(string filePath, bool autoReload, ILogger logger) { - this.fullPath = Path.GetFullPath(filePath); - if (autoReload) + if (!File.Exists(filePath)) { - var directory = Path.GetDirectoryName(this.fullPath); - if (string.IsNullOrWhiteSpace(directory)) - { - logger.Error($"Directory of {this.fullPath} not found to watch."); - } - else - { - this.fileSystemWatcher = new FileSystemWatcher(directory); - this.fileSystemWatcher.Changed += OnChanged; - this.fileSystemWatcher.Created += OnChanged; - this.fileSystemWatcher.Renamed += OnChanged; - this.fileSystemWatcher.EnableRaisingEvents = true; - logger.Information($"Watching {this.fullPath} for changes."); - } + logger.Error($"File {filePath} does not exist."); + return; } + this.fullPath = Path.GetFullPath(filePath); this.logger = logger; - _ = this.ReadFileAsync(this.fullPath); - } - private async void OnChanged(object sender, FileSystemEventArgs e) - { - // filter out events on temporary files - if (e.FullPath != this.fullPath) - return; + this.ReloadFile(); - this.logger.Information($"Reload file {e.FullPath}."); - await this.ReadFileAsync(e.FullPath); + if (autoReload) + { + this.StartWatch(); + } } - public IDictionary GetOverrides() - { - if (this.overrideValues != null) return this.overrideValues; - this.syncInit.WaitOne(); - return this.overrideValues ?? new Dictionary(); - } + public IDictionary GetOverrides() => this.overrideValues ?? new Dictionary(); + + public Task> GetOverridesAsync() => Task.FromResult(this.overrideValues ?? new Dictionary()); + - public async Task> GetOverridesAsync() + private void StartWatch() { - if (this.overrideValues != null) return this.overrideValues; - await this.asyncInit.Task.ConfigureAwait(false); - return this.overrideValues ?? new Dictionary(); + Task.Run(async () => + { + this.logger.Information($"Watching {this.fullPath} for changes."); + while (!this.cancellationTokenSource.IsCancellationRequested) + { + try + { + try + { + var lastWriteTime = File.GetLastWriteTimeUtc(this.fullPath); + if (lastWriteTime > this.fileLastWriteTime) + { + this.logger.Information($"Reload file {this.fullPath}."); + this.ReloadFile(); + } + } + + finally + { + await Task.Delay(FILE_POLL_INTERVAL, this.cancellationTokenSource.Token); + } + } + catch (OperationCanceledException) + { + // ignore exceptions from cancellation. + } + } + }); } - private async Task ReadFileAsync(string filePath) + private void ReloadFile() { - if (Interlocked.CompareExchange(ref this.isReading, 1, 0) != 0) - return; - try { for (int i = 1; i <= MAX_WAIT_ITERATIONS; i++) { try { - using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - var content = await reader.ReadToEndAsync(); + var content = File.ReadAllText(this.fullPath); var simplified = content.DeserializeOrDefault(); if (simplified?.Entries != null) { @@ -103,37 +104,23 @@ private async Task ReadFileAsync(string filePath) if (i >= MAX_WAIT_ITERATIONS) throw; - await Task.Delay(WAIT_TIME_FOR_UNLOCK); + Thread.Sleep(WAIT_TIME_FOR_UNLOCK); } - } + } } catch (Exception ex) { - this.logger.Error($"Failed to read file {filePath}. {ex}"); + this.logger.Error($"Failed to read file {this.fullPath}. {ex}"); } finally { - Interlocked.Exchange(ref this.isReading, 0); - this.SetInitialized(); + this.fileLastWriteTime = File.GetLastWriteTimeUtc(this.fullPath); } } - private void SetInitialized() - { - this.syncInit.Set(); - this.asyncInit.TrySetResult(true); - } - public void Dispose() { - if (this.fileSystemWatcher != null) - { - this.fileSystemWatcher.Changed -= OnChanged; - this.fileSystemWatcher.Created -= OnChanged; - this.fileSystemWatcher.Renamed -= OnChanged; - this.fileSystemWatcher.Dispose(); - } - this.syncInit.Dispose(); + this.cancellationTokenSource.Cancel(); } private sealed class SimplifiedConfig