using Microsoft.Extensions.DependencyInjection; using Nop.Core; using Nop.Core.Configuration; using Nop.Core.Domain.ScheduleTasks; using Nop.Core.Http; using Nop.Core.Infrastructure; using Nop.Data; using Nop.Services.Localization; using Nop.Services.Logging; namespace Nop.Services.ScheduleTasks; /// /// Represents task manager /// public partial class TaskScheduler : ITaskScheduler { #region Fields protected static readonly List _taskThreads = new(); protected readonly AppSettings _appSettings; private readonly IServiceScopeFactory _serviceScopeFactory; private Task _taskThread; #endregion #region Ctor public TaskScheduler(AppSettings appSettings, IHttpClientFactory httpClientFactory, IServiceScopeFactory serviceScopeFactory) { _appSettings = appSettings; _serviceScopeFactory = serviceScopeFactory; TaskThread.HttpClientFactory = httpClientFactory; TaskThread.ServiceScopeFactory = serviceScopeFactory; } #endregion #region Methods /// /// Initializes task scheduler /// /// A task that represents the asynchronous operation public async Task InitializeAsync() { if (!DataSettingsManager.IsDatabaseInstalled()) return; if (_taskThreads.Any()) return; using var scope = _serviceScopeFactory.CreateScope(); var scheduleTaskService = scope.ServiceProvider.GetService() ?? throw new NullReferenceException($"Can't get {nameof(IScheduleTaskService)} implementation from the scope"); //initialize and start schedule tasks var scheduleTasks = (await scheduleTaskService.GetAllTasksAsync()) .OrderBy(x => x.Seconds) .ToList(); var storeContext = scope.ServiceProvider.GetService() ?? throw new NullReferenceException($"Can't get {nameof(IStoreContext)} implementation from the scope"); var store = await storeContext.GetCurrentStoreAsync(); var scheduleTaskUrl = $"{store.Url.TrimEnd('/')}/{NopTaskDefaults.ScheduleTaskPath}"; var timeout = _appSettings.Get().ScheduleTaskRunTimeout; foreach (var scheduleTask in scheduleTasks) { var taskThread = new TaskThread(scheduleTask, scheduleTaskUrl, timeout) { Seconds = scheduleTask.Seconds }; //sometimes a task period could be set to several hours (or even days) //in this case a probability that it'll be run is quite small (an application could be restarted) //calculate time before start an interrupted task if (scheduleTask.LastStartUtc.HasValue) { //seconds left since the last start var secondsLeft = (DateTime.UtcNow - scheduleTask.LastStartUtc).Value.TotalSeconds; if (secondsLeft >= scheduleTask.Seconds) //run now (immediately) taskThread.InitSeconds = 0; else //calculate start time //and round it (so "ensureRunOncePerPeriod" parameter was fine) taskThread.InitSeconds = (int)(scheduleTask.Seconds - secondsLeft) + 1; } else if (scheduleTask.LastEnabledUtc.HasValue) { //seconds left since the last enable var secondsLeft = (DateTime.UtcNow - scheduleTask.LastEnabledUtc).Value.TotalSeconds; if (secondsLeft >= scheduleTask.Seconds) //run now (immediately) taskThread.InitSeconds = 0; else //calculate start time //and round it (so "ensureRunOncePerPeriod" parameter was fine) taskThread.InitSeconds = (int)(scheduleTask.Seconds - secondsLeft) + 1; } else //first start of a task taskThread.InitSeconds = scheduleTask.Seconds; _taskThreads.Add(taskThread); } } /// /// Starts the task scheduler /// /// A task that represents the asynchronous operation public Task StartSchedulerAsync() { _taskThread = Task.WhenAll(_taskThreads.Select(taskThread => taskThread.InitTimerAsync())); return Task.CompletedTask; } /// /// Stops the task scheduler /// /// A task that represents the asynchronous operation public async Task StopSchedulerAsync() { foreach (var taskThread in _taskThreads) taskThread.Dispose(); try { if (_taskThread is { IsCompleted: false }) await _taskThread; } catch { //ignore } _taskThread?.Dispose(); _taskThreads.Clear(); } #endregion #region Nested class /// /// Represents task thread /// protected partial class TaskThread : IDisposable { #region Fields protected readonly string _scheduleTaskUrl; protected readonly ScheduleTask _scheduleTask; protected readonly int? _timeout; protected bool _disposed; protected readonly CancellationTokenSource _cancellationToken; #endregion #region Ctor public TaskThread(ScheduleTask task, string scheduleTaskUrl, int? timeout) { _scheduleTaskUrl = scheduleTaskUrl; _scheduleTask = task; _timeout = timeout; IsStarted = false; _cancellationToken = new CancellationTokenSource(); Seconds = 10 * 60; } #endregion #region Utilities /// /// Run task /// /// A task that represents the asynchronous operation protected virtual async Task RunAsync() { if (Seconds <= 0) return; StartedUtc = DateTime.UtcNow; IsRunning = true; HttpClient client = null; try { //create and configure client client = HttpClientFactory.CreateClient(NopHttpDefaults.DefaultHttpClient); if (_timeout.HasValue) client.Timeout = TimeSpan.FromMilliseconds(_timeout.Value); //send post data var data = new FormUrlEncodedContent(new[] { new KeyValuePair("taskType", _scheduleTask.Type) }); await client.PostAsync(_scheduleTaskUrl, data); } catch (Exception ex) { using var scope = ServiceScopeFactory.CreateScope(); // Resolve var logger = EngineContext.Current.Resolve(scope); var localizationService = EngineContext.Current.Resolve(scope); var storeContext = EngineContext.Current.Resolve(scope); var message = ex.InnerException?.GetType() == typeof(TaskCanceledException) ? await localizationService.GetResourceAsync("ScheduleTasks.TimeoutError") : ex.Message; var store = await storeContext.GetCurrentStoreAsync(); message = string.Format(await localizationService.GetResourceAsync("ScheduleTasks.Error"), _scheduleTask.Name, message, _scheduleTask.Type, store.Name, _scheduleTaskUrl); await logger.ErrorAsync(message, ex); } finally { client?.Dispose(); } IsRunning = false; } /// /// Protected implementation of Dispose pattern. /// /// Specifies whether to disposing resources protected virtual void Dispose(bool disposing) { if (_disposed) return; IsStarted = false; _disposed = true; _cancellationToken.Cancel(); } #endregion #region Methods /// /// Disposes the instance /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Inits a timer /// public async Task InitTimerAsync() { var interval = TimeSpan.FromMilliseconds(Interval); IsStarted = true; using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(InitInterval > 0 ? InitInterval : 1)); while (await timer.WaitForNextTickAsync(_cancellationToken.Token)) { if (IsDisposed) break; try { await RunAsync(); } catch { // ignore } finally { if (!IsDisposed && RunOnlyOnce) Dispose(); } if (timer.Period != interval) timer.Period = interval; } } #endregion #region Properties internal static IHttpClientFactory HttpClientFactory { get; set; } internal static IServiceScopeFactory ServiceScopeFactory { get; set; } /// /// Gets or sets the interval in seconds at which to run the tasks /// public int Seconds { get; set; } /// /// Get or set the interval before timer first start /// public int InitSeconds { get; set; } /// /// Get or sets a datetime when thread has been started /// public DateTime StartedUtc { get; protected set; } /// /// Get or sets a value indicating whether thread is running /// public bool IsRunning { get; protected set; } /// /// Gets the interval (in milliseconds) at which to run the task /// public int Interval { get { //if somebody entered more than "2147483" seconds, then an exception could be thrown (exceeds int.MaxValue) var interval = Seconds * 1000; if (interval <= 0) interval = int.MaxValue; return interval; } } /// /// Gets the due time interval (in milliseconds) at which to begin start the task /// public int InitInterval { get { //if somebody entered less than "0" seconds, then an exception could be thrown var interval = InitSeconds * 1000; if (interval <= 0) interval = 0; return interval; } } /// /// Gets or sets a value indicating whether the thread would be run only once (on application start) /// public bool RunOnlyOnce { get; set; } /// /// Gets a value indicating whether the timer is started /// public bool IsStarted { get; set; } /// /// Gets a value indicating whether the timer is disposed /// public bool IsDisposed => _disposed; #endregion } #endregion }