Skip to content

fix(server): stop leaking sqlx migration locks through the Neon pooler - #713

Merged
ducnmm merged 5 commits into
devfrom
fix/walm-378-migration-pooler-lock
Aug 24, 2026
Merged

fix(server): stop leaking sqlx migration locks through the Neon pooler#713
ducnmm merged 5 commits into
devfrom
fix/walm-378-migration-pooler-lock

Conversation

@ducnmm

@ducnmm ducnmm commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes WALM-378.

What happened

Dev relayer was hard down 07:30–07:44 UTC. It booted, applied main DB migrations, then panicked:

Failed to initialize legacy security-delete database:
  legacy migration failed: canceling statement due to lock timeout

Railway reported SUCCESS/RUNNING (no healthcheck). The process never bound :3001, so every request was 502.

Why it looks random, and why staging/prod stayed up

sqlx migrations take a session-scoped pg_advisory_lock. LEGACY_DB_URL and DATABASE_URL both go through Neon’s transaction-mode pooler. LOCK and UNLOCK can land on different backends; a client disconnect returns the backend to the pool with the lock still held. Apalis then reused that backend for job-queue traffic, so the lock never cleared.

On the next boot the migrator waits on that lock. A leaked set_config('lock_timeout', '15s', false) from init_apalis_pool (session GUC, also pooler-leaked) cancels the wait after 15s, and .expect() takes the process down.

This only fires on restart, and only if a previous boot left the lock on a still-live pooled backend:

Env Why it looked fine
staging / prod Same code path, same pooler. They just weren’t redeployed into a held lock. Staging is on an older SHA and has been up; prod likewise. The next restart can hit this.
dev 9 deploys that day. Redeploy at 07:44 recovered because the lock happened to be free — then leaked again.

Not caused by #711 (7 TypeScript files, not even merged) or #706.

Fix

  1. Migrations run on the direct compute host (*-pooler.**.). Runtime query pools keep the pooled URL.
  2. Retry lock contention (5s lock_timeout, 6 attempts, backoff) instead of a single .expect().
  3. Stop setting session GUCs (set_config(..., false)) on the pooled Apalis connections. Boot is still bounded by tokio timeouts. Apalis schema setup, when needed, also uses the direct endpoint.

No Railway env change required — the pooler hostname is rewritten in code.

Tests

  • URL rewrite: Neon pooler → direct, credentials/query preserved, non-pooler URLs untouched
  • Lock-timeout / deadlock errors classified as retryable

WALM-378: sqlx takes a session-scoped pg_advisory_lock for migrations.
Through Neon's transaction-mode pooler that lock (and Apalis's
set_config session GUCs leak onto reused backends. The next boot waits
15s, times out, and panics — taking the relayer down until a lucky
redeploy.

Run sqlx/Apalis migrations on the direct compute endpoint, retry lock
contention instead of .expect(), and stop setting session GUCs on the
pooled Apalis connections.
EOF
)
ducnmm

This comment was marked as outdated.

ducnmm added 3 commits August 20, 2026 15:41
A leftover pooled session can still block the first boot after this
fix: advisory locks are database-wide, so the direct migrator waits on
the same key. On lock timeout, log the holder and pg_terminate_backend
only if that backend is idle (the WALM-378 shape). Also include the
direct host in migrate connect errors.
lock_timeout aborts the in-flight migrate statement. If sqlx had a
transaction open, later pg_locks / pg_terminate_backend queries fail
with "current transaction is aborted" and the idle holder is never
killed. ROLLBACK first, and treat idle-in-transaction like idle for
this lock key only.
Keep the explanation; do not stamp Linear identifiers into runtime
strings or source comments.
@ducnmm
ducnmm requested a review from nikola0x0 August 20, 2026 09:17

@nikola0x0 nikola0x0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pre-approve. No blocking correctness bugs. I built the branch and ran the new tests locally (cargo test --lib storage::, cargo check --bins) — everything compiles and the 4 new/updated test modules pass. I also independently re-derived sqlx's advisory-lock ID scheme (generate_lock_id in sqlx-postgres) and the pg_locks bit-reconstruction: classid/objid are oid (unsigned), so the (classid::bigint << 32) | objid::bigint reconstruction in release_orphaned_sqlx_migration_lock is correct without a mask — I initially suspected a sign-extension bug there and disproved it by checking the Postgres column types and SET_LOCKTAG_INT64 in the Postgres source. The direct-endpoint rewrite + retry/backoff design correctly addresses the actual leak mechanism (transaction-mode pooler returning a backend to the pool with a session-scoped lock still held).

Majors (details inline)

  1. init_apalis_pool (main.rs:483) — removing after_connect entirely also drops statement_timeout/idle_in_transaction_session_timeout protection from Apalis's runtime connections (job processing for the life of the process), not just the migration step that actually caused the incident. Worth confirming that's intentional, since set_config(name, value, true) (transaction-local) would close the same pooler-leak vector without giving that up. See inline comment.

Notes for whoever picks up related work

  • The retry + pg_terminate_backend self-healing path (apply_legacy_migrations, release_orphaned_sqlx_migration_lock) has no integration coverage — only the pure helpers (CRC/lock-id, error classification, URL rewrite) are unit-tested. Reasonable given how hard that is to test against a real pooler, just flagging the gap.
  • The pre-existing .expect("Failed to initialize legacy security-delete database") at main.rs:782 (outside this diff) still panics if all 6 retry attempts are exhausted (worst case ≈ 45s: 6× up to 5s lock_timeout + ~15.5s of backoff). That's fine for the incident as described (a leaked pooler backend clears well within that), but if Railway's healthcheck/deploy timeout is tighter than that window, a genuine prolonged lock (e.g., two pods migrating concurrently) would still reproduce the original 502 symptom, just far less often. Worth a sentence in the PR description if that's already been checked against Railway's config.

Minor (2) — details inline: unbounded setup_pool.close(), and string-matching vs. structured SQLSTATE for retry classification.

Comment thread services/server/src/main.rs
Comment thread services/server/src/main.rs Outdated
Comment thread services/server/src/storage/legacy_db.rs Outdated
… SQLSTATE

SET LOCAL in after_connect is a no-op under autocommit, so restore
statement_timeout / idle_in_transaction_session_timeout as session GUCs
and omit lock_timeout (the leak that aborted sqlx migrate). Bound
setup_pool.close() with the same startup timeout. Classify lock
contention on Postgres SQLSTATE (55P03 / 40P01) before mapping into
AppError, so a Display reword cannot disable retries.
@ducnmm
ducnmm merged commit f77f285 into dev Aug 24, 2026
17 checks passed
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.

2 participants