Search code examples
c#dependency-injectionasp.net-corestartup

What is the correct place to add a database driven scheduler in ASP.NET Core?


I have added a Timer to Startup class of an ASP.Net Core application. It fires on some time period and do operations like logging a sample text. I need it to be able to do database driven operations like adding a record to a table. So I try to get AppDbContext from DI but it is always null. Please see the code:

    public class Scheduler
{
    static Timer _timer;
    static bool _isStarted;
    static ILogger<Scheduler> _logger;
    const int dueTimeMin = 1;
    const int periodMin = 1;

    public static void Start(IServiceProvider serviceProvider)
    {
        if (_isStarted)
            throw new Exception("Currently is started");

        _logger = (ILogger<Scheduler>)serviceProvider.GetService(typeof(ILogger<Scheduler>));

        var autoEvent = new AutoResetEvent(false);
        var operationClass = new OperationClass(serviceProvider);
        _timer = new Timer(operationClass.DoOperation, autoEvent, dueTimeMin * 60 * 1000, periodMin * 60 * 1000);
        _isStarted = true;
        _logger.LogInformation("Scheduler started");            
    }
}

public class OperationClass
{
    IServiceProvider _serviceProvider;
    ILogger<OperationClass> _logger;
    AppDbContext _appDbContext;

    public OperationClass(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _logger = (ILogger<OperationClass>)serviceProvider.GetService(typeof(ILogger<OperationClass>));
        _appDbContext = (AppDbContext)_serviceProvider.GetService(typeof(AppDbContext));
    }

    public void DoOperation(Object stateInfo)
    {
        try     
        {
            _logger.LogInformation("Timer elapsed.");

            if (_appDbContext == null)
                throw new Exception("appDbContext is null");

            _appDbContext.PlayNows.Add(new PlayNow
            {
                DateTime = DateTime.Now
            });

            _appDbContext.SaveChanges();
        }
        catch (Exception exception)
        {
            _logger.LogError($"Error in DoOperation: {exception.Message}");
        }
    }
}

And here it is the code from Startup:

        public Startup(IHostingEnvironment env, IServiceProvider serviceProvider)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
            .AddEnvironmentVariables();
        Configuration = builder.Build();

        AppHelper.InitializeMapper();
        Scheduler.Start(serviceProvider);
    }

I guess I am calling Scheduler.Start in a wrong place. Seems that AppDbContext is not ready yet.

What is the correct place to call Scheduler.Start?


Solution

  • When you are running code on a background thread, you should always begin a new 'scope' for your DI container on that background thread and resolve from that scope.

    So what you should do is:

    • Create a new scope inside the event
    • Resolve OperationClass from that scope
    • Inside OperationClass only rely on constructor injection; not on Service Location.

    Your code should look something like this:

    public class Scheduler
    {
        static Timer _timer;
        const int dueTimeMin = 1;
        const int periodMin = 1;
    
        public static void Start(IServiceScopeFactory scopeFactory)
        {
            if (scopeFactory == null) throw new ArgumentNullException("scopeFactory");
            _timer = new Timer(_ =>
            {
                using (var scope = new scopeFactory.CreateScope())
                {
                    scope.GetRequiredService<OperationClass>().DoOperation();
                }
            }, new AutoResetEvent(false), dueTimeMin * 60 * 1000, periodMin * 60 * 1000);
        }
    }
    

    Here Start depends on IServiceScopeFactory. IServiceScopeFactory can be resolved from the IServiceProvider.

    Your OperationClass will becomes something like the following:

    public class OperationClass
    {
        private readonly ILogger<OperationClass> _logger;
        private readonly AppDbContext _appDbContext;
    
        public OperationClass(ILogger<OperationClass> logger, AppDbContext appDbContext)
        {
            if (logger == null) throw new ArgumentNullException(nameof(logger));
            if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext));
    
            _logger = logger;
            _appDbContext = appDbContext;
        }
    
        public void DoOperation()
        {
            try     
            {
                _logger.LogInformation("DoOperation.");
    
                _appDbContext.PlayNows.Add(new PlayNow
                {
                    DateTime = DateTime.Now
                });
    
                _appDbContext.SaveChanges();
            }
            catch (Exception exception)
            {
                _logger.LogError($"Error in DoOperation: {exception}");
            }
        }
    }
    

    Although not documentation particular to the .NET Core Container, this documentation provides a more detailed information about how to work with a DI Container in a multi-threaded application.