Test Environment Routing for PostgreSQL Extension Upgrades

Test environment routing is the control plane that decides where an extension upgrade payload runs, in what order it passes each validation stage, and whether it is ever allowed to reach production. For PostgreSQL DBAs and platform engineers, routing is not a network concern — it is a policy-driven workflow that provisions an isolated target, replays the upgrade as a transactional dry-run, and blocks promotion the instant a gate fails. Done well, it turns a fleet-wide ALTER EXTENSION UPDATE from a manual, high-blast-radius operation into a repeatable, auditable pipeline stage. This guide covers the routing controller end to end: dependency-pinned dispatch, ephemeral provisioning, idempotent dry-run execution, structured verdicts, and fail-fast error handling.

Up: Extension Upgrade Planning & Compatibility Validation — routing is the stage that consumes a validated version tuple and proves it survives a production-shaped rehearsal before any gate promotes it.

The Routing Decision Flow

Routing is a decision tree, not a straight pipe: every payload is resolved, provisioned, rehearsed, and then either promoted or quarantined. A candidate advances only when every stage returns a clean, machine-readable verdict; any failure short-circuits to triage instead of a partial write.

The routing decision flow, stage by stage An upgrade payload of an extension and target version enters dependency resolution, which pins a compatibility-matrix tuple. It is dispatched to a provisioned ephemeral environment that mirrors production topology, then a transactional dry-run applies and rolls back the update. A verdict gate branches: a pass promotes the payload to the next tier; a fail quarantines it under the FAILED_ROUTING label and loops back, via a rollback path, to dependency resolution for re-evaluation. Upgrade payload ext, target version Resolve deps pin matrix tuple Provision env mirror topology Transactional dry-run Verdict pass Promote emit success fail Quarantine FAILED_ROUTING rollback — re-resolve on failure

Each box is an automation boundary that reads state, makes one decision, and records a verifiable result. Dependency resolution decides whether the version tuple is even reachable; provisioning decides what shape of node it is rehearsed on; the transactional dry-run decides what would actually happen to the catalog; and the verdict gate decides whether the payload promotes or is quarantined. The rest of this page decomposes each boundary.

The routing control plane over a production-shaped ephemeral node An upgrade payload feeds a dependency-resolution gate that pins the (server major, extension, version) compatibility tuple, then dispatches into an ephemeral test node drawn as a mirror of production topology: a PgBouncer connection pooler feeding a primary with a streaming replica standby. Inside the primary, a dashed envelope wraps ALTER EXTENSION UPDATE TO between a BEGIN and a guaranteed ROLLBACK, so the catalog is asserted and then restored with zero committed state. The dry-run result reaches a verdict gate that either promotes the payload to the next tier or quarantines it under the FAILED_ROUTING label with a webhook notification. Upgrade payload ext, target version Dependency-resolution gate pin (major, ext, version) dispatch Ephemeral test node — mirror of production topology PgBouncer — connection pooler (mirrored) Primary BEGIN ALTER EXTENSION UPDATE TO v ROLLBACK Replica streaming standby WAL verdict Verdict pass Promote → next tier fail FAILED_ROUTING quarantine + webhook

Prerequisites

The routing controller fails closed: if a source is unreachable, a dependency is unresolved, or the dry-run cannot roll back cleanly, it refuses to promote rather than guessing. Confirm the environment before wiring it into a pipeline.

  • PostgreSQL version: 9.6 or newer on the target node. The pre-flight reads pg_extension and pg_available_extension_versions (present since 9.6); PostgreSQL’s transactional DDL — the basis for the dry-run — has been reliable for ALTER EXTENSION UPDATE across all supported majors.
  • Python packages: Python 3.8+ with psycopg2-binary (pip install psycopg2-binary). Only the standard library plus this driver is required. Async fleets can port the controller to asyncpg; the driver trade-offs are covered under ALTER EXTENSION Automation.
  • Required privileges: The dry-run must run as a role that owns the extension (or a superuser where the extension demands it), because ALTER EXTENSION UPDATE rewrites catalog rows. Scope that credential to the ephemeral environment only and keep it least-privilege per Security Boundaries & Permissions — production never needs to hand routing a standing superuser.
  • A pinned compatibility tuple: Routing must receive an already-validated (server_major, extension, version) from Compatibility Matrix Synchronization. Routing rehearses a tuple; it does not decide whether the tuple is allowed.
  • A topology descriptor: The provisioner needs a machine-readable description of production — primary/replica counts, connection-pooler layer, shared_preload_libraries order — so staging is a true mirror. Building that descriptor is the subject of Routing Staging Upgrades to Mirror Production Topology.

