Last active
July 7, 2022 13:49
-
-
Save cocowalla/5d181b82b9a986c6761585000901d1b8 to your computer and use it in GitHub Desktop.
Simple debounce
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Threading; | |
using System.Threading.Tasks; | |
namespace MyNamespace | |
{ | |
public class Debouncer : IDisposable | |
{ | |
private readonly CancellationTokenSource cts = new CancellationTokenSource(); | |
private readonly TimeSpan waitTime; | |
private int counter; | |
public Debouncer(TimeSpan? waitTime = null) | |
{ | |
this.waitTime = waitTime ?? TimeSpan.FromSeconds(3); | |
} | |
public void Debouce(Action action) | |
{ | |
var current = Interlocked.Increment(ref this.counter); | |
Task.Delay(this.waitTime).ContinueWith(task => | |
{ | |
// Is this the last task that was queued? | |
if (current == this.counter && !this.cts.IsCancellationRequested) | |
action(); | |
task.Dispose(); | |
}, this.cts.Token); | |
} | |
public void Dispose() | |
{ | |
this.cts.Cancel(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using Microsoft.Extensions.Configuration; | |
using Microsoft.Extensions.Primitives; | |
namespace MyNamespace | |
{ | |
public static class IConfigurationExtensions | |
{ | |
/// <summary> | |
/// Perform an action when configuration changes. Note this requires config sources to be added with | |
/// `reloadOnChange` enabled | |
/// </summary> | |
/// <param name="config">Configuration to watch for changes</param> | |
/// <param name="action">Action to perform when <paramref name="config"/> is changed</param> | |
public static void OnChange(this IConfiguration config, Action action) | |
{ | |
// IConfiguration's change detection is based on FileSystemWatcher, which will fire multiple change | |
// events for each change - Microsoft's code is buggy in that it doesn't bother to debounce/dedupe | |
// https://github.com/aspnet/AspNetCore/issues/2542 | |
var debouncer = new Debouncer(TimeSpan.FromSeconds(3)); | |
ChangeToken.OnChange<object>(config.GetReloadToken, _ => debouncer.Debouce(action), null); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
... | |
var config = new ConfigurationBuilder() | |
.AddJsonFile("appsettings.json", false, true) | |
.SetBasePath(Directory.GetCurrentDirectory()) | |
.Build(); | |
config.OnChange(() => | |
{ | |
// TODO: Config has been changed, do stuff here | |
}); | |
... |
@cocowalla, @dazinator I landed here via dotnet/aspnetcore#2542. Thanks for setting me on the right path.
I use this async approach, using a hosted service. This is better as we don't have to run async-in-sync and blow up something accidentally.
Debouncer.cs
:
public sealed class Debouncer : IDisposable {
public Debouncer(TimeSpan? delay) => _delay = delay ?? TimeSpan.FromSeconds(2);
private readonly TimeSpan _delay;
private CancellationTokenSource? previousCancellationToken = null;
public async Task Debounce(Action action) {
_ = action ?? throw new ArgumentNullException(nameof(action));
Cancel();
previousCancellationToken = new CancellationTokenSource();
try {
await Task.Delay(_delay, previousCancellationToken.Token);
await Task.Run(action, previousCancellationToken.Token);
}
catch (TaskCanceledException) { } // can swallow exception as nothing more to do if task cancelled
}
public void Cancel() {
if (previousCancellationToken != null) {
previousCancellationToken.Cancel();
previousCancellationToken.Dispose();
}
}
public void Dispose() => Cancel();
}
ConfigWatcher.cs
:
public sealed class ConfigWatcher : IHostedService, IDisposable {
public ConfigWatcher(IServiceScopeFactory scopeFactory, ILogger<ConfigWatcher> logger) {
_scopeFactory = scopeFactory;
_logger = logger;
}
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ConfigWatcher> _logger;
private readonly Debouncer _debouncer = new(TimeSpan.FromSeconds(2));
private void OnConfigurationReloaded() {
_logger.LogInformation("Configuration reloaded");
// ... can do more stuff here, e.g. validate config
}
public Task StartAsync(CancellationToken cancellationToken) {
ChangeToken.OnChange(
() => { // resolve config from scope rather than ctor injection, in case it changes (this hosted service is a singleton)
using var scope = _scopeFactory.CreateScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
return configuration.GetReloadToken();
},
async () => await _debouncer.Debounce(OnConfigurationReloaded)
);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => _debouncer.Dispose();
}
Startup.cs
:
services.AddHostedService<ConfigWatcher>(); // registered as singleton
Works for me. Here's a related SO question with this code.
Hopefully an async overload will make it into v7 later this year. If this is still important to you please upvote to help prioritise it? :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@dazinator I had a search, but alas no, I don't seem to. Interested to see what you come up with tho, as it's the kind of thing I'm bound to need myself at some point 😉 😆