Implement debounce to avoid thrashing disk on settings update

This commit is contained in:
Pat Hartl 2025-10-06 19:25:24 -05:00
parent 3783dac472
commit 2b30158764
13 changed files with 94 additions and 87 deletions

View file

@ -70,7 +70,7 @@ public class AuthenticationService(
//SetOfflineMode(false);
TemporarilyOffline = false;
await settingsProvider.UpdateAsync(s =>
settingsProvider.Update(s =>
{
s.Authentication.ServerAddress = serverAddress;
s.Authentication.AccessToken = token.AccessToken;
@ -100,7 +100,7 @@ public class AuthenticationService(
await client.Authentication.RegisterAsync(username, password, passwordConfirmation);
await settingsProvider.UpdateAsync(s =>
settingsProvider.Update(s =>
{
s.Authentication.ServerAddress = client.Connection.GetServerAddress();
s.Authentication.AccessToken = tokenProvider.GetToken();

View file

@ -257,7 +257,7 @@ namespace LANCommander.Launcher.Services
var token = await client.Authentication.AuthenticateAsync(options.Username, options.Password, client.Connection.GetServerAddress());
await client.Settings.UpdateAsync(s =>
client.Settings.Update(s =>
{
s.Authentication.AccessToken = token.AccessToken;
s.Authentication.RefreshToken = token.RefreshToken;
@ -276,7 +276,7 @@ namespace LANCommander.Launcher.Services
{
await client.Authentication.LogoutAsync();
await client.Settings.UpdateAsync(s =>
client.Settings.Update(s =>
{
s.Authentication.AccessToken = String.Empty;
s.Authentication.RefreshToken = String.Empty;

View file

@ -154,7 +154,7 @@ namespace LANCommander.Launcher.Services
async Task SaveSettingsAsync()
{
await settingsProvider.UpdateAsync(s =>
settingsProvider.Update(s =>
{
s.Filter.Title = Filter.Title;
s.Filter.GroupBy = Filter.GroupBy;

View file

@ -62,10 +62,7 @@ namespace LANCommander.Launcher.Services
Process.Start(process);
await client.Settings.UpdateAsync(s =>
{
s.Launcher.LaunchCount = 0;
});
client.Settings.Update(s => s.Launcher.LaunchCount = 0);
Logger?.LogInformation("Shutting down to get out of the way");

View file

@ -90,11 +90,8 @@ namespace LANCommander.Launcher
var tokenProvider = app.Services.GetService<ITokenProvider>()!;
tokenProvider.SetToken(settingsProvider.CurrentValue.Authentication.AccessToken);
settingsProvider.UpdateAsync(s =>
{
s.LaunchCount++;
});
settingsProvider.Update(s => s.LaunchCount++);
Logger?.Debug("Starting application!");

View file

@ -10,7 +10,7 @@ namespace LANCommander.Launcher.Services
private readonly ResourceManager _resourceManager;
public LocalizationService(IOptions<Settings> settings)
{ ;
{
_resourceManager = new ResourceManager("LANCommander.Launcher.Resources.SharedResources", typeof(LocalizationService).Assembly);
var cultureInfo = new CultureInfo(settings.Value.Culture);

View file

@ -173,14 +173,4 @@
Loading = false;
}
}
async Task OfflineMode()
{
await Client.Settings.UpdateAsync(s =>
{
s.Authentication.OfflineModeEnabled = true;
});
NavigationManager.NavigateTo("/");
}
}

View file

@ -17,7 +17,7 @@ else if (!ConnectionClient.IsConfigured())
protected override async Task OnInitializedAsync()
{
ConnectionClient.OnConnect += (sender, args) => StateHasChanged();
ConnectionClient.OnOfflineModeEnabled += (sender, args) => StateHasChanged();
ConnectionClient.OnConnect += (sender, args) => InvokeAsync(StateHasChanged);
ConnectionClient.OnOfflineModeEnabled += (sender, args) => InvokeAsync(StateHasChanged);
}
}

View file

@ -55,7 +55,7 @@
Importing = true;
ImportStatusIndex = 0;
ImportStatusTotal = 0;
await NotifyProgress(ImportProgressState.Importing);
NotifyProgress(ImportProgressState.Importing);
await InvokeAsync(StateHasChanged);
MessageService.Info(LocalizationService.GetString("ImportStarted"), 2.5);
@ -66,12 +66,12 @@
}
}
private async Task NotifyProgress(ImportProgressState state)
private void NotifyProgress(ImportProgressState state)
{
if (!Progress.HasDelegate)
return;
await Progress.InvokeAsync(new ImportProgressEventArgs
Progress.InvokeAsync(new ImportProgressEventArgs
{
State = state,
Index = ImportStatusIndex,
@ -86,7 +86,7 @@
ImportStatusIndex = 0;
ImportStatusTotal = 0;
await NotifyProgress(ImportProgressState.Imported);
NotifyProgress(ImportProgressState.Imported);
MessageService.Success(LocalizationService.GetString("ImportComplete"), 3);
}
@ -96,7 +96,7 @@
ImportStatusIndex = 0;
ImportStatusTotal = 0;
await NotifyProgress(ImportProgressState.Failed);
NotifyProgress(ImportProgressState.Failed);
MessageService.Error(LocalizationService.GetString("ImportFailed"), 3);
}
@ -106,7 +106,7 @@
ImportStatusIndex = status.Index;
ImportStatusTotal = status.Total;
await NotifyProgress(ImportProgressState.Updated);
NotifyProgress(ImportProgressState.Updated);
}
public enum ImportProgressState

View file

@ -120,7 +120,7 @@
// Client.DefaultInstallDirectory = _settings.Games.InstallDirectories.First();
Client.Scripts.Debug = SettingsProvider.CurrentValue.Debug.EnableScriptDebugging;
await SettingsProvider.UpdateAsync(s =>
SettingsProvider.Update(s =>
{
s.Games.InstallDirectories = _installDirectories.Select(d => d.Path).ToArray();
s.Media.StoragePath = _settings.Media.StoragePath;

View file

@ -7,6 +7,5 @@ namespace LANCommander.SDK.Abstractions;
public interface ISettingsProvider
{
Settings CurrentValue { get; }
Task UpdateAsync(Settings settings);
Task UpdateAsync(Action<Settings> patch);
void Update(Action<Settings> patch);
}

View file

@ -1,9 +1,13 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.SDK;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using Microsoft.Extensions.Options;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using Settings = LANCommander.SDK.Models.Settings;
@ -13,6 +17,13 @@ public class SettingsProvider<TSettings> : ISettingsProvider
{
private readonly string _filePath;
private readonly IOptionsMonitor<TSettings> _optionsMonitor;
private readonly TimeSpan _debounceDelay = TimeSpan.FromMilliseconds(250);
private readonly SemaphoreSlim _ioGate = new(1, 1);
private readonly object _debounceLock = new();
private CancellationTokenSource? _saveCts;
public TSettings CurrentValue => _optionsMonitor.CurrentValue;
@ -22,62 +33,81 @@ public class SettingsProvider<TSettings> : ISettingsProvider
{
_filePath = Path.Join(AppPaths.GetConfigDirectory(), Settings.SETTINGS_FILE_NAME);
if (!File.Exists(_filePath))
{
var template = new TSettings();
Save(template);
}
_optionsMonitor = optionsMonitor;
}
public async Task UpdateAsync(TSettings settings) => await SaveAsync(settings);
public async Task UpdateAsync(Action<TSettings> patch)
public void Update(Action<TSettings> mutator)
{
patch.Invoke(_optionsMonitor.CurrentValue);
await SaveAsync(_optionsMonitor.CurrentValue);
mutator.Invoke(_optionsMonitor.CurrentValue);
ScheduleSave();
}
// ISettingsProvider (non-generic) explicit impls
async Task ISettingsProvider.UpdateAsync(Settings settings)
void ISettingsProvider.Update(Action<Settings> mutator)
{
// Allow callers who only know about base Settings to update
if (settings is TSettings typed)
mutator.Invoke(_optionsMonitor.CurrentValue);
ScheduleSave();
}
private void ScheduleSave()
{
CancellationTokenSource? ctsToStart;
lock (_debounceLock)
{
await UpdateAsync(typed);
_saveCts?.Cancel();
_saveCts?.Dispose();
_saveCts = new CancellationTokenSource();
ctsToStart = _saveCts;
}
else
_ = DebouncedSaveAsync(ctsToStart!.Token);
}
private async Task DebouncedSaveAsync(CancellationToken token)
{
try
{
await Task.Delay(_debounceDelay, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
var current = _optionsMonitor.CurrentValue;
await UpdateAsync(current);
}
await _ioGate.WaitAsync().ConfigureAwait(false);
try
{
await SaveAsync(CurrentValue, token).ConfigureAwait(false);
}
finally
{
_ioGate.Release();
}
}
async Task ISettingsProvider.UpdateAsync(Action<Settings> patch)
private async Task SaveAsync(TSettings settings, CancellationToken ct)
{
// Patch the current derived instance through the base type view
var current = _optionsMonitor.CurrentValue;
patch(current);
await UpdateAsync(current);
}
private void Save(TSettings settings)
{
var serializer = new SerializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
.Build();
File.WriteAllText(_filePath, serializer.Serialize(settings));
}
private async Task SaveAsync(TSettings settings)
{
var serializer = new SerializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
.Build();
await File.WriteAllTextAsync(_filePath, serializer.Serialize(settings));
var fso = new FileStreamOptions
{
Mode = FileMode.OpenOrCreate,
Access = FileAccess.Write,
Share = FileShare.None,
Options = FileOptions.Asynchronous | FileOptions.WriteThrough,
};
await using (var fs = new FileStream(_filePath, fso))
await using (var writer = new StreamWriter(fs, Encoding.UTF8))
{
var serializer = YamlSerializerFactory.Create();
var serialization = serializer.Serialize(settings);
await writer.WriteAsync(serialization).WaitAsync(ct);
await writer.FlushAsync(ct).ConfigureAwait(false);
await fs.FlushAsync(ct).ConfigureAwait(false);
}
}
}

View file

@ -69,10 +69,7 @@ public class ConnectionClient(
{
if (await PingAsync(uri))
{
await settingsProvider.UpdateAsync(s =>
{
s.Authentication.ServerAddress = uri;
});
settingsProvider.Update(s => s.Authentication.ServerAddress = uri);
logger?.LogInformation("Successfully discovered server at {ServerAddress}", uri.ToString());
@ -99,7 +96,7 @@ public class ConnectionClient(
if (!rpc.IsConnected())
await rpc.ConnectAsync(GetServerAddress());
await settingsProvider.UpdateAsync(s => s.Authentication.OfflineModeEnabled = false);
settingsProvider.Update(s => s.Authentication.OfflineModeEnabled = false);
OnConnect?.Invoke(this, EventArgs.Empty);
@ -120,10 +117,7 @@ public class ConnectionClient(
{
await DisconnectAsync();
await settingsProvider.UpdateAsync(s =>
{
s.Authentication.OfflineModeEnabled = true;
});
settingsProvider.Update(s => s.Authentication.OfflineModeEnabled = true);
OnOfflineModeEnabled?.Invoke(this, EventArgs.Empty);
}