Adds abstraction around boot time checks for database availability (#19848)

* Adds abstraction around boot time checks for database availability.

* Addressed issues raised in code review.
This commit is contained in:
Andy Butland
2025-08-05 13:33:18 +02:00
committed by GitHub
parent 240e155d91
commit 0c22d512e2
5 changed files with 174 additions and 24 deletions

View File

@@ -90,6 +90,9 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationAsyncHandler<RuntimeUnattendedUpgradeNotification, UnattendedUpgrader>();
builder.AddNotificationAsyncHandler<RuntimePremigrationsUpgradeNotification, PremigrationUpgrader>();
// Database availability check.
builder.Services.AddUnique<IDatabaseAvailabilityCheck, DefaultDatabaseAvailabilityCheck>();
// Add runtime mode validation
builder.Services.AddSingleton<IRuntimeModeValidationService, RuntimeModeValidationService>();
builder.RuntimeModeValidators()

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
namespace Umbraco.Cms.Infrastructure.Persistence;
/// <summary>
/// Checks if a configured database is available on boot using the default method of 5 attempts with a 1 second delay between each one.
/// </summary>
internal class DefaultDatabaseAvailabilityCheck : IDatabaseAvailabilityCheck
{
private const int NumberOfAttempts = 5;
private const int DefaultAttemptDelayMilliseconds = 1000;
private readonly ILogger<DefaultDatabaseAvailabilityCheck> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultDatabaseAvailabilityCheck"/> class.
/// </summary>
/// <param name="logger"></param>
public DefaultDatabaseAvailabilityCheck(ILogger<DefaultDatabaseAvailabilityCheck> logger) => _logger = logger;
/// <summary>
/// Gets or sets the number of milliseconds to delay between attempts.
/// </summary>
/// <remarks>
/// Exposed for testing purposes, hence settable only internally.
/// </remarks>
public int AttemptDelayMilliseconds { get; internal set; } = DefaultAttemptDelayMilliseconds;
/// <inheritdoc/>
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;
}
}

View File

@@ -0,0 +1,16 @@
namespace Umbraco.Cms.Infrastructure.Persistence;
/// <summary>
/// Checks if a configured database is available on boot.
/// </summary>
public interface IDatabaseAvailabilityCheck
{
/// <summary>
/// Checks if the database is available for Umbraco to boot.
/// </summary>
/// <param name="databaseFactory">The <see cref="IUmbracoDatabaseFactory"/>.</param>
/// <returns>
/// A value indicating whether the database is available.
/// </returns>
bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory);
}

View File

@@ -31,6 +31,7 @@ public class RuntimeState : IRuntimeState
private readonly IConflictingRouteService _conflictingRouteService = null!;
private readonly IEnumerable<IDatabaseProviderMetadata> _databaseProviderMetadata = null!;
private readonly IRuntimeModeValidationService _runtimeModeValidationService = null!;
private readonly IDatabaseAvailabilityCheck _databaseAvailabilityCheck = null!;
/// <summary>
/// The initial <see cref="RuntimeState"/>
@@ -46,6 +47,7 @@ public class RuntimeState : IRuntimeState
/// <summary>
/// Initializes a new instance of the <see cref="RuntimeState" /> class.
/// </summary>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public RuntimeState(
IOptions<GlobalSettings> globalSettings,
IOptions<UnattendedSettings> unattendedSettings,
@@ -56,6 +58,34 @@ public class RuntimeState : IRuntimeState
IConflictingRouteService conflictingRouteService,
IEnumerable<IDatabaseProviderMetadata> databaseProviderMetadata,
IRuntimeModeValidationService runtimeModeValidationService)
: this(
globalSettings,
unattendedSettings,
umbracoVersion,
databaseFactory,
logger,
packageMigrationState,
conflictingRouteService,
databaseProviderMetadata,
runtimeModeValidationService,
StaticServiceProvider.Instance.GetRequiredService<IDatabaseAvailabilityCheck>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RuntimeState" /> class.
/// </summary>
public RuntimeState(
IOptions<GlobalSettings> globalSettings,
IOptions<UnattendedSettings> unattendedSettings,
IUmbracoVersion umbracoVersion,
IUmbracoDatabaseFactory databaseFactory,
ILogger<RuntimeState> logger,
PendingPackageMigrations packageMigrationState,
IConflictingRouteService conflictingRouteService,
IEnumerable<IDatabaseProviderMetadata> 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;
}
/// <inheritdoc />
@@ -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;
}
}

View File

@@ -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<IUmbracoDatabaseFactory>();
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<IUmbracoDatabaseFactory>();
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<bool>();
for (var i = 0; i < attemptsUntilConnection - 1; i++)
{
attemptResults.Enqueue(false);
}
attemptResults.Enqueue(true);
var mockDatabaseFactory = new Mock<IUmbracoDatabaseFactory>();
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<DefaultDatabaseAvailabilityCheck>())
{
AttemptDelayMilliseconds = 1 // Set to 1 ms for faster tests.
};
}