Skip to content

fix(connection-manager): skip direct URL derivation for Session Pooler (port 6543) — fixes IPv6-only ECONNREFUSED#1006

Open
diazMelgarejo wants to merge 1 commit into
garrytan:masterfrom
diazMelgarejo:diazMelgarejo-session-pooler-ddl
Open

fix(connection-manager): skip direct URL derivation for Session Pooler (port 6543) — fixes IPv6-only ECONNREFUSED#1006
diazMelgarejo wants to merge 1 commit into
garrytan:masterfrom
diazMelgarejo:diazMelgarejo-session-pooler-ddl

Conversation

@diazMelgarejo
Copy link
Copy Markdown

TL;DR

2 files, ~15 lines. deriveDirectUrl() now returns null for Session Pooler URLs (port 6543), preventing the auto-derivation of db.<ref>.supabase.co:5432 — which is IPv6-only on most Supabase projects and causes ECONNREFUSED for users on standard IPv4 networks.

Closes garrytan/gstack#1301
See also: gstack IPv6 link-local fix garrytan/gstack#1249
Companion band-aid PR (9 migration files): #1005


The problem

connection-manager.ts auto-derives a "direct" DDL connection URL from any Supabase pooler URL:

postgresql://postgres.<ref>:pw@aws-N.pooler.supabase.com:6543/postgres
    → db.<ref>.supabase.co:5432   ← IPv6-only on most Supabase projects

On standard IPv4 networks — which is the majority of home/office/CI environments — connecting to db.<ref>.supabase.co:5432 fails with ECONNREFUSED because Supabase resolves that hostname to an IPv6 address (AAAA record only).

This crashes:

  • gbrain init --migrate-only (DDL → tries direct pool)
  • gbrain apply-migrations --yes (orchestrators spawn subprocesses that also hit this)
  • Any operation that routes through ddl() or bulk()

Why the current design was correct for Transaction Pooler but wrong for Session Pooler

The dual-pool architecture exists for a good reason: Transaction Pooler (port 5432 on *.pooler.supabase.com) cannot handle DDL because:

  1. It's stateless — each transaction may land on a different backend connection
  2. PgBouncer enforces a 2-minute statement_timeout which kills long schema migrations
  3. Startup parameters (statement_timeout, maintenance_work_mem) don't persist across transactions

So for Transaction Pooler, deriving a direct URL and opening a separate DDL pool is the right design.

Session Pooler (port 6543) has none of these constraints:

Property Transaction Pooler Session Pooler
Session state ✗ lost between txns ✓ preserved
statement_timeout ✗ 2-min PgBouncer cap ✓ configurable
Prepared statements ✗ cache invalidated ✓ work normally
DDL support ✗ needs direct conn ✓ native

Session Pooler IS a full session — it's PgBouncer in session mode. It can handle ALTER TABLE, CREATE INDEX CONCURRENTLY, long migrations, everything. There is no architectural reason to derive a separate direct URL for it.


The fix

// connection-manager.ts: deriveDirectUrl()

  const isPoolerHost = SUPABASE_POOLER_HOSTNAME_PATTERNS.some(re => re.test(hostname));
  if (port !== '6543' && !isPoolerHost) return null;
+ // Session Pooler (port 6543) maintains full session state and supports DDL
+ // natively — no separate direct connection needed. Deriving one produces
+ // db.<ref>.supabase.co:5432, which is IPv6-only on most networks.
+ // Users needing a dedicated DDL pool can set GBRAIN_DIRECT_DATABASE_URL.
+ if (port === '6543') return null;
  // User part on Supabase pooler is typically `postgres.<project-ref>`.

Result:

  • Port 6543 → deriveDirectUrl() returns null → isDualPoolActive() false → ddl() uses read pool (Session Pooler) → works on IPv4
  • Port 5432 on pooler host → unchanged → derives direct URL → existing behavior preserved
  • GBRAIN_DIRECT_DATABASE_URL explicit override → still respected → escape hatch intact

What about the 30-minute DDL statement_timeout?

The direct pool was configured with statement_timeout: 30min. The read pool via Session Pooler gets the timeout from resolveSessionTimeouts() — which defaults to 5min unless overridden.

For most users and most migrations, 5 minutes is sufficient. For very large databases with long-running schema changes, the escape hatch is:

export GBRAIN_DIRECT_DATABASE_URL="postgresql://u:p@ipv4-accessible-host:5432/db"

This could be a Supabase IPv6-capable host (VPN/Tailscale), a connection pooler you run yourself, or the direct URL once your network has IPv6 support. This env var already exists in the code — we're just documenting it as the power-user path.


Tests

Updated test/connection-manager.serial.test.ts:

  • Rewrote deriveDirectUrl suite to explicitly distinguish Session Pooler (→ null) vs Transaction Pooler (→ derives direct)
  • Added ConnectionManager routing tests for both pooler types
  • All 28 tests pass
bun test test/connection-manager.serial.test.ts
28 pass, 0 fail

Contrast with band-aid PR #1005

Band-aid (#1005) This PR
Files changed 9 migration orchestrators 2 (connection-manager + test)
Lines +126 +15
Root cause fixed ✗ (symptom)
Future migrations covered
Code duplication _childEnv() × 9 None
Mechanism GBRAIN_DISABLE_DIRECT_POOL=1 in child env Don't derive bad URL

If this PR merges, #1005 becomes unnecessary. If only #1005 merges, future migration files will need the same _childEnv() pattern added manually.


Happy to PR this fix if useful. Verified on gbrain v0.33.2.1, macOS 26.x, Supabase Session Pooler (port 6543).

…port 6543)

## Problem

deriveDirectUrl() fired for any Supabase pooler URL — both Transaction
Pooler (port 5432) and Session Pooler (port 6543) — always producing
db.<ref>.supabase.co:5432. That hostname is IPv6-only on most Supabase
projects, causing ECONNREFUSED for the majority of users on standard
IPv4 networks.

## Root cause

The dual-pool architecture (read pool → pooler, DDL pool → direct) was
designed to work around Transaction Pooler's constraints:
  - Stateless (no session across transactions)
  - 2-minute statement_timeout enforced by PgBouncer

Neither constraint applies to Session Pooler (port 6543), which runs
PgBouncer in session mode: full session state, configurable timeouts,
prepared statements, and native support for DDL.

## Fix

Return null from deriveDirectUrl() when port === '6543'. With no direct
URL, isDualPoolActive() returns false and ddl()/bulk() fall back to the
read pool (Session Pooler) — which handles DDL correctly.

Before: port 6543 URL → derives db.<ref>.supabase.co:5432 → IPv6 fail
After:  port 6543 URL → null → ddl() uses Session Pooler directly ✓

Transaction Pooler (port 5432 on *.pooler.supabase.com) is unchanged:
it still derives the direct URL because it cannot handle DDL.

## Escape hatch

Users who need a dedicated DDL pool (e.g. for 30-min schema migrations
on very large databases) can set GBRAIN_DIRECT_DATABASE_URL explicitly
to an IPv4-accessible direct or tunneled connection.

## Tests updated

connection-manager.serial.test.ts: rewrote the deriveDirectUrl suite
to cover Session Pooler (null) vs Transaction Pooler (derives direct)
as distinct cases. Added ConnectionManager routing test for both.

Closes: garrytan/gstack#1301
See also: garrytan/gstack#1249

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

/setup-gbrain: provision picks transaction pooler (6543) but new Supabase projects only listen on session pooler (5432)

1 participant