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
3 changes: 2 additions & 1 deletion src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ 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(30);
npgsql.CommandTimeout(databaseSettings.CommandTimeoutSeconds);
}));

builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
Expand Down
7 changes: 6 additions & 1 deletion src/Orbit.Api/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;
using Orbit.Infrastructure.Configuration;
using Orbit.Infrastructure.Persistence;
using Orbit.Infrastructure.Services;
using Scalar.AspNetCore;
Expand All @@ -21,9 +22,13 @@ public static async Task ConfigureOrbitPipeline(this WebApplication app)
if (!BuildTimeDocumentGeneration.IsActive)
{
var migrationConnectionString = OrbitConnectionStringFactory.ForSession(app.Configuration);
var databaseSettings = DatabaseConnectionSettings.From(app.Configuration);
var migrationOptions = new DbContextOptionsBuilder<OrbitDbContext>()
.UseNpgsql(migrationConnectionString, npgsql =>
npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null))
{
npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null);
npgsql.CommandTimeout(databaseSettings.MigrationCommandTimeoutSeconds);
})
.Options;
await using var migrationDb = new OrbitDbContext(migrationOptions);
await migrationDb.Database.MigrateAsync();
Expand Down
6 changes: 4 additions & 2 deletions src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
"SessionConnection": ""
},
"Database": {
"EfMaxPoolSize": 8,
"SessionMaxPoolSize": 2
"EfMaxPoolSize": 15,
"SessionMaxPoolSize": 5,
"CommandTimeoutSeconds": 60,
"MigrationCommandTimeoutSeconds": 180
},
"AI": {
"ApiKey": "REPLACE-IN-DEVELOPMENT-JSON",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
using Microsoft.Extensions.Configuration;

namespace Orbit.Infrastructure.Configuration;

/// <summary>
/// Npgsql client-pool caps for the Supabase Supavisor pooler, applied in code so the API can never open
/// more backend connections than the pooler allows regardless of the deploy-time connection string.
/// <see cref="EfMaxPoolSize"/> bounds the EF Core request-path pool; <see cref="SessionMaxPoolSize"/>
/// bounds the session-pooler pool shared by startup migrations and the Hangfire durable queue. The two
/// caps together must stay below the pooler's configured pool size, leaving headroom for transient
/// connections (a rolling deploy's instance overlap, migrations, and the Supabase dashboard).
/// Npgsql client-pool caps and command timeouts, applied in code so the API can never open more backend
/// connections than the Supabase Supavisor pooler allows, regardless of the deploy-time connection string.
/// <para>
/// Sizing model (Supabase compute has <c>max_connections = 60</c>, 3 reserved, so ~57 usable direct backends):
/// the request path (<see cref="EfMaxPoolSize"/>) runs through the Supavisor <b>transaction</b> pooler, which
/// multiplexes — a client connection only holds a Postgres backend for the duration of a transaction — so this
/// cap is a request-concurrency limit that is decoupled from the held backend count and can safely exceed the
/// pooler's server-side pool size. The session path (<see cref="SessionMaxPoolSize"/>) runs through the
/// <b>session</b> pooler, where each client connection holds a dedicated Postgres backend for its whole lifetime
/// (1:1); it is shared by startup migrations and the always-on Hangfire durable queue (2 workers + the storage
/// expiration/heartbeat threads + request-path enqueue), so it must stay well under the usable-backend ceiling.
/// At 15 + 5 the worst case — a rolling deploy's brief two-instance overlap (2 × 5 = 10 held session backends)
/// plus Supabase's own internal connections — stays comfortably under 57.
/// </para>
/// <para>
/// Timeouts: <see cref="CommandTimeoutSeconds"/> bounds request-path queries (raised from 30s so a long
/// 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.
/// </para>
/// </summary>
public sealed class DatabaseConnectionSettings
{
public const string SectionName = "Database";

public int EfMaxPoolSize { get; init; } = 8;
public int EfMaxPoolSize { get; init; } = 15;

public int SessionMaxPoolSize { get; init; } = 5;

public int CommandTimeoutSeconds { get; init; } = 60;

public int MigrationCommandTimeoutSeconds { get; init; } = 180;

public int SessionMaxPoolSize { get; init; } = 2;
public static DatabaseConnectionSettings From(IConfiguration configuration) =>
configuration.GetSection(SectionName).Get<DatabaseConnectionSettings>()
?? new DatabaseConnectionSettings();
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,20 @@ public static class OrbitConnectionStringFactory
{
public static string ForRequestPath(IConfiguration configuration)
{
var settings = ReadSettings(configuration);
var settings = DatabaseConnectionSettings.From(configuration);
return ApplyPoolCap(configuration.GetConnectionString("DefaultConnection"), settings.EfMaxPoolSize);
}

public static string ForSession(IConfiguration configuration)
{
var settings = ReadSettings(configuration);
var settings = DatabaseConnectionSettings.From(configuration);
var sessionConnectionString = configuration.GetConnectionString("SessionConnection");
if (string.IsNullOrWhiteSpace(sessionConnectionString))
sessionConnectionString = configuration.GetConnectionString("DefaultConnection");

return ApplyPoolCap(sessionConnectionString, settings.SessionMaxPoolSize);
}

private static DatabaseConnectionSettings ReadSettings(IConfiguration configuration) =>
configuration.GetSection(DatabaseConnectionSettings.SectionName).Get<DatabaseConnectionSettings>()
?? new DatabaseConnectionSettings();

private static string ApplyPoolCap(string? connectionString, int maxPoolSize)
{
if (string.IsNullOrWhiteSpace(connectionString))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Orbit.Infrastructure.Configuration;

namespace Orbit.Infrastructure.Tests.Configuration;

public class DatabaseConnectionSettingsTests
{
[Fact]
public void Defaults_KeepSessionPoolSmallerThanRequestPool_SoSessionPoolerBackendsStayBounded()
{
var settings = new DatabaseConnectionSettings();

settings.EfMaxPoolSize.Should().Be(15);
settings.SessionMaxPoolSize.Should().Be(5);
settings.SessionMaxPoolSize.Should().BeLessThan(settings.EfMaxPoolSize);
}

[Fact]
public void Defaults_KeepTwoInstanceSessionOverlapUnderSupabaseUsableBackends()
{
var settings = new DatabaseConnectionSettings();

(settings.SessionMaxPoolSize * 2).Should().BeLessThan(57);
}

[Fact]
public void Defaults_GiveMigrationsMoreCommandHeadroomThanRequestPath()
{
var settings = new DatabaseConnectionSettings();

settings.CommandTimeoutSeconds.Should().Be(60);
settings.MigrationCommandTimeoutSeconds.Should().Be(180);
settings.MigrationCommandTimeoutSeconds.Should().BeGreaterThan(settings.CommandTimeoutSeconds);
}

[Fact]
public void From_BindsEveryConfiguredValue()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["Database:EfMaxPoolSize"] = "20",
["Database:SessionMaxPoolSize"] = "6",
["Database:CommandTimeoutSeconds"] = "90",
["Database:MigrationCommandTimeoutSeconds"] = "240"
});

var settings = DatabaseConnectionSettings.From(configuration);

settings.EfMaxPoolSize.Should().Be(20);
settings.SessionMaxPoolSize.Should().Be(6);
settings.CommandTimeoutSeconds.Should().Be(90);
settings.MigrationCommandTimeoutSeconds.Should().Be(240);
}

[Fact]
public void From_ReturnsDefaults_WhenSectionAbsent()
{
var settings = DatabaseConnectionSettings.From(BuildConfiguration(new Dictionary<string, string?>()));

settings.EfMaxPoolSize.Should().Be(15);
settings.SessionMaxPoolSize.Should().Be(5);
settings.CommandTimeoutSeconds.Should().Be(60);
settings.MigrationCommandTimeoutSeconds.Should().Be(180);
}

private static IConfiguration BuildConfiguration(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ public void UsesDefaultCaps_WhenDatabaseSectionAbsent()
});

new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForRequestPath(configuration))
.MaxPoolSize.Should().Be(8);
.MaxPoolSize.Should().Be(15);
new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForSession(configuration))
.MaxPoolSize.Should().Be(2);
.MaxPoolSize.Should().Be(5);
}

[Fact]
Expand Down
Loading