diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 66b3687cc2..8068b074fb 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -90,6 +90,9 @@ public static partial class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + // Database availability check. + builder.Services.AddUnique(); + // Add runtime mode validation builder.Services.AddSingleton(); builder.RuntimeModeValidators() diff --git a/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..f88c08d57e --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot using the default method of 5 attempts with a 1 second delay between each one. +/// +internal class DefaultDatabaseAvailabilityCheck : IDatabaseAvailabilityCheck +{ + private const int NumberOfAttempts = 5; + private const int DefaultAttemptDelayMilliseconds = 1000; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + public DefaultDatabaseAvailabilityCheck(ILogger logger) => _logger = logger; + + /// + /// Gets or sets the number of milliseconds to delay between attempts. + /// + /// + /// Exposed for testing purposes, hence settable only internally. + /// + public int AttemptDelayMilliseconds { get; internal set; } = DefaultAttemptDelayMilliseconds; + + /// + public bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory) + { + bool canConnect; + for (var i = 0; ;) + { + canConnect = databaseFactory.CanConnect; + if (canConnect || ++i == NumberOfAttempts) + { + break; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Could not immediately connect to database, trying again."); + } + + // Wait for the configured time before trying again. + Thread.Sleep(AttemptDelayMilliseconds); + } + + return canConnect; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..9261f3ca5c --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot. +/// +public interface IDatabaseAvailabilityCheck +{ + /// + /// Checks if the database is available for Umbraco to boot. + /// + /// The . + /// + /// A value indicating whether the database is available. + /// + bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory); +} diff --git a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs index 04ba9a2584..bd1153f51a 100644 --- a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs @@ -31,6 +31,7 @@ public class RuntimeState : IRuntimeState private readonly IConflictingRouteService _conflictingRouteService = null!; private readonly IEnumerable _databaseProviderMetadata = null!; private readonly IRuntimeModeValidationService _runtimeModeValidationService = null!; + private readonly IDatabaseAvailabilityCheck _databaseAvailabilityCheck = null!; /// /// The initial @@ -46,6 +47,7 @@ public class RuntimeState : IRuntimeState /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] public RuntimeState( IOptions globalSettings, IOptions unattendedSettings, @@ -56,6 +58,34 @@ public class RuntimeState : IRuntimeState IConflictingRouteService conflictingRouteService, IEnumerable databaseProviderMetadata, IRuntimeModeValidationService runtimeModeValidationService) + : this( + globalSettings, + unattendedSettings, + umbracoVersion, + databaseFactory, + logger, + packageMigrationState, + conflictingRouteService, + databaseProviderMetadata, + runtimeModeValidationService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public RuntimeState( + IOptions globalSettings, + IOptions unattendedSettings, + IUmbracoVersion umbracoVersion, + IUmbracoDatabaseFactory databaseFactory, + ILogger logger, + PendingPackageMigrations packageMigrationState, + IConflictingRouteService conflictingRouteService, + IEnumerable databaseProviderMetadata, + IRuntimeModeValidationService runtimeModeValidationService, + IDatabaseAvailabilityCheck databaseAvailabilityCheck) { _globalSettings = globalSettings; _unattendedSettings = unattendedSettings; @@ -66,6 +96,7 @@ public class RuntimeState : IRuntimeState _conflictingRouteService = conflictingRouteService; _databaseProviderMetadata = databaseProviderMetadata; _runtimeModeValidationService = runtimeModeValidationService; + _databaseAvailabilityCheck = databaseAvailabilityCheck; } /// @@ -242,7 +273,7 @@ public class RuntimeState : IRuntimeState { try { - if (!TryDbConnect(databaseFactory)) + if (_databaseAvailabilityCheck.IsDatabaseAvailable(databaseFactory) is false) { return UmbracoDatabaseState.CannotConnect; } @@ -305,27 +336,4 @@ public class RuntimeState : IRuntimeState } return CurrentMigrationState != FinalMigrationState; } - - private bool TryDbConnect(IUmbracoDatabaseFactory databaseFactory) - { - // anything other than install wants a database - see if we can connect - // (since this is an already existing database, assume localdb is ready) - bool canConnect; - var tries = 5; - for (var i = 0; ;) - { - canConnect = databaseFactory.CanConnect; - if (canConnect || ++i == tries) - { - break; - } - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) - { - _logger.LogDebug("Could not immediately connect to database, trying again."); - } - Thread.Sleep(1000); - } - - return canConnect; - } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs new file mode 100644 index 0000000000..71c500f10f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheckTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Install.Models; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Persistence.SqlServer.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Persistence; + +[TestFixture] +public class DefaultDatabaseAvailabilityCheckTests +{ + [Test] + public void IsDatabaseAvailable_WithDatabaseUnavailable_ReturnsFalse() + { + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(false); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.IsFalse(result); + } + + [Test] + public void IsDatabaseAvailable_WithDatabaseImmediatelyAvailable_ReturnsTrue() + { + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(true); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.IsTrue(result); + } + + [TestCase(5, true)] + [TestCase(6, false)] + public void IsDatabaseAvailable_WithDatabaseImmediatelyAvailableAfterMultipleAttempts_ReturnsExpectedResult(int attemptsUntilConnection, bool expectedResult) + { + if (attemptsUntilConnection < 1) + { + throw new ArgumentException($"{nameof(attemptsUntilConnection)} must be greater than or equal to 1.", nameof(attemptsUntilConnection)); + } + + var attemptResults = new Queue(); + for (var i = 0; i < attemptsUntilConnection - 1; i++) + { + attemptResults.Enqueue(false); + } + + attemptResults.Enqueue(true); + + var mockDatabaseFactory = new Mock(); + mockDatabaseFactory + .Setup(x => x.CanConnect) + .Returns(attemptResults.Dequeue); + + var sut = CreateDefaultDatabaseAvailabilityCheck(); + var result = sut.IsDatabaseAvailable(mockDatabaseFactory.Object); + Assert.AreEqual(expectedResult, result); + } + + private static DefaultDatabaseAvailabilityCheck CreateDefaultDatabaseAvailabilityCheck() + => new(new NullLogger()) + { + AttemptDelayMilliseconds = 1 // Set to 1 ms for faster tests. + }; +}