Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ private static void AddBackgroundServices(WebApplicationBuilder builder)
}

builder.Services.AddHealthChecks()
.AddCheck<BackgroundServiceHealthCheck>("background-services");
.AddCheck<BackgroundServiceHealthCheck>("background-services")
.AddCheck<DatabaseHealthCheck>("database");
}

private static void AddInProcessSchedulers(WebApplicationBuilder builder)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -225,6 +227,8 @@ private static void AddMcpToolServer(WebApplicationBuilder builder)

private static void AddApiPipeline(WebApplicationBuilder builder)
{
AddResponseCompression(builder);

builder.Services.AddControllers()
.AddJsonOptions(options =>
{
Expand All @@ -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<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
["application/json", "application/problem+json"]);
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
}

private static void InitializeFirebase(ConfigurationManager configuration)
{
var firebaseCredJson = configuration["Firebase:CredentialsJson"];
Expand Down
20 changes: 12 additions & 8 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrbitDbContext>(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<SlowQueryCommandInterceptor>();
builder.Services.AddDbContext<OrbitDbContext>((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<SlowQueryCommandInterceptor>()));

builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
Expand Down
4 changes: 4 additions & 0 deletions src/Orbit.Api/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public static async Task ConfigureOrbitPipeline(this WebApplication app)

app.UseMiddleware<Orbit.Api.Middleware.SecurityHeadersMiddleware>();
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<Orbit.Api.Middleware.RequestCorrelationMiddleware>();

if (app.Environment.IsDevelopment())
Expand Down
4 changes: 3 additions & 1 deletion src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"EfMaxPoolSize": 15,
"SessionMaxPoolSize": 5,
"CommandTimeoutSeconds": 60,
"MigrationCommandTimeoutSeconds": 180
"MigrationCommandTimeoutSeconds": 180,
"TransactionTimeoutSeconds": 120,
"SlowQueryThresholdMilliseconds": 500
},
"AI": {
"ApiKey": "REPLACE-IN-DEVELOPMENT-JSON",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <c>AI:BatchNetworkTimeoutSeconds</c>, not a single long DB command). <see cref="MigrationCommandTimeoutSeconds"/>
/// 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. <see cref="TransactionTimeoutSeconds"/> is a wall-clock ceiling on
/// a whole <c>UnitOfWork.ExecuteInTransactionAsync</c> 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 <see cref="CommandTimeoutSeconds"/> so a legitimate multi-statement
/// transaction is never clipped. See thomasluizon/orbit-ui-mobile#243.
/// </para>
/// </summary>
public sealed class DatabaseConnectionSettings
Expand All @@ -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<DatabaseConnectionSettings>()
?? new DatabaseConnectionSettings();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Orbit.Infrastructure.Configuration;

namespace Orbit.Infrastructure.Persistence;

/// <summary>
/// Logs a warning for any database command whose measured execution exceeds
/// <see cref="DatabaseConnectionSettings.SlowQueryThresholdMilliseconds"/>, 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 <c>log_min_duration_statement</c> on the Supabase side as a complement:
/// https://supabase.com/docs/guides/telemetry/logs#database-logs
/// </summary>
public sealed partial class SlowQueryCommandInterceptor(
ILogger<SlowQueryCommandInterceptor> 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<DbDataReader> 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<int> 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<object?> 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);
}
44 changes: 29 additions & 15 deletions src/Orbit.Infrastructure/Persistence/UnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -35,24 +37,36 @@ public async Task<T> ExecuteInTransactionAsync<T>(
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)
Expand Down
25 changes: 25 additions & 0 deletions src/Orbit.Infrastructure/Services/DatabaseHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Orbit.Infrastructure.Persistence;

namespace Orbit.Infrastructure.Services;

/// <summary>
/// Reports the <c>/health</c> 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
/// <see cref="Microsoft.EntityFrameworkCore.Infrastructure.DatabaseFacade.CanConnectAsync"/>, which opens a
/// pooled connection and runs a trivial probe query — a full round trip through the Supavisor pooler.
/// </summary>
public sealed class DatabaseHealthCheck(OrbitDbContext dbContext) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);

return canConnect
? HealthCheckResult.Healthy("Database reachable")
: HealthCheckResult.Unhealthy("Database unreachable");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<User>(context),
new GenericRepository<Habit>(context),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +55,7 @@ public async Task ClaimAdReward_ConflictThenAtCapOnReload_DoesNotOverGrant()

await using var context = CreateContext(dbName, interceptor);
var handler = new ClaimAdRewardCommandHandler(
new GenericRepository<User>(context), new UnitOfWork(context), StubToday(today), StubLimit());
new GenericRepository<User>(context), new UnitOfWork(context, new DatabaseConnectionSettings()), StubToday(today), StubLimit());

var result = await handler.Handle(new ClaimAdRewardCommand(userId), CancellationToken.None);

Expand Down Expand Up @@ -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<User>(context), new UnitOfWork(context), StubToday(today), StubLimit());
new GenericRepository<User>(context), new UnitOfWork(context, new DatabaseConnectionSettings()), StubToday(today), StubLimit());

var result = await handler.Handle(new ClaimAdRewardCommand(userId), CancellationToken.None);

Expand Down Expand Up @@ -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);

Expand All @@ -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<User>(context);
var unitOfWork = new UnitOfWork(context);
var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings());

var mutateRuns = 0;
await ConcurrencyRetry.SaveWithRetryAsync(
Expand Down Expand Up @@ -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<User>(context);
var unitOfWork = new UnitOfWork(context);
var unitOfWork = new UnitOfWork(context, new DatabaseConnectionSettings());

var act = async () => await ConcurrencyRetry.SaveWithRetryAsync(
unitOfWork,
Expand Down Expand Up @@ -275,7 +276,7 @@ private static UpdateGoalProgressCommandHandler CreateGoalProgressHandler(OrbitD
new GenericRepository<GoalProgressLog>(context),
PassingGoalGate(),
Substitute.For<IGamificationService>(),
new UnitOfWork(context),
new UnitOfWork(context, new DatabaseConnectionSettings()),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<UpdateGoalProgressCommandHandler>.Instance);

Expand Down
Loading
Loading