Skip to content

Instantly share code, notes, and snippets.

@cocowalla
Last active July 7, 2022 13:49
Show Gist options
  • Save cocowalla/5d181b82b9a986c6761585000901d1b8 to your computer and use it in GitHub Desktop.
Save cocowalla/5d181b82b9a986c6761585000901d1b8 to your computer and use it in GitHub Desktop.
Simple debounce
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();
}
}
}
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);
}
}
}
...
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false, true)
.SetBasePath(Directory.GetCurrentDirectory())
.Build();
config.OnChange(() =>
{
// TODO: Config has been changed, do stuff here
});
...
@dazinator
Copy link

dazinator commented Dec 4, 2018

Good stuff.
Here is an additional version for handling callbacks that pass state objects,
Also I added an #if compilation statement to get it to compile for < netstandard 2.0.

  public class Debouncer<T> : 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 Debounce(Action<T> action, T state)
        {
            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(state);
#if NETSTANDARD2_0
                  task.Dispose();
#endif

            }, this.cts.Token);
        }

        public void Dispose()
        {
            this.cts.Cancel();
        }
    }

@dazinator
Copy link

dazinator commented Dec 4, 2018

Here is a simple example of watching for file changes using IFileProvider and the debouncer mechanism.

   public static class ChangeTokenHelper
    {     
        private const int DefaultDelayInMilliseconds = 500;

        /// <summary>
        /// Handle <see cref="ChangeToken.OnChange{TState}(Func{IChangeToken}, Action{TState}, TState)"/> after a delay that discards any duplicate invocations within that period of time.
        /// Useful for working around issue like described here: https://github.com/aspnet/AspNetCore/issues/2542
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="changeTokenFactory"></param>
        /// <param name="listener"></param>
        /// <param name="state"></param>
        /// <param name="delay"></param>
        /// <returns></returns>
        public static IDisposable OnChangeDebounce<T>(Func<IChangeToken> changeTokenFactory, Action<T> listener, T state, int delayInMilliseconds = DefaultDelayInMilliseconds)
        {
            var debouncer = new Debouncer<T>(TimeSpan.FromMilliseconds(delayInMilliseconds));
            var token = ChangeToken.OnChange<T>(changeTokenFactory, s => debouncer.Debounce(listener, s), state);
            return token;
        }       

    }

Usage:

            var pattern = "/amd/**/*.js"
            var debounceDelayInMilliseconds = 500;
            IFileProvider fileProvider = GetFileProvider();

            ChangeTokenHelper.OnChangeDebounce(() => fileProvider.Watch(pattern), (p) =>
            {
                _logger.LogInformation("file change detected, for watch pattern: " + p);              
            }, pattern, debounceDelayInMilliseconds);

@dazinator
Copy link

Just checking if you also had written a DebounceAsync version of this method, that takes a Func<T, Task> instead of an Action<T> to allow debounced methods to use async calls?

@cocowalla
Copy link
Author

@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 😉 😆

@lonix1
Copy link

lonix1 commented Mar 5, 2021

@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.

@lonix1
Copy link

lonix1 commented Jul 7, 2022

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