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
12 changes: 8 additions & 4 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
{
builder.Services.AddDbContext<OrbitDbContext>(options =>
options.UseNpgsql(
builder.Configuration.GetConnectionString("DefaultConnection"),
OrbitConnectionStringFactory.ForRequestPath(builder.Configuration),
npgsql =>
{
npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null);
Expand Down Expand Up @@ -495,17 +495,21 @@ private static void AddInProcessSchedulers(WebApplicationBuilder builder)

private static void AddDurableRecurringJobs(WebApplicationBuilder builder)
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var connectionString = OrbitConnectionStringFactory.ForSession(builder.Configuration);
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException(
$"{BackgroundJobSettings.SectionName}:UseDurableQueue is true but ConnectionStrings:DefaultConnection is not configured.");
$"{BackgroundJobSettings.SectionName}:UseDurableQueue is true but no database connection string is configured.");

builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(connectionString)));
builder.Services.AddHangfireServer();
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = 2;
options.SchedulePollingInterval = TimeSpan.FromMinutes(1);
});

builder.Services.AddSingleton<ScheduledJobRunner>();
AddScheduledJob<ReminderSchedulerService>(builder);
Expand Down
10 changes: 7 additions & 3 deletions src/Orbit.Api/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
{
public static async Task ConfigureOrbitPipeline(this WebApplication app)
{
using (var scope = app.Services.CreateScope())
var migrationConnectionString = OrbitConnectionStringFactory.ForSession(app.Configuration);
var migrationOptions = new DbContextOptionsBuilder<OrbitDbContext>()
.UseNpgsql(migrationConnectionString, npgsql =>
npgsql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorCodesToAdd: null))
.Options;
await using (var migrationDb = new OrbitDbContext(migrationOptions))
{
var db = scope.ServiceProvider.GetRequiredService<OrbitDbContext>();
await db.Database.MigrateAsync();
await migrationDb.Database.MigrateAsync();
}

app.UseMiddleware<Orbit.Api.Middleware.SecurityHeadersMiddleware>();
Expand Down Expand Up @@ -320,7 +324,7 @@
}
}
catch (System.Text.Json.JsonException)
{

Check warning on line 327 in src/Orbit.Api/Extensions/WebApplicationExtensions.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Either remove or fill this block of code.
}

return false;
Expand Down
7 changes: 6 additions & 1 deletion src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
"DefaultConnection": "",
"SessionConnection": ""
},
"Database": {
"EfMaxPoolSize": 10,
"SessionMaxPoolSize": 5
},
"AI": {
"ApiKey": "REPLACE-IN-DEVELOPMENT-JSON",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 within the pooler's configured pool size.
/// </summary>
public sealed class DatabaseConnectionSettings
{
public const string SectionName = "Database";

public int EfMaxPoolSize { get; init; } = 10;

public int SessionMaxPoolSize { get; init; } = 5;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Configuration;
using Npgsql;
using Orbit.Infrastructure.Configuration;

namespace Orbit.Infrastructure.Persistence;

/// <summary>
/// Resolves the API's PostgreSQL connection strings and enforces Npgsql client-pool caps in code so a
/// misconfigured deploy can never exhaust the Supabase pooler. The request path uses
/// <c>ConnectionStrings:DefaultConnection</c> (intended for the Supavisor transaction pooler); startup
/// migrations, the design-time factory, and the Hangfire durable queue use
/// <c>ConnectionStrings:SessionConnection</c> (the session pooler), falling back to
/// <c>DefaultConnection</c> when it is unset. The configured <see cref="DatabaseConnectionSettings"/>
/// pool caps override any pool size present in the raw connection string.
/// </summary>
public static class OrbitConnectionStringFactory
{
public static string ForRequestPath(IConfiguration configuration)
{
var settings = ReadSettings(configuration);
return ApplyPoolCap(configuration.GetConnectionString("DefaultConnection"), settings.EfMaxPoolSize);
}

public static string ForSession(IConfiguration configuration)
{
var settings = ReadSettings(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))
return connectionString ?? string.Empty;

return new NpgsqlConnectionStringBuilder(connectionString)
{
MaxPoolSize = maxPoolSize,
MinPoolSize = 0
}.ConnectionString;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public OrbitDbContext CreateDbContext(string[] args)
.Build();

var optionsBuilder = new DbContextOptionsBuilder<OrbitDbContext>();
optionsBuilder.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
optionsBuilder.UseNpgsql(OrbitConnectionStringFactory.ForSession(configuration));

return new OrbitDbContext(optionsBuilder.Options);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Orbit.Infrastructure.Persistence;

namespace Orbit.Infrastructure.Tests.Persistence;

public class OrbitConnectionStringFactoryTests
{
[Fact]
public void ForRequestPath_AppliesEfPoolCap_AndPreservesEndpointAndParams()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] =
"Host=db.example.com;Port=6543;Database=postgres;Username=u;Password=p;No Reset On Close=true",
["Database:EfMaxPoolSize"] = "10"
});

var result = new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForRequestPath(configuration));

result.MaxPoolSize.Should().Be(10);
result.MinPoolSize.Should().Be(0);
result.Host.Should().Be("db.example.com");
result.Port.Should().Be(6543);
result.NoResetOnClose.Should().BeTrue();
}

[Fact]
public void ForRequestPath_OverridesPoolSizePresentInConnectionString()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=h;Maximum Pool Size=200",
["Database:EfMaxPoolSize"] = "12"
});

new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForRequestPath(configuration))
.MaxPoolSize.Should().Be(12);
}

[Fact]
public void ForSession_PrefersSessionConnection_OverDefault()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=transaction;Port=6543",
["ConnectionStrings:SessionConnection"] = "Host=session;Port=5432",
["Database:SessionMaxPoolSize"] = "5"
});

var result = new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForSession(configuration));

result.Host.Should().Be("session");
result.Port.Should().Be(5432);
result.MaxPoolSize.Should().Be(5);
}

[Theory]
[InlineData("")]
[InlineData(null)]
public void ForSession_FallsBackToDefault_WhenSessionConnectionBlankOrMissing(string? sessionValue)
{
var values = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=default;Port=5432"
};
if (sessionValue is not null)
values["ConnectionStrings:SessionConnection"] = sessionValue;

var result = new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForSession(BuildConfiguration(values)));

result.Host.Should().Be("default");
}

[Fact]
public void UsesDefaultCaps_WhenDatabaseSectionAbsent()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=h;Port=6543",
["ConnectionStrings:SessionConnection"] = "Host=h2;Port=5432"
});

new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForRequestPath(configuration))
.MaxPoolSize.Should().Be(10);
new NpgsqlConnectionStringBuilder(OrbitConnectionStringFactory.ForSession(configuration))
.MaxPoolSize.Should().Be(5);
}

[Fact]
public void ReturnsEmpty_WhenNoConnectionStringConfigured()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>());

OrbitConnectionStringFactory.ForRequestPath(configuration).Should().BeEmpty();
OrbitConnectionStringFactory.ForSession(configuration).Should().BeEmpty();
}

private static IConfiguration BuildConfiguration(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
Loading