Core Concept: Transactional Dry-Run as the Routing Primitive

The primitive that makes routing safe is PostgreSQL’s transactional DDL. ALTER EXTENSION UPDATE TO runs inside a transaction block, so the controller can BEGIN, apply the update, inspect the resulting catalog state, and ROLLBACK — proving the migration scripts execute end-to-end without leaving a single committed byte behind. The dry-run is therefore not a static lint of the --from--to-- script; it is a real execution whose only difference from production is the missing COMMIT.

Two properties make this the right routing primitive:

  1. Idempotence. Because every rehearsal ends in ROLLBACK, the same payload can be routed repeatedly against the same environment with zero state drift. A retried pipeline run produces the same verdict, which is what lets routing sit inside a CI/CD stage that may re-execute on flakes.
  2. Determinism against production shape. A dry-run against an empty scratch database proves only that the script parses. A dry-run against a node whose catalog, extension set, and shared_preload_libraries order mirror production proves the update behaves under real dependency chains — the difference that catches could not find function and update-path gaps that a bare instance hides. Where those transitive requires chains govern the order of transitions, routing consumes the ordering computed by Dependency Tree Analysis rather than re-deriving it.

Routing stays strictly decoupled from execution: it dispatches and rehearses, and the committing ALTER EXTENSION UPDATE against production is owned separately by ALTER EXTENSION Automation. Keeping the boundary sharp is what lets routing run on every candidate without a maintenance window.

Step-by-Step Implementation

The following procedure turns the decision flow into a runnable controller. Each code block is complete and copy-pasteable; run every step against the ephemeral environment, never against production.

Step 1 — Resolve dependencies and pin the version tuple

Before dispatch, confirm the target version is reachable and that no transitive dependency demands a co-upgrade. Query pg_available_extension_versions on the target and cross-reference the published compatibility tuple rather than trusting a static manifest.

#!/usr/bin/env python3
"""
Pre-dispatch dependency and reachability check.
Confirms the target version is installable on this node before routing.
Requires: psycopg2-binary
"""
import json
import sys
import psycopg2


def resolve_target(dsn: str, extension: str, target_version: str) -> dict:
    """Return a routing decision for a single (extension, version) tuple."""
    conn = psycopg2.connect(dsn)
    try:
        with conn.cursor() as cur:
            # Is the extension installed, and at what version?
            cur.execute(
                "SELECT extversion FROM pg_extension WHERE extname = %s;",
                (extension,),
            )
            row = cur.fetchone()
            if row is None:
                return {"route": "reject", "reason": f"{extension} not installed"}
            current = row[0]

            # Is the target version actually offered by this node's catalog?
            cur.execute(
                """
                SELECT 1 FROM pg_available_extension_versions
                WHERE name = %s AND version = %s;
                """,
                (extension, target_version),
            )
            if cur.fetchone() is None:
                return {"route": "reject",
                        "reason": f"version {target_version} not in catalog"}

            if current == target_version:
                return {"route": "skip", "reason": "target already active"}

        return {"route": "dispatch", "from": current, "to": target_version}
    finally:
        conn.close()


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("usage: resolve.py <dsn> <extension> <target_version>")
        sys.exit(1)
    decision = resolve_target(sys.argv[1], sys.argv[2], sys.argv[3])
    print(json.dumps(decision))
    sys.exit(0 if decision["route"] in ("dispatch", "skip") else 2)

For comprehensive catalog behaviour and version-resolution semantics, consult the official PostgreSQL documentation on available extension versions.

Step 2 — Provision an ephemeral environment that mirrors topology

Routing must dispatch to a node whose architecture matches production, not a bare instance. The following provisioner spins up a disposable container, restores a production-shaped template (correct shared_preload_libraries, extension set, and pooler in front), and prints a DSN the controller routes against.

