Skip to content

fix(api): cap Npgsql pools + split request/session connections (Supabase pooler exhaustion) - #237

Merged
thomasluizon merged 2 commits into
mainfrom
fix/db-connection-pool-exhaustion
Jun 24, 2026
Merged

fix(api): cap Npgsql pools + split request/session connections (Supabase pooler exhaustion)#237
thomasluizon merged 2 commits into
mainfrom
fix/db-connection-pool-exhaustion

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jun 24, 2026

Copy link
Copy Markdown
Owner

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 default Maximum Pool Size is 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=false so 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 Npgsql Maximum/Minimum Pool Size in 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.
  • EF request pathDefaultConnection (intended for the transaction pooler, port 6543, which multiplexes and scales).
  • Migrations + design-time factory + HangfireSessionConnection (session pooler, 5432; falls back to DefaultConnection). Required because EF's migration lock (LOCK TABLE "__EFMigrationsHistory" ... ACCESS EXCLUSIVE) and Hangfire's session semantics are silently voided under transaction pooling.
  • Startup migration runs on a dedicated session-connection DbContext rather than the runtime (transaction-pooler) context.
  • Hangfire capped to WorkerCount=2 + 1-min schedule poll (closes the documented gap: the durable queue was wired to DefaultConnection with 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)

These are env/dashboard changes only the operator can make. The code falls back to current behavior until they are set, so this PR is safe to merge first.

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):

  • Set 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=Require
  • Change ConnectionStrings__DefaultConnection to the transaction pooler string (port 6543) with No 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=true
  • Optional tuning (no redeploy): Database__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:

  • ProcessUserChatCommand background fact-extractionTask.Run scope, but the AI call holds no DB connection; only the brief SaveChanges does.
  • CalendarAutoSyncService — processes its ≤50 users sequentially in one scope/DbContext (awaited foreach), not a 50-way fan-out; one connection at a time.
  • Inline email handlers (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.slnx0 errors.
  • dotnet test tests/Orbit.Infrastructure.Tests1150 pass, incl. 7 new OrbitConnectionStringFactory cases (caps, env-string override, session/default fallback, config defaults, empty).

🤖 Generated with Claude Code

…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>
Comment thread src/Orbit.Api/Extensions/WebApplicationExtensions.cs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 158fa0a into main Jun 24, 2026
8 checks passed
@thomasluizon
thomasluizon deleted the fix/db-connection-pool-exhaustion branch June 24, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant