Skip to content

Zero-downtime expand/contract migration for the tenant_id PK widening (#33)#64

Merged
iarunsaragadam merged 8 commits into
mainfrom
feat/zero-downtime-migration
Jun 21, 2026
Merged

Zero-downtime expand/contract migration for the tenant_id PK widening (#33)#64
iarunsaragadam merged 8 commits into
mainfrom
feat/zero-downtime-migration

Conversation

@iarunsaragadam

Copy link
Copy Markdown
Contributor

Closes #33 — removes the ACCESS EXCLUSIVE availability hazard in the tenant_id PK widening (flagged by the #27 gate, deferred for pre-launch), before the tables grow large.

Migrate restructured: DDL now runs statement-by-statement on a dedicated pooled connection (not one wrapping tx), so CREATE UNIQUE INDEX CONCURRENTLY is usable. Cross-replica serialization moved from tx-scoped pg_advisory_xact_lock to a session-level pg_advisory_lock (released via WithoutCancel); lock_timeout set per session for fail-fast. The in-place ALTER TABLE … ADD PRIMARY KEY DO-blocks are gone.

Expand (boot/workspace migrate, idempotent, safe on populated tables): for each of the 5 tables, if the PK doesn't yet include tenant_id, build the composite key as CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS and leave the old PK intact — no ACCESS EXCLUSIVE, old+new binaries interoperate (no 42P10). Fresh DBs get the composite PK from CREATE TABLE (expand is a no-op). The two tenant-scoped secondary indexes also moved to CONCURRENTLY. An INVALID leftover index from a failed concurrent build is dropped + rebuilt (IF NOT EXISTS alone wouldn't).

Contract (separate, explicit workspace migrate --contract, never on boot): promotes each composite index to PRIMARY KEY via ADD CONSTRAINT … PRIMARY KEY USING INDEX (short metadata lock, adopts the prebuilt index) and drops the old PK. Run after the whole fleet is on the new binary.

Auto-migrate guard: GATEWAY_POSTGRES_AUTO_MIGRATE=false lets a large-DB operator run expand + contract out of band. Two-phase runbook documented in README.

Operational test (acceptance criterion): seeds 2000 rows, then during expand a second connection (a) fails on any sustained AccessExclusiveLock on the table in pg_locks and (b) asserts 50 concurrent INSERTs each complete <1s — proving writes aren't blocked. Plus idempotency (3× expand = no-op) and contract-promotes-to-PK. All existing postgres conformance stays green.

No proto change · go test -race ./... ✓ (real Postgres) · lint 0 ✓.

Closes #33

Restructure Migrate to run DDL outside a wrapping transaction under a
session-level advisory lock (pg_advisory_lock on a dedicated connection),
so CREATE UNIQUE INDEX CONCURRENTLY is usable and lock_timeout still makes
a contended statement fail fast. The expand phase builds each composite
(project_id, tenant_id, ...) key as a UNIQUE INDEX CONCURRENTLY, leaving
the old narrow PK intact so old and new binaries interoperate during a
rolling deploy. Tenant-scoped secondary partial indexes are likewise built
CONCURRENTLY. A new Contract method (workspace migrate --contract) promotes
those indexes to PRIMARY KEY USING INDEX and drops the old PK, as a
deliberate out-of-band step after the fleet is on the new binary.

Add an operational test that seeds a populated table and asserts the expand
holds no sustained ACCESS EXCLUSIVE lock and does not block concurrent
writes, plus expand idempotency and contract promotion, all in an isolated
schema.
Describe workspace migrate (expand) and workspace migrate --contract, the
auto-migrate guard for large databases, and the rolling-deploy ordering.
Use a non-cancellable release context for the migration advisory unlock.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 513fd6b63f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/repo/postgres/store.go Outdated
unlock := func() {
_, _ = conn.Exec(releaseCtx, "SELECT pg_advisory_unlock($1)", migrateAdvisoryLockKey)
}
if _, err := conn.Exec(ctx, "SET lock_timeout = '"+migrateLockTimeout+"'"); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reset lock_timeout before returning pooled connections

When auto-migrate runs on boot, lockAndPrepare sets lock_timeout with session scope on a connection acquired from the same pool used by request traffic, but the deferred cleanup only releases the advisory lock. After Migrate succeeds that connection is returned to the pool still capped at 5s, so any later repository call that happens to borrow it can fail while waiting on normal row/table locks even though migration is over. Previously SET LOCAL was scoped to the migration transaction; reset lock_timeout in the cleanup before releasing the connection.

Useful? React with 👍 / 👎.

Migrate/Contract now run on a short-lived pgx.Connect connection instead of
borrowing from the request-serving pool, so the per-session lock_timeout can
never leak onto a connection that later serves traffic; closing the dedicated
connection releases the advisory lock and discards its session GUCs.

Bound the advisory-lock acquisition: pg_advisory_lock is an unbounded wait that
lock_timeout does not cover, so a stuck/long out-of-band migrator would hang
every booting replica forever. Acquire via pg_try_advisory_lock polling against
a 30s deadline and fail fast with a clear error instead.

Replace the stale workspaces_personal_uniq index without a uniqueness gap: build
the tenant-scoped index CONCURRENTLY under a temp name first, then drop the stale
index and rename the temp in one short transaction.

Emit a structured migrate_contract_pending WARN after expand for any table whose
PK is not yet composite, so the expanded-but-not-contracted state is observable;
silent on a fresh (born-composite) database.
Parse the migrate subcommand with the flag package so an unknown or typo'd flag
errors with a non-zero exit instead of silently running expand; accept
-contract/--contract. Log migrate_start/migrate_complete with an explicit
phase=expand|contract.

Wrap the auto-migrate-on-boot expand in a bounded context so a replica blocked
on the migration lock fails readiness and reschedules rather than hanging on
context.Background() forever; out-of-band migrate keeps an unbounded context.
Pass the logger into the store so the contract-pending WARN is emitted.
Add tests on a populated isolated schema: expand-only steady state resolves
upserts and same-id-different-tenant writes against the composite unique index
while the PK stays narrow, and cross-tenant id reuse only succeeds after
contract; the migrate_contract_pending WARN fires after expand on an upgrading
DB and not on a fresh one; the migration lock_timeout does not leak onto pooled
connections; and the advisory-lock acquisition fails fast when the lock is held.

Document the dedicated-connection no-leak property, the bounded-lock failure
mode, the phase logging, the contract-pending signal, the gap-free index swap,
and that cross-tenant reuse activates only after contract.
Boot-path auto-migrate is now opt-in. A large existing DB's first deploy
can no longer livelock on a bounded CONCURRENTLY expand build inside the
boot window; migrations become a deliberate operator step. Small/dev DBs
opt in explicitly.
Export postgres.ErrMigrationLockHeld so the boot path can classify it.
A lock-held expand is transient and benign (another actor is migrating;
an expanded-but-not-contracted schema serves fine), so the service starts
without migrating and logs an alertable migrate_lock_contended instead of
a hard exit. A boot-window deadline logs a distinct migrate_boot_timeout;
any other schema/DDL error stays fatal.
…ting

README: auto-migrate is opt-in (false by default); fresh/small and dev run
workspace migrate explicitly or set GATEWAY_POSTGRES_AUTO_MIGRATE=true, the
recommended prod path is out-of-band expand then migrate --contract. Replace
the misleading fails-readiness wording with the start-without-migrating /
hard-exit behavior. docker-compose sets GATEWAY_POSTGRES_AUTO_MIGRATE=true so
docker compose up still creates the dev schema.
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.

Zero-downtime expand/contract migration for the tenant_id PK widening (before prod scale)

1 participant