diff --git a/src/Umbraco.Core/Configuration/Models/DatabaseServerMessengerSettings.cs b/src/Umbraco.Core/Configuration/Models/DatabaseServerMessengerSettings.cs new file mode 100644 index 0000000000..32b957d5d0 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/DatabaseServerMessengerSettings.cs @@ -0,0 +1,27 @@ +using System; + +namespace Umbraco.Core.Configuration.Models +{ + public class DatabaseServerMessengerSettings + { + /// + /// The maximum number of instructions that can be processed at startup; otherwise the server cold-boots (rebuilds its caches). + /// + public int MaxProcessingInstructionCount { get; set; } = 1000; + + /// + /// The time to keep instructions in the database; records older than this number will be pruned. + /// + public TimeSpan TimeToRetainInstructions { get; set; } = TimeSpan.FromDays(2); + + /// + /// The time to wait between each sync operations. + /// + public TimeSpan TimeBetweenSyncOperations { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// The time to wait between each prune operations. + /// + public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.FromMinutes(1); + } +} diff --git a/src/Umbraco.Core/Configuration/Models/DatabaseServerRegistrarSettings.cs b/src/Umbraco.Core/Configuration/Models/DatabaseServerRegistrarSettings.cs new file mode 100644 index 0000000000..1aa670fae6 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/DatabaseServerRegistrarSettings.cs @@ -0,0 +1,17 @@ +using System; + +namespace Umbraco.Core.Configuration.Models +{ + public class DatabaseServerRegistrarSettings + { + /// + /// The amount of time to wait between calls to the database on the background thread. + /// + public TimeSpan WaitTimeBetweenCalls { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// The time span to wait before considering a server stale, after it has last been accessed. + /// + public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.FromMinutes(2); + } +} diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index 37d0f4a8a2..0a566cd7fe 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -46,6 +46,10 @@ public bool DisableElectionForSingleServer { get; set; } = false; + public DatabaseServerRegistrarSettings DatabaseServerRegistrar { get; set; } = new DatabaseServerRegistrarSettings(); + + public DatabaseServerMessengerSettings DatabaseServerMessenger { get; set; } = new DatabaseServerMessengerSettings(); + public string RegisterType { get; set; } = string.Empty; public string DatabaseFactoryServerVersion { get; set; } = string.Empty; diff --git a/src/Umbraco.Core/Sync/DatabaseServerMessengerCallbacks.cs b/src/Umbraco.Core/Sync/DatabaseServerMessengerCallbacks.cs new file mode 100644 index 0000000000..7438762295 --- /dev/null +++ b/src/Umbraco.Core/Sync/DatabaseServerMessengerCallbacks.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.Core.Sync +{ + /// + /// Holds a list of callbacks associated with implementations of . + /// + public class DatabaseServerMessengerCallbacks + { + /// + /// A list of callbacks that will be invoked if the lastsynced.txt file does not exist. + /// + /// + /// These callbacks will typically be for e.g. rebuilding the xml cache file, or examine indexes, based on + /// the data in the database to get this particular server node up to date. + /// + public IEnumerable InitializingCallbacks { get; set; } + } +} diff --git a/src/Umbraco.Core/Sync/DatabaseServerMessengerOptions.cs b/src/Umbraco.Core/Sync/DatabaseServerMessengerOptions.cs deleted file mode 100644 index 6bfd6bff4a..0000000000 --- a/src/Umbraco.Core/Sync/DatabaseServerMessengerOptions.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Umbraco.Core.Sync -{ - /// - /// Provides options to the . - /// - public class DatabaseServerMessengerOptions - { - /// - /// Initializes a new instance of the with default values. - /// - public DatabaseServerMessengerOptions() - { - DaysToRetainInstructions = 2; // 2 days - ThrottleSeconds = 5; // 5 second - MaxProcessingInstructionCount = 1000; - PruneThrottleSeconds = 60; // 1 minute - } - - /// - /// The maximum number of instructions that can be processed at startup; otherwise the server cold-boots (rebuilds its caches). - /// - public int MaxProcessingInstructionCount { get; set; } - - /// - /// A list of callbacks that will be invoked if the lastsynced.txt file does not exist. - /// - /// - /// These callbacks will typically be for eg rebuilding the xml cache file, or examine indexes, based on - /// the data in the database to get this particular server node up to date. - /// - public IEnumerable InitializingCallbacks { get; set; } - - /// - /// The number of days to keep instructions in the database; records older than this number will be pruned. - /// - public int DaysToRetainInstructions { get; set; } - - /// - /// The number of seconds to wait between each sync operations. - /// - public int ThrottleSeconds { get; set; } - - /// - /// The number of seconds to wait between each prune operations. - /// - public int PruneThrottleSeconds { get; set; } - } -} diff --git a/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs b/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs index 390e89843e..f361eb7a67 100644 --- a/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs +++ b/src/Umbraco.Core/Sync/DatabaseServerRegistrar.cs @@ -15,19 +15,13 @@ namespace Umbraco.Core.Sync { private readonly Lazy _registrationService; - /// - /// Gets or sets the registrar options. - /// - public DatabaseServerRegistrarOptions Options { get; } - /// /// Initializes a new instance of the class. /// /// The registration service. /// Some options. - public DatabaseServerRegistrar(Lazy registrationService, DatabaseServerRegistrarOptions options) + public DatabaseServerRegistrar(Lazy registrationService) { - Options = options ?? throw new ArgumentNullException(nameof(options)); _registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService)); } diff --git a/src/Umbraco.Core/Sync/DatabaseServerRegistrarOptions.cs b/src/Umbraco.Core/Sync/DatabaseServerRegistrarOptions.cs deleted file mode 100644 index 58b66ca8e6..0000000000 --- a/src/Umbraco.Core/Sync/DatabaseServerRegistrarOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.ComponentModel; - -namespace Umbraco.Core.Sync -{ - /// - /// Provides options to the . - /// - public sealed class DatabaseServerRegistrarOptions - { - /// - /// Initializes a new instance of the class with default values. - /// - public DatabaseServerRegistrarOptions() - { - StaleServerTimeout = TimeSpan.FromMinutes(2); // 2 minutes - RecurringSeconds = 60; // do it every minute - } - - /// - /// The amount of seconds to wait between calls to the database on the background thread - /// - public int RecurringSeconds { get; set; } - - /// - /// The time span to wait before considering a server stale, after it has last been accessed. - /// - public TimeSpan StaleServerTimeout { get; set; } - } -} diff --git a/src/Umbraco.Core/Sync/IBatchedDatabaseServerMessenger.cs b/src/Umbraco.Core/Sync/IBatchedDatabaseServerMessenger.cs index d8ec82818d..560b7fb235 100644 --- a/src/Umbraco.Core/Sync/IBatchedDatabaseServerMessenger.cs +++ b/src/Umbraco.Core/Sync/IBatchedDatabaseServerMessenger.cs @@ -6,7 +6,7 @@ namespace Umbraco.Core.Sync public interface IBatchedDatabaseServerMessenger : IDatabaseServerMessenger { void FlushBatch(); - DatabaseServerMessengerOptions Options { get; } + DatabaseServerMessengerCallbacks Callbacks { get; } void Startup(); } } diff --git a/src/Umbraco.Infrastructure/BatchedDatabaseServerMessenger.cs b/src/Umbraco.Infrastructure/BatchedDatabaseServerMessenger.cs index e022219d15..187eced6e4 100644 --- a/src/Umbraco.Infrastructure/BatchedDatabaseServerMessenger.cs +++ b/src/Umbraco.Infrastructure/BatchedDatabaseServerMessenger.cs @@ -1,17 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; -using Newtonsoft.Json; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; using Umbraco.Core; using Umbraco.Core.Cache; -using Umbraco.Core.Sync; -using Umbraco.Web.Routing; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Hosting; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Scoping; -using Umbraco.Core.Hosting; +using Umbraco.Core.Sync; +using Umbraco.Web.Routing; namespace Umbraco.Web { @@ -34,12 +36,13 @@ namespace Umbraco.Web IProfilingLogger proflog, ILogger logger, IServerRegistrar serverRegistrar, - DatabaseServerMessengerOptions options, + DatabaseServerMessengerCallbacks callbacks, IHostingEnvironment hostingEnvironment, CacheRefresherCollection cacheRefreshers, IRequestCache requestCache, - IRequestAccessor requestAccessor) - : base(mainDom, scopeProvider, databaseFactory, proflog, logger, serverRegistrar, true, options, hostingEnvironment, cacheRefreshers) + IRequestAccessor requestAccessor, + IOptions globalSettings) + : base(mainDom, scopeProvider, databaseFactory, proflog, logger, serverRegistrar, true, callbacks, hostingEnvironment, cacheRefreshers, globalSettings) { _databaseFactory = databaseFactory; _requestCache = requestCache; @@ -89,7 +92,7 @@ namespace Umbraco.Web //Write the instructions but only create JSON blobs with a max instruction count equal to MaxProcessingInstructionCount using (var scope = ScopeProvider.CreateScope()) { - foreach (var instructionsBatch in instructions.InGroupsOf(Options.MaxProcessingInstructionCount)) + foreach (var instructionsBatch in instructions.InGroupsOf(GlobalSettings.DatabaseServerMessenger.MaxProcessingInstructionCount)) { WriteInstructions(scope, instructionsBatch); } @@ -143,7 +146,7 @@ namespace Umbraco.Web //only write the json blob with a maximum count of the MaxProcessingInstructionCount using (var scope = ScopeProvider.CreateScope()) { - foreach (var maxBatch in instructions.InGroupsOf(Options.MaxProcessingInstructionCount)) + foreach (var maxBatch in instructions.InGroupsOf(GlobalSettings.DatabaseServerMessenger.MaxProcessingInstructionCount)) { WriteInstructions(scope, maxBatch); } diff --git a/src/Umbraco.Infrastructure/Compose/DatabaseServerRegistrarAndMessengerComponent.cs b/src/Umbraco.Infrastructure/Compose/DatabaseServerRegistrarAndMessengerComponent.cs index 9cd2b187d5..3309c347db 100644 --- a/src/Umbraco.Infrastructure/Compose/DatabaseServerRegistrarAndMessengerComponent.cs +++ b/src/Umbraco.Infrastructure/Compose/DatabaseServerRegistrarAndMessengerComponent.cs @@ -1,18 +1,12 @@ using System; -using System.Threading; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Umbraco.Core; using Umbraco.Core.Composing; -using Umbraco.Core.Hosting; -using Umbraco.Core.Services; using Umbraco.Core.Services.Changes; using Umbraco.Core.Sync; -using Umbraco.Examine; using Umbraco.Web.Cache; using Umbraco.Web.PublishedCache; using Umbraco.Web.Routing; -using Umbraco.Web.Scheduling; using Umbraco.Web.Search; namespace Umbraco.Web.Compose @@ -36,9 +30,9 @@ namespace Umbraco.Web.Compose public sealed class DatabaseServerRegistrarAndMessengerComposer : ComponentComposer, ICoreComposer { - public static DatabaseServerMessengerOptions GetDefaultOptions(IServiceProvider factory) + public static DatabaseServerMessengerCallbacks GetCallbacks(IServiceProvider factory) { - return new DatabaseServerMessengerOptions + return new DatabaseServerMessengerCallbacks { //These callbacks will be executed if the server has not been synced // (i.e. it is a new server or the lastsynced.txt file has been removed) @@ -74,208 +68,55 @@ namespace Umbraco.Web.Compose { base.Compose(composition); - composition.SetDatabaseServerMessengerOptions(GetDefaultOptions); + composition.SetDatabaseServerMessengerCallbacks(GetCallbacks); composition.SetServerMessenger(); } } public sealed class DatabaseServerRegistrarAndMessengerComponent : IComponent { - private object _locker = new object(); - private readonly DatabaseServerRegistrar _registrar; private readonly IBatchedDatabaseServerMessenger _messenger; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly IServerRegistrationService _registrationService; - private readonly BackgroundTaskRunner _touchTaskRunner; - private readonly BackgroundTaskRunner _processTaskRunner; - private bool _started; - private IBackgroundTask[] _tasks; private readonly IRequestAccessor _requestAccessor; public DatabaseServerRegistrarAndMessengerComponent( - IServerRegistrar serverRegistrar, IServerMessenger serverMessenger, - IServerRegistrationService registrationService, - ILogger logger, - ILoggerFactory loggerFactory, - IApplicationShutdownRegistry hostingEnvironment, IRequestAccessor requestAccessor) { - _logger = logger; - _loggerFactory = loggerFactory; - _registrationService = registrationService; _requestAccessor = requestAccessor; - - // create task runner for DatabaseServerRegistrar - _registrar = serverRegistrar as DatabaseServerRegistrar; - if (_registrar != null) - { - _touchTaskRunner = new BackgroundTaskRunner("ServerRegistration", - new BackgroundTaskRunnerOptions { AutoStart = true }, _loggerFactory.CreateLogger>(), hostingEnvironment); - } - - // create task runner for BatchedDatabaseServerMessenger _messenger = serverMessenger as IBatchedDatabaseServerMessenger; - if (_messenger != null) - { - _processTaskRunner = new BackgroundTaskRunner("ServerInstProcess", - new BackgroundTaskRunnerOptions { AutoStart = true }, _loggerFactory.CreateLogger>(), hostingEnvironment); - } } public void Initialize() { - //We will start the whole process when a successful request is made - if (_registrar != null || _messenger != null) - _requestAccessor.RouteAttempt += RegisterBackgroundTasksOnce; + // The scheduled tasks - TouchServerTask and InstructionProcessTask - run as .NET Core hosted services. + // The former (as well as other hosted services that run outside of an HTTP request context) depends on the application URL + // being available (via IRequestAccessor), which can only be retrieved within an HTTP request (unless it's explicitly configured). + // Hence we hook up a one-off task on an HTTP request to ensure this is retrieved, which caches the value and makes it available + // for the hosted services to use when the HTTP request is not available. + _requestAccessor.RouteAttempt += EnsureApplicationUrlOnce; - // must come last, as it references some _variables + // Must come last, as it references some _variables _messenger?.Startup(); } public void Terminate() { } - /// - /// Handle when a request is made - /// - /// - /// - /// - /// We require this because: - /// - ApplicationContext.UmbracoApplicationUrl is initialized by UmbracoModule in BeginRequest - /// - RegisterServer is called on _requestAccessor.RouteAttempt which is triggered in ProcessRequest - /// we are safe, UmbracoApplicationUrl has been initialized - /// - private void RegisterBackgroundTasksOnce(object sender, RoutableAttemptEventArgs e) + private void EnsureApplicationUrlOnce(object sender, RoutableAttemptEventArgs e) { - switch (e.Outcome) + if (e.Outcome == EnsureRoutableOutcome.IsRoutable || e.Outcome == EnsureRoutableOutcome.NotDocumentRequest) { - case EnsureRoutableOutcome.IsRoutable: - case EnsureRoutableOutcome.NotDocumentRequest: - _requestAccessor.RouteAttempt -= RegisterBackgroundTasksOnce; - RegisterBackgroundTasks(); - break; + _requestAccessor.RouteAttempt -= EnsureApplicationUrlOnce; + EnsureApplicationUrl(); } } - private void RegisterBackgroundTasks() + private void EnsureApplicationUrl() { - // only perform this one time ever - LazyInitializer.EnsureInitialized(ref _tasks, ref _started, ref _locker, () => - { - var serverAddress = _requestAccessor.GetApplicationUrl().ToString(); - - return new[] - { - RegisterInstructionProcess(), - RegisterTouchServer(_registrationService, serverAddress) - }; - }); - } - - private IBackgroundTask RegisterInstructionProcess() - { - if (_messenger == null) - return null; - - var task = new InstructionProcessTask(_processTaskRunner, - 60000, //delay before first execution - _messenger.Options.ThrottleSeconds*1000, //amount of ms between executions - _messenger, - _logger); - _processTaskRunner.TryAdd(task); - return task; - } - - private IBackgroundTask RegisterTouchServer(IServerRegistrationService registrationService, string serverAddress) - { - if (_registrar == null) - return null; - - var task = new TouchServerTask(_touchTaskRunner, - 15000, //delay before first execution - _registrar.Options.RecurringSeconds*1000, //amount of ms between executions - registrationService, _registrar, serverAddress, _logger); - _touchTaskRunner.TryAdd(task); - return task; - } - - private class InstructionProcessTask : RecurringTaskBase - { - private readonly IDatabaseServerMessenger _messenger; - private readonly ILogger _logger; - - public InstructionProcessTask(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, - IDatabaseServerMessenger messenger, ILogger logger) - : base(runner, delayMilliseconds, periodMilliseconds) - { - _messenger = messenger; - _logger = logger; - } - - public override bool IsAsync => false; - - /// - /// Runs the background task. - /// - /// A value indicating whether to repeat the task. - public override bool PerformRun() - { - try - { - _messenger.Sync(); - } - catch (Exception e) - { - _logger.LogError(e, "Failed (will repeat)."); - } - return true; // repeat - } - } - - private class TouchServerTask : RecurringTaskBase - { - private readonly IServerRegistrationService _svc; - private readonly DatabaseServerRegistrar _registrar; - private readonly string _serverAddress; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - public TouchServerTask(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, - IServerRegistrationService svc, DatabaseServerRegistrar registrar, string serverAddress, ILogger logger) - : base(runner, delayMilliseconds, periodMilliseconds) - { - _svc = svc ?? throw new ArgumentNullException(nameof(svc)); - _registrar = registrar; - _serverAddress = serverAddress; - _logger = logger; - } - - public override bool IsAsync => false; - - /// - /// Runs the background task. - /// - /// A value indicating whether to repeat the task. - public override bool PerformRun() - { - try - { - // TouchServer uses a proper unit of work etc underneath so even in a - // background task it is safe to call it without dealing with any scope - _svc.TouchServer(_serverAddress, _svc.CurrentServerIdentity, _registrar.Options.StaleServerTimeout); - return true; // repeat - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update server record in database."); - return false; // probably stop if we have an error - } - } + // By retrieving the application URL within the context of a request (as we are here in responding + // to the IRequestAccessor's RouteAttempt event), we'll get it from the HTTP context and save it for + // future requests that may not be within an HTTP request (e.g. from hosted services). + _requestAccessor.GetApplicationUrl(); } } } diff --git a/src/Umbraco.Infrastructure/CompositionExtensions.cs b/src/Umbraco.Infrastructure/CompositionExtensions.cs index aa351657c5..3a7537d346 100644 --- a/src/Umbraco.Infrastructure/CompositionExtensions.cs +++ b/src/Umbraco.Infrastructure/CompositionExtensions.cs @@ -236,7 +236,7 @@ namespace Umbraco.Core /// The composition. /// A function creating the options. /// Use DatabaseServerRegistrarAndMessengerComposer.GetDefaultOptions to get the options that Umbraco would use by default. - public static void SetDatabaseServerMessengerOptions(this Composition composition, Func factory) + public static void SetDatabaseServerMessengerCallbacks(this Composition composition, Func factory) { composition.Services.AddUnique(factory); } @@ -247,7 +247,7 @@ namespace Umbraco.Core /// The composition. /// Options. /// Use DatabaseServerRegistrarAndMessengerComposer.GetDefaultOptions to get the options that Umbraco would use by default. - public static void SetDatabaseServerMessengerOptions(this Composition composition, DatabaseServerMessengerOptions options) + public static void SetDatabaseServerMessengerOptions(this Composition composition, DatabaseServerMessengerCallbacks options) { composition.Services.AddUnique(_ => options); } diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs new file mode 100644 index 0000000000..8d669178b0 --- /dev/null +++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Core; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Sync; + +namespace Umbraco.Infrastructure.HostedServices.ServerRegistration +{ + /// + /// Implements periodic database instruction processing as a hosted service. + /// + public class InstructionProcessTask : RecurringHostedServiceBase + { + private readonly IRuntimeState _runtimeState; + private readonly IDatabaseServerMessenger _messenger; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public InstructionProcessTask(IRuntimeState runtimeState, IServerMessenger messenger, ILogger logger, IOptions globalSettings) + : base(globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations, TimeSpan.FromMinutes(1)) + { + _runtimeState = runtimeState; + _messenger = messenger as IDatabaseServerMessenger ?? throw new ArgumentNullException(nameof(messenger)); + _logger = logger; + } + + internal override async Task PerformExecuteAsync(object state) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return; + } + + try + { + _messenger.Sync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed (will repeat)."); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs new file mode 100644 index 0000000000..0384a071c6 --- /dev/null +++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Core; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Services; +using Umbraco.Web; + +namespace Umbraco.Infrastructure.HostedServices.ServerRegistration +{ + /// + /// Implements periodic server "touching" (to mark as active/deactive) as a hosted service. + /// + public class TouchServerTask : RecurringHostedServiceBase + { + private readonly IRuntimeState _runtimeState; + private readonly IServerRegistrationService _serverRegistrationService; + private readonly IRequestAccessor _requestAccessor; + private readonly ILogger _logger; + private GlobalSettings _globalSettings; + + /// + /// Initializes a new instance of the class. + /// + public TouchServerTask(IRuntimeState runtimeState, IServerRegistrationService serverRegistrationService, IRequestAccessor requestAccessor, ILogger logger, IOptions globalSettings) + : base(globalSettings.Value.DatabaseServerRegistrar.WaitTimeBetweenCalls, TimeSpan.FromSeconds(15)) + { + _runtimeState = runtimeState; + _serverRegistrationService = serverRegistrationService ?? throw new ArgumentNullException(nameof(serverRegistrationService)); + _requestAccessor = requestAccessor; + _logger = logger; + _globalSettings = globalSettings.Value; + } + + internal override async Task PerformExecuteAsync(object state) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return; + } + + var serverAddress = _requestAccessor.GetApplicationUrl()?.ToString(); + if (serverAddress.IsNullOrWhiteSpace()) + { + _logger.LogWarning("No umbracoApplicationUrl for service (yet), skip."); + return; + } + + try + { + // TouchServer uses a proper unit of work etc underneath so even in a + // background task it is safe to call it without dealing with any scope. + _serverRegistrationService.TouchServer(serverAddress, _serverRegistrationService.CurrentServerIdentity, _globalSettings.DatabaseServerRegistrar.StaleServerTimeout); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update server record in database."); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Runtime/CoreInitialComposer.cs b/src/Umbraco.Infrastructure/Runtime/CoreInitialComposer.cs index 081c1b4d10..2ae6f74465 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreInitialComposer.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreInitialComposer.cs @@ -139,8 +139,7 @@ namespace Umbraco.Core.Runtime return singleServer ? (IServerRegistrar) new SingleServerRegistrar(f.GetRequiredService()) : new DatabaseServerRegistrar( - new Lazy(f.GetRequiredService), - new DatabaseServerRegistrarOptions()); + new Lazy(f.GetRequiredService)); }); // by default we'll use the database server messenger with default options (no callbacks), @@ -154,9 +153,11 @@ namespace Umbraco.Core.Runtime factory.GetRequiredService(), factory.GetRequiredService>(), factory.GetRequiredService(), - true, new DatabaseServerMessengerOptions(), + true, + new DatabaseServerMessengerCallbacks(), factory.GetRequiredService(), - factory.GetRequiredService() + factory.GetRequiredService(), + factory.GetRequiredService>() )); composition.CacheRefreshers() diff --git a/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs b/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs index 7066e5d806..856772148f 100644 --- a/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs +++ b/src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs @@ -5,16 +5,17 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NPoco; -using Microsoft.Extensions.Logging; using Umbraco.Core.Cache; -using Umbraco.Core.Composing; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Hosting; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; -using Umbraco.Core.Hosting; using Umbraco.Core.Scoping; namespace Umbraco.Core.Sync @@ -46,11 +47,13 @@ namespace Umbraco.Core.Sync private bool _syncing; private bool _released; - public DatabaseServerMessengerOptions Options { get; } + public DatabaseServerMessengerCallbacks Callbacks { get; } + + public GlobalSettings GlobalSettings { get; } public DatabaseServerMessenger( IMainDom mainDom, IScopeProvider scopeProvider, IUmbracoDatabaseFactory umbracoDatabaseFactory, IProfilingLogger proflog, ILogger logger, IServerRegistrar serverRegistrar, - bool distributedEnabled, DatabaseServerMessengerOptions options, IHostingEnvironment hostingEnvironment, CacheRefresherCollection cacheRefreshers) + bool distributedEnabled, DatabaseServerMessengerCallbacks callbacks, IHostingEnvironment hostingEnvironment, CacheRefresherCollection cacheRefreshers, IOptions globalSettings) : base(distributedEnabled) { ScopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); @@ -61,7 +64,8 @@ namespace Umbraco.Core.Sync _hostingEnvironment = hostingEnvironment; _cacheRefreshers = cacheRefreshers; Logger = logger; - Options = options ?? throw new ArgumentNullException(nameof(options)); + Callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + GlobalSettings = globalSettings.Value; _lastPruned = _lastSync = DateTime.UtcNow; _syncIdle = new ManualResetEvent(true); _distCacheFilePath = new Lazy(() => GetDistCacheFilePath(hostingEnvironment)); @@ -200,14 +204,14 @@ namespace Umbraco.Core.Sync //check for how many instructions there are to process, each row contains a count of the number of instructions contained in each //row so we will sum these numbers to get the actual count. var count = database.ExecuteScalar("SELECT SUM(instructionCount) FROM umbracoCacheInstruction WHERE id > @lastId", new {lastId = _lastId}); - if (count > Options.MaxProcessingInstructionCount) + if (count > GlobalSettings.DatabaseServerMessenger.MaxProcessingInstructionCount) { //too many instructions, proceed to cold boot Logger.LogWarning( "The instruction count ({InstructionCount}) exceeds the specified MaxProcessingInstructionCount ({MaxProcessingInstructionCount})." + " The server will skip existing instructions, rebuild its caches and indexes entirely, adjust its last synced Id" + " to the latest found in the database and maintain cache updates based on that Id.", - count, Options.MaxProcessingInstructionCount); + count, GlobalSettings.DatabaseServerMessenger.MaxProcessingInstructionCount); coldboot = true; } @@ -225,8 +229,8 @@ namespace Umbraco.Core.Sync SaveLastSynced(maxId); // execute initializing callbacks - if (Options.InitializingCallbacks != null) - foreach (var callback in Options.InitializingCallbacks) + if (Callbacks.InitializingCallbacks != null) + foreach (var callback in Callbacks.InitializingCallbacks) callback(); } @@ -248,7 +252,7 @@ namespace Umbraco.Core.Sync if (_released) return; - if ((DateTime.UtcNow - _lastSync).TotalSeconds <= Options.ThrottleSeconds) + if ((DateTime.UtcNow - _lastSync) <= GlobalSettings.DatabaseServerMessenger.TimeBetweenSyncOperations) return; //Set our flag and the lock to be in it's original state (i.e. it can be awaited) @@ -265,7 +269,7 @@ namespace Umbraco.Core.Sync ProcessDatabaseInstructions(scope.Database); //Check for pruning throttling - if (_released || (DateTime.UtcNow - _lastPruned).TotalSeconds <= Options.PruneThrottleSeconds) + if (_released || (DateTime.UtcNow - _lastPruned) <= GlobalSettings.DatabaseServerMessenger.TimeBetweenPruneOperations) { scope.Complete(); return; @@ -447,7 +451,7 @@ namespace Umbraco.Core.Sync /// private void PruneOldInstructions(IUmbracoDatabase database) { - var pruneDate = DateTime.UtcNow.AddDays(-Options.DaysToRetainInstructions); + var pruneDate = DateTime.UtcNow - GlobalSettings.DatabaseServerMessenger.TimeToRetainInstructions; // using 2 queries is faster than convoluted joins diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs index 6c1284854e..cdb98f7fa5 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs @@ -37,10 +37,14 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.HostedServices VerifyNotificationsNotSent(); } - [Test] - public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run() + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel) { - var sut = CreateHealthCheckNotifier(runtimeLevel: RuntimeLevel.Boot); + var sut = CreateHealthCheckNotifier(runtimeLevel: runtimeLevel); await sut.PerformExecuteAsync(null); VerifyNotificationsNotSent(); } diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs index e0fd2a4acc..c016e52e54 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs @@ -27,10 +27,14 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.HostedServices VerifyScheduledPublishingNotPerformed(); } - [Test] - public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run() + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel) { - var sut = CreateScheduledPublishing(runtimeLevel: RuntimeLevel.Boot); + var sut = CreateScheduledPublishing(runtimeLevel: runtimeLevel); await sut.PerformExecuteAsync(null); VerifyScheduledPublishingNotPerformed(); } diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs new file mode 100644 index 0000000000..86644afc77 --- /dev/null +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Core; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Sync; +using Umbraco.Infrastructure.HostedServices.ServerRegistration; + +namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration +{ + [TestFixture] + public class InstructionProcessTaskTests + { + private Mock _mockDatabaseServerMessenger; + + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel) + { + var sut = CreateInstructionProcessTask(runtimeLevel: runtimeLevel); + await sut.PerformExecuteAsync(null); + VerifyMessengerNotSynced(); + } + + [Test] + public async Task Executes_And_Touches_Server() + { + var sut = CreateInstructionProcessTask(); + await sut.PerformExecuteAsync(null); + VerifyMessengerSynced(); + } + + private InstructionProcessTask CreateInstructionProcessTask(RuntimeLevel runtimeLevel = RuntimeLevel.Run) + { + var mockRunTimeState = new Mock(); + mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel); + + var mockLogger = new Mock>(); + + _mockDatabaseServerMessenger = new Mock(); + + var settings = new GlobalSettings(); + + return new InstructionProcessTask(mockRunTimeState.Object, _mockDatabaseServerMessenger.Object, mockLogger.Object, Options.Create(settings)); + } + + private void VerifyMessengerNotSynced() + { + VerifyMessengerSyncedTimes(Times.Never()); + } + + private void VerifyMessengerSynced() + { + VerifyMessengerSyncedTimes(Times.Once()); + } + + private void VerifyMessengerSyncedTimes(Times times) + { + _mockDatabaseServerMessenger.Verify(x => x.Sync(), times); + } + } +} diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs new file mode 100644 index 0000000000..499ff05f04 --- /dev/null +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Core; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Services; +using Umbraco.Infrastructure.HostedServices.ServerRegistration; +using Umbraco.Web; + +namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration +{ + [TestFixture] + public class TouchServerTaskTests + { + private Mock _mockServerRegistrationService; + + private const string _applicationUrl = "https://mysite.com/"; + private const string _serverIdentity = "Test/1"; + private readonly TimeSpan _staleServerTimeout = TimeSpan.FromMinutes(2); + + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel) + { + var sut = CreateTouchServerTask(runtimeLevel: runtimeLevel); + await sut.PerformExecuteAsync(null); + VerifyServerNotTouched(); + } + + [Test] + public async Task Does_Not_Execute_When_Application_Url_Is_Not_Available() + { + var sut = CreateTouchServerTask(applicationUrl: string.Empty); + await sut.PerformExecuteAsync(null); + VerifyServerNotTouched(); + } + + [Test] + public async Task Executes_And_Touches_Server() + { + var sut = CreateTouchServerTask(); + await sut.PerformExecuteAsync(null); + VerifyServerTouched(); + } + + private TouchServerTask CreateTouchServerTask(RuntimeLevel runtimeLevel = RuntimeLevel.Run, string applicationUrl = _applicationUrl) + { + var mockRequestAccessor = new Mock(); + mockRequestAccessor.Setup(x => x.GetApplicationUrl()).Returns(!string.IsNullOrEmpty(applicationUrl) ? new Uri(_applicationUrl) : null); + + var mockRunTimeState = new Mock(); + mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel); + + var mockLogger = new Mock>(); + + _mockServerRegistrationService = new Mock(); + _mockServerRegistrationService.SetupGet(x => x.CurrentServerIdentity).Returns(_serverIdentity); + + var settings = new GlobalSettings + { + DatabaseServerRegistrar = new DatabaseServerRegistrarSettings + { + StaleServerTimeout = _staleServerTimeout, + } + }; + + return new TouchServerTask(mockRunTimeState.Object, _mockServerRegistrationService.Object, mockRequestAccessor.Object, + mockLogger.Object, Options.Create(settings)); + } + + private void VerifyServerNotTouched() + { + VerifyServerTouchedTimes(Times.Never()); + } + + private void VerifyServerTouched() + { + VerifyServerTouchedTimes(Times.Once()); + } + + private void VerifyServerTouchedTimes(Times times) + { + _mockServerRegistrationService + .Verify(x => x.TouchServer( + It.Is(y => y == _applicationUrl), + It.Is(y => y == _serverIdentity), + It.Is(y => y == _staleServerTimeout)), + times); + } + } +} diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs index 985c568a08..c5104c0fdc 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Options; using Umbraco.Core.Configuration.Models; -using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; using Umbraco.Web.Common.Lifetime; using Umbraco.Web.Routing; @@ -43,9 +42,8 @@ namespace Umbraco.Web.Common.AspNetCore RouteAttempt?.Invoke(sender, new RoutableAttemptEventArgs(reason, _umbracoContextAccessor.UmbracoContext)); } - - public string GetRequestValue(string name) => GetFormValue(name) ?? GetQueryStringValue(name); + public string GetFormValue(string name) { var request = _httpContextAccessor.GetRequiredHttpContext().Request; @@ -58,14 +56,15 @@ namespace Umbraco.Web.Common.AspNetCore public event EventHandler EndRequest; public event EventHandler RouteAttempt; + public Uri GetRequestUrl() => _httpContextAccessor.HttpContext != null ? new Uri(_httpContextAccessor.HttpContext.Request.GetEncodedUrl()) : null; + public Uri GetApplicationUrl() { //Fixme: This causes problems with site swap on azure because azure pre-warms a site by calling into `localhost` and when it does that // it changes the URL to `localhost:80` which actually doesn't work for pinging itself, it only works internally in Azure. The ironic part // about this is that this is here specifically for the slot swap scenario https://issues.umbraco.org/issue/U4-10626 - // see U4-10626 - in some cases we want to reset the application url // (this is a simplified version of what was in 7.x) // note: should this be optional? is it expensive? @@ -77,7 +76,10 @@ namespace Umbraco.Web.Common.AspNetCore var request = _httpContextAccessor.HttpContext?.Request; - if (request is null) return _currentApplicationUrl; + if (request is null) + { + return _currentApplicationUrl; + } var url = UriHelper.BuildAbsolute(request.Scheme, request.Host); var change = url != null && !_applicationUrls.Contains(url); diff --git a/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs b/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs index 48ef00e1c8..c98cbca39e 100644 --- a/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/UmbracoCoreServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.SqlSyntax; using Umbraco.Core.Runtime; using Umbraco.Infrastructure.HostedServices; +using Umbraco.Infrastructure.HostedServices.ServerRegistration; using Umbraco.Web.Common.AspNetCore; using Umbraco.Web.Common.Profiler; using ConnectionStrings = Umbraco.Core.Configuration.Models.ConnectionStrings; @@ -298,6 +299,9 @@ namespace Umbraco.Extensions services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + return services; }