#!/usr/bin/env bash
# Provision a disposable, production-shaped test node and print its DSN.
# Fails closed: any provisioning error exits non-zero before routing runs.
set -euo pipefail

RUN_ID="route-$(date +%s)-$$"
PG_IMAGE="${PG_IMAGE:-postgres:16}"
TEMPLATE_DUMP="${1:?usage: provision.sh <production_template.sql>}"

# Match production shared library preloading exactly; ordering is significant.
PRELOAD="${SHARED_PRELOAD_LIBRARIES:-pg_stat_statements,pg_cron}"

cid="$(docker run -d --name "$RUN_ID" \
    -e POSTGRES_PASSWORD=routing \
    "$PG_IMAGE" \
    -c "shared_preload_libraries=${PRELOAD}")"

# Wait for readiness, then restore the production-shaped template.
until docker exec "$cid" pg_isready -q; do sleep 1; done
docker exec -i "$cid" psql -U postgres -v ON_ERROR_STOP=1 < "$TEMPLATE_DUMP"

port="$(docker port "$cid" 5432/tcp | cut -d: -f2)"
echo "{\"run_id\":\"${RUN_ID}\",\"dsn\":\"postgresql://postgres:routing@127.0.0.1:${port}/postgres\"}"

The provisioner is deliberately dumb about what to rehearse — it only guarantees the shape. The exact primary/replica and pooler-aware descriptor it consumes is built in Routing Staging Upgrades to Mirror Production Topology.

Step 3 — Run the idempotent transactional dry-run

With a dispatch decision and a DSN in hand, replay the upgrade inside a transaction that always rolls back. This verifies extension availability, checks the target version, applies the update, asserts the resulting catalog state, and undoes every change — returning a structured payload for the pipeline to gate on.

#!/usr/bin/env python3
"""
Idempotent transactional dry-run for a PostgreSQL extension upgrade.
Emits a structured verdict and never commits a catalog change.
Requires: psycopg2-binary
"""
import json
import sys
from contextlib import contextmanager

import psycopg2
from psycopg2 import sql


@contextmanager
def routing_connection(dsn: str):
    """Connection whose transaction is always rolled back on exit."""
    conn = psycopg2.connect(dsn)
    conn.autocommit = False
    try:
        yield conn
    finally:
        conn.rollback()   # guarantee zero side effects
        conn.close()


def dry_run_upgrade(dsn: str, extension: str, target_version: str) -> dict:
    result = {"status": "pending", "extension": extension,
              "target_version": target_version}

    with routing_connection(dsn) as conn:
        cur = conn.cursor()

        # 1. Extension must already be present in this environment.
        cur.execute("SELECT extversion FROM pg_extension WHERE extname = %s;",
                    (extension,))
        current = cur.fetchone()
        if current is None:
            result.update(status="failed", reason=f"{extension} not installed")
            return result
        if current[0] == target_version:
            result.update(status="skipped", reason="target already active")
            return result

        # 2. Target version must be offered by the catalog.
        cur.execute(
            "SELECT 1 FROM pg_available_extension_versions "
            "WHERE name = %s AND version = %s;",
            (extension, target_version),
        )
        if cur.fetchone() is None:
            result.update(status="failed",
                          reason=f"{target_version} unavailable in catalog")
            return result

        # 3. Transactional dry-run: apply, assert, roll back.
        try:
            cur.execute(
                sql.SQL("ALTER EXTENSION {} UPDATE TO {}").format(
                    sql.Identifier(extension), sql.Literal(target_version)
                )
            )
            cur.execute("SELECT extversion FROM pg_extension WHERE extname = %s;",
                        (extension,))
            applied = cur.fetchone()[0]
            if applied != target_version:
                raise RuntimeError("dry-run did not reach expected version")
            result.update(status="success", from_version=current[0],
                          dry_run_version=applied)
        except psycopg2.Error as exc:
            result.update(status="failed",
                          sqlstate=exc.pgcode,
                          reason=str(exc).strip())
        except RuntimeError as exc:
            result.update(status="failed", reason=str(exc))

    return result   # connection context has already rolled back


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("usage: dry_run.py <dsn> <extension> <target_version>")
        sys.exit(1)
    payload = dry_run_upgrade(sys.argv[1], sys.argv[2], sys.argv[3])
    print(json.dumps(payload, indent=2))
    sys.exit(0 if payload["status"] in ("success", "skipped") else 2)

