diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs index 90e2ab23..b70a7ba8 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs @@ -34,7 +34,8 @@ private static void AddBackgroundServices(WebApplicationBuilder builder) } builder.Services.AddHealthChecks() - .AddCheck("background-services"); + .AddCheck("background-services") + .AddCheck("database"); } private static void AddInProcessSchedulers(WebApplicationBuilder builder) diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs index 0287e7dc..440bf603 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs @@ -1,3 +1,5 @@ +using System.IO.Compression; +using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.Options; using Orbit.Api.Mcp.Tools; using Orbit.Api.Middleware; @@ -225,6 +227,8 @@ private static void AddMcpToolServer(WebApplicationBuilder builder) private static void AddApiPipeline(WebApplicationBuilder builder) { + AddResponseCompression(builder); + builder.Services.AddControllers() .AddJsonOptions(options => { @@ -249,6 +253,22 @@ private static void AddApiPipeline(WebApplicationBuilder builder) }); } + private static void AddResponseCompression(WebApplicationBuilder builder) + { + builder.Services.AddResponseCompression(options => + { + // EnableForHttps is required because Render terminates TLS upstream (X-Forwarded-Proto=https), so compression would otherwise never apply; BREACH is not a concern here as responses are parameterized JSON with no attacker-reflected secrets: https://learn.microsoft.com/aspnet/core/performance/response-compression + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( + ["application/json", "application/problem+json"]); + }); + + builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); + builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); + } + private static void InitializeFirebase(ConfigurationManager configuration) { var firebaseCredJson = configuration["Firebase:CredentialsJson"]; diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 182d86a3..40c74ef4 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -58,14 +58,18 @@ public static WebApplicationBuilder ValidateOrbitSecuritySettings(this WebApplic public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder) { var databaseSettings = DatabaseConnectionSettings.From(builder.Configuration); - builder.Services.AddDbContext(options => - options.UseNpgsql( - OrbitConnectionStringFactory.ForRequestPath(builder.Configuration), - npgsql => - { - npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null); - npgsql.CommandTimeout(databaseSettings.CommandTimeoutSeconds); - })); + builder.Services.AddSingleton(databaseSettings); + builder.Services.AddSingleton(); + builder.Services.AddDbContext((serviceProvider, options) => + options + .UseNpgsql( + OrbitConnectionStringFactory.ForRequestPath(builder.Configuration), + npgsql => + { + npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null); + npgsql.CommandTimeout(databaseSettings.CommandTimeoutSeconds); + }) + .AddInterceptors(serviceProvider.GetRequiredService())); builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)); builder.Services.AddScoped(); diff --git a/src/Orbit.Api/Extensions/WebApplicationExtensions.cs b/src/Orbit.Api/Extensions/WebApplicationExtensions.cs index 8f5ebf96..b757a56d 100644 --- a/src/Orbit.Api/Extensions/WebApplicationExtensions.cs +++ b/src/Orbit.Api/Extensions/WebApplicationExtensions.cs @@ -36,6 +36,10 @@ public static async Task ConfigureOrbitPipeline(this WebApplication app) app.UseMiddleware(); app.UseForwardedHeaders(BuildForwardedHeadersOptions(app)); + + // After UseForwardedHeaders so Request.IsHttps reflects X-Forwarded-Proto, which EnableForHttps gates on: https://learn.microsoft.com/aspnet/core/performance/response-compression + app.UseResponseCompression(); + app.UseMiddleware(); if (app.Environment.IsDevelopment()) diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index 6af1dc5b..ca701b05 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -22,7 +22,9 @@ "EfMaxPoolSize": 15, "SessionMaxPoolSize": 5, "CommandTimeoutSeconds": 60, - "MigrationCommandTimeoutSeconds": 180 + "MigrationCommandTimeoutSeconds": 180, + "TransactionTimeoutSeconds": 120, + "SlowQueryThresholdMilliseconds": 500 }, "AI": { "ApiKey": "REPLACE-IN-DEVELOPMENT-JSON", diff --git a/src/Orbit.Infrastructure/Configuration/DatabaseConnectionSettings.cs b/src/Orbit.Infrastructure/Configuration/DatabaseConnectionSettings.cs index 819e66cb..96f38df0 100644 --- a/src/Orbit.Infrastructure/Configuration/DatabaseConnectionSettings.cs +++ b/src/Orbit.Infrastructure/Configuration/DatabaseConnectionSettings.cs @@ -22,7 +22,11 @@ namespace Orbit.Infrastructure.Configuration; /// transaction is not clipped; the long AI/batch work is OpenAI network I/O bounded separately by /// AI:BatchNetworkTimeoutSeconds, not a single long DB command). /// is deliberately larger because a startup migration can build an index or backfill a table in one statement -/// that far exceeds the request-path budget. See thomasluizon/orbit-ui-mobile#243. +/// that far exceeds the request-path budget. is a wall-clock ceiling on +/// a whole UnitOfWork.ExecuteInTransactionAsync unit of work — the per-command timeout cannot bound a +/// transaction that wedges between commands (app-side stall, lock wait), so this releases the held backend +/// instead of leaking it. It sits above so a legitimate multi-statement +/// transaction is never clipped. See thomasluizon/orbit-ui-mobile#243. /// /// public sealed class DatabaseConnectionSettings @@ -37,6 +41,10 @@ public sealed class DatabaseConnectionSettings public int MigrationCommandTimeoutSeconds { get; init; } = 180; + public int TransactionTimeoutSeconds { get; init; } = 120; + + public int SlowQueryThresholdMilliseconds { get; init; } = 500; + public static DatabaseConnectionSettings From(IConfiguration configuration) => configuration.GetSection(SectionName).Get() ?? new DatabaseConnectionSettings(); diff --git a/src/Orbit.Infrastructure/Persistence/SlowQueryCommandInterceptor.cs b/src/Orbit.Infrastructure/Persistence/SlowQueryCommandInterceptor.cs new file mode 100644 index 00000000..5c9dac6b --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/SlowQueryCommandInterceptor.cs @@ -0,0 +1,68 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Persistence; + +/// +/// Logs a warning for any database command whose measured execution exceeds +/// , making slow queries observable in +/// the application logs (Render) without turning on EF's per-command Information logging in production. The +/// measured duration includes the network round trip and client-side materialization; for server-side timing +/// only, set PostgreSQL's log_min_duration_statement on the Supabase side as a complement: +/// https://supabase.com/docs/guides/telemetry/logs#database-logs +/// +public sealed partial class SlowQueryCommandInterceptor( + ILogger logger, + DatabaseConnectionSettings databaseSettings) : DbCommandInterceptor +{ + private TimeSpan Threshold => TimeSpan.FromMilliseconds(databaseSettings.SlowQueryThresholdMilliseconds); + + public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.ReaderExecuted(command, eventData, result); + } + + public override ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.ReaderExecutedAsync(command, eventData, result, cancellationToken); + } + + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.NonQueryExecuted(command, eventData, result); + } + + public override ValueTask NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken); + } + + public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.ScalarExecuted(command, eventData, result); + } + + public override ValueTask ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default) + { + LogIfSlow(command.CommandText, eventData.Duration); + return base.ScalarExecutedAsync(command, eventData, result, cancellationToken); + } + + internal void LogIfSlow(string commandText, TimeSpan duration) + { + if (duration < Threshold) + return; + + LogSlowQuery(logger, (long)duration.TotalMilliseconds, commandText); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Slow database query took {ElapsedMilliseconds}ms: {CommandText}")] + private static partial void LogSlowQuery(ILogger logger, long elapsedMilliseconds, string commandText); +} diff --git a/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs b/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs index ffe79c03..c7c42da1 100644 --- a/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs @@ -1,9 +1,11 @@ using Microsoft.EntityFrameworkCore; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; namespace Orbit.Infrastructure.Persistence; -public sealed class UnitOfWork(OrbitDbContext context) : IUnitOfWork, IAsyncDisposable +public sealed class UnitOfWork(OrbitDbContext context, DatabaseConnectionSettings databaseSettings) + : IUnitOfWork, IAsyncDisposable { public Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -35,24 +37,36 @@ public async Task ExecuteInTransactionAsync( return await operation(cancellationToken); } + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(databaseSettings.TransactionTimeoutSeconds)); + var transactionToken = timeoutCts.Token; + var strategy = context.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync(async () => + try { - await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); - - try - { - var result = await operation(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return result; - } - catch + return await strategy.ExecuteAsync(async () => { - context.ChangeTracker.Clear(); - throw; - } - }); + await using var transaction = await context.Database.BeginTransactionAsync(transactionToken); + + try + { + var result = await operation(transactionToken); + await transaction.CommitAsync(transactionToken); + return result; + } + catch + { + context.ChangeTracker.Clear(); + throw; + } + }); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Transaction exceeded the {databaseSettings.TransactionTimeoutSeconds}s timeout and was rolled back."); + } } public Task AcquireAdvisoryLockAsync(string key, CancellationToken cancellationToken = default) diff --git a/src/Orbit.Infrastructure/Services/DatabaseHealthCheck.cs b/src/Orbit.Infrastructure/Services/DatabaseHealthCheck.cs new file mode 100644 index 00000000..365005a3 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/DatabaseHealthCheck.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +/// +/// Reports the /health endpoint as unhealthy when the API cannot reach PostgreSQL, so a load balancer +/// or uptime monitor observes a database outage instead of a superficially-live process. Uses EF Core's +/// , which opens a +/// pooled connection and runs a trivial probe query — a full round trip through the Supavisor pooler. +/// +public sealed class DatabaseHealthCheck(OrbitDbContext dbContext) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + + return canConnect + ? HealthCheckResult.Healthy("Database reachable") + : HealthCheckResult.Unhealthy("Database unreachable"); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs index aa2a63f5..03de6c1d 100644 --- a/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs @@ -6,6 +6,7 @@ using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Tests.Behaviors; @@ -37,7 +38,7 @@ public IdempotencyBehaviorDbTests() _dbContext.Users.Add(user); _dbContext.SaveChanges(); - _unitOfWork = new UnitOfWork(_dbContext); + _unitOfWork = new UnitOfWork(_dbContext, new DatabaseConnectionSettings()); _store = new IdempotencyStore(_dbContext); } diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs index 18ea86e9..cf764846 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs @@ -11,6 +11,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Tests.Persistence; @@ -38,7 +39,7 @@ public async Task Apply_ConflictOnFirstSave_RetriesAndAppliesExactlyOnce() var interceptor = new ConflictOnceInterceptor(); await using var context = CreateContext(dbName, interceptor); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); var handler = new ApplyOnboardingCommandHandler( new GenericRepository(context), new GenericRepository(context), diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs index 2edf77ef..db37c458 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs @@ -10,6 +10,7 @@ using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Tests.Persistence; @@ -54,7 +55,7 @@ public async Task ClaimAdReward_ConflictThenAtCapOnReload_DoesNotOverGrant() await using var context = CreateContext(dbName, interceptor); var handler = new ClaimAdRewardCommandHandler( - new GenericRepository(context), new UnitOfWork(context), StubToday(today), StubLimit()); + new GenericRepository(context), new UnitOfWork(context, new DatabaseConnectionSettings()), StubToday(today), StubLimit()); var result = await handler.Handle(new ClaimAdRewardCommand(userId), CancellationToken.None); @@ -86,7 +87,7 @@ public async Task ClaimAdReward_ConflictThenStillUnderCap_RetriesAndGrantsOnce() var interceptor = new ConflictOnceInterceptor(); await using var context = CreateContext(dbName, interceptor); var handler = new ClaimAdRewardCommandHandler( - new GenericRepository(context), new UnitOfWork(context), StubToday(today), StubLimit()); + new GenericRepository(context), new UnitOfWork(context, new DatabaseConnectionSettings()), StubToday(today), StubLimit()); var result = await handler.Handle(new ClaimAdRewardCommand(userId), CancellationToken.None); @@ -176,7 +177,7 @@ public async Task PlainEdit_PersistentConflict_PropagatesConcurrencyExceptionFor } await using var context = CreateContext(dbName, new ConflictAlwaysInterceptor()); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); var goal = context.Goals.Single(g => g.Id == goalId); goal.Update("Renamed", null, 120, "kg", null); @@ -202,7 +203,7 @@ public async Task SaveWithRetryAsync_ConflictThenSuccess_ReloadsAndPersists() var interceptor = new ConflictOnceInterceptor(); await using var context = CreateContext(dbName, interceptor); var userRepo = new GenericRepository(context); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); var mutateRuns = 0; await ConcurrencyRetry.SaveWithRetryAsync( @@ -239,7 +240,7 @@ public async Task SaveWithRetryAsync_PersistentConflict_PropagatesAfterMaxAttemp var interceptor = new ConflictAlwaysInterceptor(); await using var context = CreateContext(dbName, interceptor); var userRepo = new GenericRepository(context); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); var act = async () => await ConcurrencyRetry.SaveWithRetryAsync( unitOfWork, @@ -275,7 +276,7 @@ private static UpdateGoalProgressCommandHandler CreateGoalProgressHandler(OrbitD new GenericRepository(context), PassingGoalGate(), Substitute.For(), - new UnitOfWork(context), + new UnitOfWork(context, new DatabaseConnectionSettings()), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/SlowQueryCommandInterceptorTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/SlowQueryCommandInterceptorTests.cs new file mode 100644 index 00000000..c49d29f3 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/SlowQueryCommandInterceptorTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Tests.Persistence; + +public class SlowQueryCommandInterceptorTests +{ + [Fact] + public void LogIfSlow_WhenDurationExceedsThreshold_LogsSingleWarning() + { + var logger = new RecordingLogger(); + var interceptor = new SlowQueryCommandInterceptor( + logger, new DatabaseConnectionSettings { SlowQueryThresholdMilliseconds = 100 }); + + interceptor.LogIfSlow("SELECT 1", TimeSpan.FromMilliseconds(250)); + + logger.Entries.Should().ContainSingle().Which.Should().Be(LogLevel.Warning); + } + + [Fact] + public void LogIfSlow_WhenDurationBelowThreshold_DoesNotLog() + { + var logger = new RecordingLogger(); + var interceptor = new SlowQueryCommandInterceptor( + logger, new DatabaseConnectionSettings { SlowQueryThresholdMilliseconds = 100 }); + + interceptor.LogIfSlow("SELECT 1", TimeSpan.FromMilliseconds(50)); + + logger.Entries.Should().BeEmpty(); + } + + [Fact] + public void LogIfSlow_WhenDurationEqualsThreshold_LogsWarning() + { + var logger = new RecordingLogger(); + var interceptor = new SlowQueryCommandInterceptor( + logger, new DatabaseConnectionSettings { SlowQueryThresholdMilliseconds = 100 }); + + interceptor.LogIfSlow("SELECT 1", TimeSpan.FromMilliseconds(100)); + + logger.Entries.Should().ContainSingle(); + } + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Entries.Add(logLevel); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs index b627ff01..d409e6c1 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using Orbit.Domain.Entities; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Tests.Persistence; @@ -20,7 +21,7 @@ public async Task ExecuteInTransactionAsync_WhenOperationThrows_ClearsTrackedEnt .Options; using var context = new OrbitDbContext(options); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); var conflict = new DbUpdateException( "value too long for type character varying(256)", @@ -55,7 +56,7 @@ public async Task ExecuteInTransactionAsync_WhenAmbientTransactionActive_RunsInl .Options; using var context = new OrbitDbContext(options); - var unitOfWork = new UnitOfWork(context); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); await using var ambientTransaction = await context.Database.BeginTransactionAsync(); @@ -70,4 +71,49 @@ public async Task ExecuteInTransactionAsync_WhenAmbientTransactionActive_RunsInl operationRan.Should().BeTrue(); context.Database.CurrentTransaction.Should().BeSameAs(ambientTransaction); } + + [Fact] + public async Task ExecuteInTransactionAsync_WhenOperationExceedsTimeout_ThrowsTimeoutExceptionAndRollsBack() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var context = new OrbitDbContext(options); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings { TransactionTimeoutSeconds = 1 }); + + var act = () => unitOfWork.ExecuteInTransactionAsync(async token => + await Task.Delay(TimeSpan.FromSeconds(30), token)); + + await act.Should().ThrowAsync().WithMessage("*1s timeout*"); + context.Database.CurrentTransaction.Should().BeNull(); + } + + [Fact] + public async Task ExecuteInTransactionAsync_WhenCallerCancels_SurfacesCancellationNotTimeout() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var context = new OrbitDbContext(options); + var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings()); + + using var cancellation = new CancellationTokenSource(); + + var act = () => unitOfWork.ExecuteInTransactionAsync(async token => + { + await cancellation.CancelAsync(); + await Task.Delay(TimeSpan.FromSeconds(30), token); + }, cancellation.Token); + + await act.Should().ThrowAsync(); + context.Database.CurrentTransaction.Should().BeNull(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs index 927fd16c..0ad38437 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; @@ -31,7 +32,7 @@ public AccountDeletionServiceDbTests() var serviceProvider = new ServiceCollection() .AddSingleton(_dbContext) - .AddSingleton(new UnitOfWork(_dbContext)) + .AddSingleton(new UnitOfWork(_dbContext, new DatabaseConnectionSettings())) .AddSingleton(new AccountResetRepository(_dbContext)) .BuildServiceProvider(); diff --git a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceConcurrentRefreshTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceConcurrentRefreshTests.cs index 0d0ddd1d..674d9f5d 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceConcurrentRefreshTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceConcurrentRefreshTests.cs @@ -117,7 +117,7 @@ private static AuthSessionService CreateService(OrbitDbContext context) new GenericRepository(context), new GenericRepository(context), tokenService, - new UnitOfWork(context), + new UnitOfWork(context, new DatabaseConnectionSettings()), Options.Create(new JwtSettings { SecretKey = "test-secret-key-that-is-at-least-32-bytes-long-for-hmac", diff --git a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceHasSessionForTokenTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceHasSessionForTokenTests.cs index b1527bdf..c8c09334 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceHasSessionForTokenTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceHasSessionForTokenTests.cs @@ -101,7 +101,7 @@ private static AuthSessionService CreateService(OrbitDbContext context) new GenericRepository(context), new GenericRepository(context), tokenService, - new UnitOfWork(context), + new UnitOfWork(context, new DatabaseConnectionSettings()), Options.Create(new JwtSettings { SecretKey = "test-secret-key-that-is-at-least-32-bytes-long-for-hmac", diff --git a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceRevokeAllTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceRevokeAllTests.cs index c05bf79c..2b9dc57c 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceRevokeAllTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceRevokeAllTests.cs @@ -130,7 +130,7 @@ private static AuthSessionService CreateService(OrbitDbContext context) new GenericRepository(context), new GenericRepository(context), tokenService, - new UnitOfWork(context), + new UnitOfWork(context, new DatabaseConnectionSettings()), Options.Create(new JwtSettings { SecretKey = "test-secret-key-that-is-at-least-32-bytes-long-for-hmac", diff --git a/tests/Orbit.Infrastructure.Tests/Services/DatabaseHealthCheckTests.cs b/tests/Orbit.Infrastructure.Tests/Services/DatabaseHealthCheckTests.cs new file mode 100644 index 00000000..9c4d9ec8 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/DatabaseHealthCheckTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class DatabaseHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_WhenDatabaseReachable_ReturnsHealthy() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var context = new OrbitDbContext(options); + var healthCheck = new DatabaseHealthCheck(context); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_WhenDatabaseUnreachable_ReturnsUnhealthy() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=127.0.0.1;Port=1;Database=orbit;Username=none;Password=none;Timeout=1;Command Timeout=1") + .Options; + + using var context = new OrbitDbContext(options); + var healthCheck = new DatabaseHealthCheck(context); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Unhealthy); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/XpAwardLogBackfillHostedServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/XpAwardLogBackfillHostedServiceTests.cs index fcaf97ed..4902a4d6 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/XpAwardLogBackfillHostedServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/XpAwardLogBackfillHostedServiceTests.cs @@ -7,6 +7,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; @@ -30,6 +31,7 @@ public sealed class XpAwardLogBackfillHostedServiceTests : IDisposable public XpAwardLogBackfillHostedServiceTests() { var services = new ServiceCollection(); + services.AddSingleton(new DatabaseConnectionSettings()); services.AddScoped(_ => CreateContext(_dbName)); services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)); services.AddScoped();