PhoenixKit.Migrations.Repair.Environment (phoenix_kit v2.4.0)

Copy Markdown View Source

Spec §6.3's environment rules: pooled-connection detection and the advisory lock. classify_config/1 is pure (the config-based half of mix phoenix_kit.doctor's existing PgBouncer heuristic, doctor.ex:174-195, reused rather than re-derived); pooled?/1 and the lock functions execute real queries and have no unit test.

Detection is two-layered, on purpose

Config alone (a non-5432 port, a hostname containing "pgbouncer") is a fast, always-available hint — it is what mix phoenix_kit.doctor already prints a warning from. It is not proof: a direct Postgres connection can legitimately run on a non-standard port, and a transaction-pooling proxy can sit behind a hostname that says nothing about it. The authoritative signal is behavioral: pg_backend_pid() sampled twice over the SAME checked-out connection, as two separate (non-transactional) statements. A direct connection — or a session-pooling proxy, which behaves like one for this purpose — answers with the same pid both times, because both statements ride the one physical server backend for as long as the connection is checked out. Transaction-pooling PgBouncer can hand each statement a different backend connection, because nothing binds them together outside an explicit transaction. pooled?/1 therefore checks out one connection (Ecto.Repo.checkout/2) before sampling — sampling via two plain repo.query!/3 calls without checking out first would let Ecto's own pool (nothing to do with PgBouncer) hand the two queries to different workers and manufacture a false positive on a perfectly direct database.

Config classification and pid-sampling are combined with OR: either one saying "pooled" is enough to require --unsafe-pooled — false positives (an operator has to pass one extra flag) are the safe failure direction; false negatives (skipping VALIDATE/the advisory lock against a pooled connection that fooled both checks) are not.

Summary

Types

The config-only heuristic's verdict — a hint, never authoritative on its own.

Functions

The config-based half of the detection (doctor.ex's existing heuristic, reused verbatim): a non-5432 port, or a hostname containing "pgbouncer", is :maybe_pooled.

The advisory lock key repair uses — exposed for tests/diagnostics, never meant to be passed elsewhere.

The authoritative, behavioral check — see moduledoc. Checks out one connection and compares pg_backend_pid() across two separate statements on it. Fails toward true (pooled, the safer assumption) on any error — an environment we cannot prove is direct is treated as pooled, never the other way around.

Combines classify_config/1 and pooled?/1 with OR (see moduledoc). config is the repo's Application.get_env(app, repo, []) keyword list.

Acquires lock_key/0 (session-level pg_advisory_lock/1, blocking) on repo's checked-out connection, runs fun, then releases it — even if fun raises. Must run on a direct connection (§6.3): PgBouncer in transaction-pooling mode can hand the lock and unlock calls to different backend connections, silently defeating it. --unsafe-pooled skips calling this at all (PhoenixKit.Migrations.Repair never calls with_lock/2 when the environment was classified pooled and the flag was given).

Types

config_verdict()

@type config_verdict() :: :maybe_pooled | :direct

The config-only heuristic's verdict — a hint, never authoritative on its own.

Functions

classify_config(config)

@spec classify_config(keyword()) :: config_verdict()

The config-based half of the detection (doctor.ex's existing heuristic, reused verbatim): a non-5432 port, or a hostname containing "pgbouncer", is :maybe_pooled.

iex> Environment.classify_config(port: 5432, hostname: "db.internal")
:direct
iex> Environment.classify_config(port: 6432, hostname: "db.internal")
:maybe_pooled
iex> Environment.classify_config(port: 5432, hostname: "pgbouncer.internal")
:maybe_pooled
iex> Environment.classify_config(url: "ecto://user:pass@pgbouncer:6432/db")
:maybe_pooled

lock_key()

@spec lock_key() :: non_neg_integer()

The advisory lock key repair uses — exposed for tests/diagnostics, never meant to be passed elsewhere.

pooled?(repo)

@spec pooled?(Ecto.Repo.t()) :: boolean()

The authoritative, behavioral check — see moduledoc. Checks out one connection and compares pg_backend_pid() across two separate statements on it. Fails toward true (pooled, the safer assumption) on any error — an environment we cannot prove is direct is treated as pooled, never the other way around.

pooled?(repo, config)

@spec pooled?(
  Ecto.Repo.t(),
  keyword()
) :: boolean()

Combines classify_config/1 and pooled?/1 with OR (see moduledoc). config is the repo's Application.get_env(app, repo, []) keyword list.

with_lock(repo, fun)

@spec with_lock(Ecto.Repo.t(), (-> result)) :: result when result: term()

Acquires lock_key/0 (session-level pg_advisory_lock/1, blocking) on repo's checked-out connection, runs fun, then releases it — even if fun raises. Must run on a direct connection (§6.3): PgBouncer in transaction-pooling mode can hand the lock and unlock calls to different backend connections, silently defeating it. --unsafe-pooled skips calling this at all (PhoenixKit.Migrations.Repair never calls with_lock/2 when the environment was classified pooled and the flag was given).