Step 4 — Emit a routing verdict and gate promotion

The controller’s only output is a machine-readable verdict that downstream stages consume to decide promotion. On success it routes the payload forward; on failure it captures the exact SQLSTATE, tags the environment, and halts.

#!/usr/bin/env python3
"""Turn a dry-run payload into a promotion verdict for the pipeline."""
import json
import sys


def routing_verdict(payload: dict) -> dict:
    status = payload.get("status")
    if status in ("success", "skipped"):
        return {"decision": "promote", "next_stage": "async-simulation",
                "evidence": payload}
    # Any failure quarantines the environment and blocks promotion.
    return {"decision": "quarantine",
            "label": "FAILED_ROUTING",
            "sqlstate": payload.get("sqlstate"),
            "evidence": payload}


if __name__ == "__main__":
    verdict = routing_verdict(json.load(sys.stdin))
    print(json.dumps(verdict, indent=2))
    sys.exit(0 if verdict["decision"] == "promote" else 2)

A promote verdict routes the payload to the next validation tier; a quarantine verdict is a hard stop. Where the next tier models the upgrade against production-sized data across parallel clusters, routing hands off to Async Upgrade Simulation, which reuses the same checksummed tuple so staging and simulation evaluate byte-identical inputs.

Dry-Run & Validation Gate

The dry-run is the safety interlock: it exercises the real migration scripts but archives a verdict instead of committing. A clean pass looks like this:

{
  "status": "success",
  "extension": "postgis",
  "target_version": "3.5.0",
  "from_version": "3.4.1",
  "dry_run_version": "3.5.0"
}

Gate the promotion on three conditions before the payload advances:

  • status is success or skipped, never failed. A failed verdict carries an SQLSTATE and a reason; the controller must quarantine and route to triage rather than promote.
  • The rollback completed. The routing_connection context manager guarantees a ROLLBACK, so a post-run SELECT extversion FROM pg_extension on the ephemeral node must still report the original version. If it does not, the environment is contaminated and must be destroyed, not reused.
  • The verdict is pinned to the same tuple the matrix published. A dry-run that succeeds against a different (server_major, extension, version) than the one under promotion proves nothing. Carry the compatibility checksum through the verdict so the gate can reject a mismatched rehearsal.

Failure Modes & Error Taxonomy

Routing fails in a small, well-defined set of ways. Each has a distinctive signal — an SQLSTATE, a log line, or a controller verdict — and each maps to a specific recovery action. Capture the SQLSTATE from psycopg2.Error.pgcode so triage keys off a code, not a scraped string.

Symptom SQLSTATE / signal Root cause Controller action
extension "x" has no update path from version "A" to "B" 22023 (invalid_parameter_value) Node received a partial package upgrade; --from--to-- script missing locally Quarantine; reconcile packages so the node is catalog-identical, then re-route
could not find function ... in file server FATAL at call time .so built against wrong major / mismatched libc Quarantine; rebuild the artifact against the host toolchain before re-routing
permission denied for extension "x" 42501 (insufficient_privilege) Dry-run role does not own the extension Escalate per Security Boundaries & Permissions
function ... does not exist after update 42883 (undefined_function) Migration drops a signature a dependent object still calls Quarantine; resolve the dependent object via Dependency Tree Analysis
could not connect to server 08006 (connection_failure) Ephemeral node unreachable mid-route Fail closed; destroy and re-provision, never promote on an unverified node
Post-run version still advanced rollback did not fire An intervening COMMIT or autocommit connection Treat the environment as contaminated; destroy it and audit the controller path

When these signals recur across runs, route them into a structured classifier instead of reading logs by hand — Error Categorization Frameworks turns these SQLSTATEs and verdicts into automated triage signals, and known-bad tuples can be diverted early with Fallback Routing Strategies.

Rollback & Recovery Path

