fix(api): cap Npgsql pools + split request/session connections (Supabase pooler exhaustion) - #237
Conversation
…p Supabase pooler exhaustion The API shared one DefaultConnection (Supavisor session pooler, pool_size 15) between EF Core and Hangfire with no pool cap. Npgsql's default 100-connection pool could open far more session-pinned connections than the pooler allows, so a burst -- overlapping Render deploys, a scheduled-job fan-out, or request load -- exhausted the 15 session slots and surfaced as Npgsql.PostgresException XX000 EMAXCONNSESSION "max clients reached in session mode". Root-cause guard + connection-topology split: - OrbitConnectionStringFactory enforces Npgsql Max/Min Pool Size in code from DatabaseConnectionSettings (EF 10, session 5 -- together within the 15 ceiling), so no deploy-time connection string can uncap the pool. - EF request path resolves ConnectionStrings:DefaultConnection (intended for the Supavisor transaction pooler, port 6543, which multiplexes and scales). - Startup migrations, the design-time factory, and the Hangfire durable queue resolve ConnectionStrings:SessionConnection (session pooler 5432; falls back to DefaultConnection), because EF's migration table lock (ACCESS EXCLUSIVE) and Hangfire's session semantics are voided by transaction pooling. - Startup migration runs on a dedicated session-connection DbContext instead of the runtime (transaction-pooler) context. - Hangfire server capped to WorkerCount=2 with a 1-minute schedule poll. Behavior is unchanged until the operator sets SessionConnection and points DefaultConnection at the 6543 pooler; deploying this alone already bounds the pool under 15. Adds OrbitConnectionStringFactoryTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
One correctness regression to fix before this lands. Missing retry on migration DbContext (WebApplicationExtensions.cs lines 19-21): the new bare UseNpgsql(migrationConnectionString) call drops the EnableRetryOnFailure(3, 5s) that the DI-registered context had before this PR. During the overlapping-deploy window this PR is designed to survive, the session pooler can still emit a transient EMAXCONNSESSION; without retry the migration throws immediately and the new instance crashes at startup, exactly the scenario this fix is meant to prevent. See inline suggestion for the one-liner fix. Everything else is solid: the factory logic, pool-cap enforcement, the session/transaction pooler split, Hangfire worker cap, and the new test suite all look correct.
The dedicated session-connection migration DbContext used a bare UseNpgsql, dropping the EnableRetryOnFailure(3, 5s) the DI-registered context carries. During the deploy-overlap window this PR targets, a transient EMAXCONNSESSION would fail the migration with no retry and crash the new instance at startup. Restore retry on the migration context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
This lands the connection-pool exhaustion fix cleanly. The factory correctly enforces Npgsql client-side pool caps in code (10 EF + 5 session = 15, within the Supavisor ceiling) so no deploy-time connection string can uncap the pool. The session/transaction pooler topology split, the startup migration isolation onto a dedicated session-connection context, and the Hangfire worker/poll cap are all sound. My prior concern — missing EnableRetryOnFailure on the migration context — was addressed in commit ffd3550. The 7 new factory unit tests cover the key behaviors (cap enforcement, env-string override, session/default fallback, absent config, empty string), and SonarQube reports 0 new issues at 96.7% coverage on new code. Good to go.
|



What & why
Production Sentry alert (
ORBIT-API-9/-A/-B,EMAXCONNSESSION) — the API exhausted the Supabase Supavisor session pooler (pool_size: 15).Root cause. A single
ConnectionStrings:DefaultConnection(session pooler, port 5432) is shared by EF Core and Hangfire with no pool cap. Npgsql's defaultMaximum Pool Sizeis 100, and session mode holds each connection for its whole lifetime — so any concurrency bump opens more than 15 session-pinned backends and the pooler rejects the surplus. The 2026-06-23 trigger was overlapping Render deploys (a Dependabot merge burst): the new instance boots — startup migration + 10 hosted-service first-ticks, each grabbing a connection — while the old instance still holds its session connections, briefly doubling demand past 15. The two erroring call sites (GoalDeadlineNotificationService,AccountDeletionService) are exactly those startup ticks.Verified: single instance today;
UseDurableQueue=falseso Hangfire isn't live in prod yet; all explicit transactions already execution-strategy-wrapped; Hangfire.PostgreSql 1.21.1.The fix (Stage 1 — code; safe to deploy alone)
Root-cause guard + connection-topology split, enforced in code so a deploy can't re-break it:
OrbitConnectionStringFactory+DatabaseConnectionSettings— apply NpgsqlMaximum/Minimum Pool Sizein code from config (Database:EfMaxPoolSize=10,Database:SessionMaxPoolSize=5→ together within the 15 ceiling). The cap overrides whatever the env connection string contains, so an env typo can never uncap the pool again.DefaultConnection(intended for the transaction pooler, port 6543, which multiplexes and scales).SessionConnection(session pooler, 5432; falls back toDefaultConnection). Required because EF's migration lock (LOCK TABLE "__EFMigrationsHistory" ... ACCESS EXCLUSIVE) and Hangfire's session semantics are silently voided under transaction pooling.DbContextrather than the runtime (transaction-pooler) context.WorkerCount=2+ 1-min schedule poll (closes the documented gap: the durable queue was wired toDefaultConnectionwith no worker/pool cap).Deploying this PR alone bounds total connections under 15 — the incident cannot recur even before the pooler cutover.
Operator steps (required to fully resolve / Stage 2)
1. Immediate buffer (no deploy): Supabase → Database → Connection pooling → raise Pool Size 15 → ~40 (bounded by the compute tier's
max_connections; don't max it).2. Stage 2 cutover (Render env vars, reversible):
ConnectionStrings__SessionConnection= the session pooler string (port 5432):Host=aws-1-us-east-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.<ref>;Password=<pw>;SSL Mode=RequireConnectionStrings__DefaultConnectionto the transaction pooler string (port 6543) withNo Reset On Close=true:Host=aws-1-us-east-1.pooler.supabase.com;Port=6543;Database=postgres;Username=postgres.<ref>;Password=<pw>;SSL Mode=Require;No Reset On Close=trueDatabase__EfMaxPoolSize,Database__SessionMaxPoolSize.3. Recommended: raise the per-role server timeout so slower queries aren't clipped independent of Npgsql's client timeout:
alter role authenticated set statement_timeout = '10s';Rollback = revert the env vars (instant) or revert this PR.
Request-path connection-hold review (audited — no extra code needed)
Audited the request-path holds; three candidates are not connection-pool issues (EF releases the connection between operations), so no change is warranted:
ProcessUserChatCommandbackground fact-extraction —Task.Runscope, but the AI call holds no DB connection; only the briefSaveChangesdoes.CalendarAutoSyncService— processes its ≤50 users sequentially in one scope/DbContext(awaitedforeach), not a 50-way fan-out; one connection at a time.SendCode/SendSupport/RequestAccountDeletion) —IMemoryCache+ email, or a single read that completes before the send; no connection held across the email. This is a request-latency concern, not connection pressure.The one real hold —
DistributedRateLimitService's brief per-request Serializable transaction — is neutralized by the Stage 2 transaction-pooler cutover (the connection is returned on commit, not pinned). Fully removing Postgres from the rate-limit path (move to Redis) is gated on provisioning Redis and tracked separately.Test plan
dotnet build Orbit.slnx— 0 errors.dotnet test tests/Orbit.Infrastructure.Tests— 1150 pass, incl. 7 newOrbitConnectionStringFactorycases (caps, env-string override, session/default fallback, config defaults, empty).🤖 Generated with Claude Code