Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Log configuration changes in ASP.NET Core

I want to log when configuration is changed.

I do this in Program.cs or Startup.cs:

ChangeToken.OnChange(
  () => configuration.GetReloadToken(),
  state => logger.Information("Configuration reloaded"),
  (object)null
);

But I get double change reports, so it needs to be debounced. The advice is to do this:

ChangeToken.OnChange(
  () => configuration.GetReloadToken(),
  state => { Thread.Sleep(2000); logger.Information("Configuration reloaded"); },
  (object)null
);

I'm using 2000 here as I'm not sure what's a reasonable value.

I've found that sometimes I still get multiple change detections, separated by 2000 milliseconds. So the debounce doesn't work for me, just causes a delay between reported changes. If I set a high value then I only get one report, but that isn't ideal (and conceals the problem).

So I'd like to know:

  • Is this really debouncing, or just queueing reported changes?
  • I've used values from 1000 to 5000 to varying success. What are others using?
  • Is the sleep issued to the server's main thread? I hope not!
like image 547
lonix Avatar asked Nov 16 '25 05:11

lonix


1 Answers

The multiple change detection issue discussed here (and at least a dozen other issues in multiple repos) is something they refuse to address using a built-in mechanism.

The MS docs use a file hashing approach, but I think that debouncing is better.

My solution uses async (avoids async-in-sync which could blow up something accidentally) and a hosted service that debounces change detections.

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
like image 77
lonix Avatar answered Nov 17 '25 20:11

lonix