Routing never mutates production, so recovery is about the ephemeral environment and the verdict artifact, not the live catalog. A failed route is undone by tearing down the disposable node and reverting the promotion pointer.

  1. Trust the transaction, then verify it. The dry-run’s ROLLBACK already restores the ephemeral catalog; confirm with a post-run extversion read before the node is considered clean. A confirmed rollback means the environment is reusable.
  2. Tag and isolate on failure. Apply the FAILED_ROUTING label so the environment is never silently reused for another payload, and archive the full verdict — SQLSTATE, reason, and the pinned tuple — to an immutable artifact store for the change record.
  3. Destroy contaminated nodes. If the post-run read shows the version advanced, the rollback did not fire; destroy the container outright rather than attempting a manual ALTER EXTENSION ... UPDATE TO back to the prior version, which itself may fail.
  4. Block production promotion. Hold the payload out of the promotion queue until the dependency graph is reconciled or the compatibility tuple is re-published. Routing must never advance a payload whose last verdict was quarantine.
  5. Restore production if a live upgrade already leaked past routing. Routing is a rehearsal; if a committing upgrade downstream still fails, drive the restore through Snapshot & Point-in-Time Recovery rather than hand-editing the catalog.

Pair every verdict with Version Control & Branching so each routing decision is a reviewable commit and every quarantine is a reproducible record rather than an improvised fix.

Performance & Scale Considerations

The dry-run itself is cheap; the cost lives in provisioning and in fleets with many distinct topologies.

  • Provisioning is the long pole. Restoring a production-shaped template dominates route latency. Keep a warm pool of pre-restored ephemeral nodes keyed by topology descriptor so a route claims a ready node instead of building one, and bound every provisioning step with a timeout so a hung restore cannot stall the pipeline.
  • Parallelise across topologies, serialise per node. Distinct (server_major, topology) combinations can be routed concurrently on separate nodes, but a single ephemeral node runs one dry-run at a time — the transaction would otherwise contend for the same AccessExclusiveLock that ALTER EXTENSION UPDATE takes on the extension’s objects.
  • Lock window is short but real. Even in a rolled-back transaction, the update acquires locks for its duration; on a node carrying production-sized objects, measure that window so the eventual committing upgrade is budgeted correctly under Threshold Tuning for Downtime Windows.
  • Sample topologies, don’t rehearse every replica. On fleets of hundreds of identical replicas, route one representative node per topology generation and reconcile the rest against their package manifest, accepting that a divergent replica outside the sample is caught at the compatibility gate rather than rehearsed individually.

FAQ

Why route to an ephemeral environment instead of a long-lived staging server?

A long-lived staging server drifts: its catalog, extension set, and package versions diverge from production the moment someone hand-installs something. An ephemeral node restored from a production-shaped template is identical by construction on every route, which is what makes the dry-run’s verdict trustworthy. It also lets you tag and destroy a contaminated node instead of nursing a shared server back to a known-good state.

Does the transactional dry-run really prove the production upgrade will succeed?

It proves the migration scripts execute end-to-end against a production-shaped catalog and reach the expected version — which catches update-path gaps, privilege failures, and dropped-signature breakage. It cannot prove data-volume-dependent timing or replication-lag behaviour; those are modelled downstream in Async Upgrade Simulation. Routing answers “does this apply cleanly here?”; simulation answers “how does it behave at scale?”.

What privileges does the routing role actually need?

Enough to own the extension being rehearsed, because ALTER EXTENSION UPDATE rewrites catalog rows in pg_proc, pg_class, and pg_extension. Grant that only on the disposable node and never hand routing a standing production superuser. The dependency-resolution reads in Step 1 need only catalog SELECT, so split the credentials and keep each least-privilege per Security Boundaries & Permissions.

How does routing stay idempotent across pipeline retries?

Every rehearsal ends in ROLLBACK, so the ephemeral catalog returns to its starting version regardless of how many times the route runs. A retried CI/CD stage therefore produces the same verdict from the same inputs, with no accumulated state. The only non-idempotent step is provisioning, which is why each route gets a fresh node keyed by a unique run id.

What happens when a transitive dependency demands a co-upgrade?

Step 1 resolves the dependency graph before dispatch, so a version whose requires chain is unsatisfiable is rejected before an environment is ever provisioned. The ordering of co-upgrades comes from Dependency Tree Analysis; routing consumes that order and rehearses the transitions as a unit, so a partial co-upgrade never reaches the promotion gate.