From a6cc0455cfcbd619cf8405fd1fdb7da5144077d4 Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:26:49 -0400 Subject: [PATCH 1/8] fix(connection): gate the slot-invalidation query for PostgreSQL 15 (R05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pg_replication_slots.conflicting` was added in PostgreSQL 16, but the PG<17 invalidation query selected `wal_status, conflicting`. On PG15 that errors `column "conflicting" does not exist` (probe-confirmed on live PG 15.19), crashing a PG15 pipeline at the invalidation check into a reconnect storm — the same failure class as the A5 PG16/PG17 text-coercion regression, one major lower. Gate the query in three tiers by `server_version_num`: - PG15 (`< 160000`) -> `wal_status` - PG16 (`160000..169999`) -> `wal_status, conflicting` (unchanged) - PG17+ (`>= 170000`) -> `+ invalidation_reason, synced` (unchanged) `classify_slot_status/1` gains the matching PG15 1-col clause (`wal_status = 'lost'` is PG15's sole invalidation signal; recovery-conflict is not observable there by construction), and `coerce_status_row/1` a 1-col passthrough. PG16/17/18 behavior is byte-identical to before. Red-before-green: the query-builder PG15 test failed RED on `conflicting` present, and the classify PG15 test failed RED with FunctionClauseError, before this change. Verified against live PostgreSQL 15/16/17/18. --- lib/replicant/connection.ex | 16 +++++++++++++-- lib/replicant/query_builder.ex | 28 ++++++++++++++++++--------- test/replicant/connection_test.exs | 11 +++++++++++ test/replicant/query_builder_test.exs | 15 +++++++++++++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/lib/replicant/connection.ex b/lib/replicant/connection.ex index 2fb4a4e..35f0834 100644 --- a/lib/replicant/connection.ex +++ b/lib/replicant/connection.ex @@ -736,7 +736,9 @@ defmodule Replicant.Connection do @doc """ Classify a `pg_replication_slots` invalidation-status result (spec §5/§8). `[]` → - `:absent`. On the **PG16 2-col** row `[wal_status, conflicting]`: `wal_status = "lost"` → + `:absent`. On the **PG15 1-col** row `[wal_status]` (PG15 has no `conflicting` column): + `wal_status = "lost"` → `{:invalidated, :wal_lost}`, otherwise `:ok`. On the **PG16 2-col** + row `[wal_status, conflicting]`: `wal_status = "lost"` → `{:invalidated, :wal_lost}`; `conflicting = true` → `{:invalidated, :conflict}`; otherwise `:ok`. On the **PG17 4-col** row `[wal_status, conflicting, invalidation_reason, synced]`: the legacy signals classify first (same as above), then any non-empty `invalidation_reason` @@ -767,6 +769,13 @@ defmodule Replicant.Connection do end end + # PG15 1-col row `[wal_status]` — `conflicting` does not exist on PG15, so recovery-conflict + # is not observable by construction; `wal_status = 'lost'` (WAL removed) is PG15's sole + # invalidation signal. Anything else is :ok. + def classify_slot_status([[wal_status] | _rest]) do + if wal_status == "lost", do: {:invalidated, :wal_lost}, else: :ok + end + # Map PG's invalidation_reason enum string to a FIXED atom class (spec §5.2). NEVER # String.to_atom (atom-table exhaustion / Critical Rule 1) — an unknown/future reason maps # to the generic :invalidated so a new PG cause still halts fail-closed. @@ -1350,7 +1359,10 @@ defmodule Replicant.Connection do # Replication simple-query results arrive as TEXT; coerce the invalidation-status boolean # columns (conflicting, synced) so classify_slot_status / synced_unpromoted? see real booleans. - # 2-col PG16 row: [wal_status, conflicting]; 4-col PG17: [wal_status, conflicting, reason, synced]. + # 1-col PG15 row: [wal_status] (no boolean to coerce); 2-col PG16 row: [wal_status, conflicting]; + # 4-col PG17: [wal_status, conflicting, reason, synced]. + defp coerce_status_row([wal_status]), do: [wal_status] + defp coerce_status_row([wal_status, conflicting]), do: [wal_status, repl_bool(conflicting)] defp coerce_status_row([wal_status, conflicting, reason, synced]), diff --git a/lib/replicant/query_builder.ex b/lib/replicant/query_builder.ex index eb2a4b5..3d8abf5 100644 --- a/lib/replicant/query_builder.ex +++ b/lib/replicant/query_builder.ex @@ -166,21 +166,31 @@ defmodule Replicant.QueryBuilder do end @doc """ - Query returning the slot's invalidation signals (spec §5/§8). On **PG < 17** (`version < - 170000`): `wal_status` + `conflicting` (the PG16 columns; `invalidation_reason` errors there). - On **PG ≥ 17**: also `invalidation_reason` (Postgres's authoritative invalidation field) and - `synced` (true on a standby holding a slot synced from the primary). `wal_status = 'lost'` = - WAL removed; `conflicting = true` = standby recovery conflict; any non-null `invalidation_reason` - = invalidated. All are unrecoverable → fail-closed halt. + Query returning the slot's invalidation signals (spec §5/§8), gated by the numeric server + version because the available `pg_replication_slots` columns differ per major (probe-confirmed + on live PG 15/16/17/18): + + * **PG 15** (`version < 160000`) — `wal_status` ONLY. `conflicting` was added in PG16, so + selecting it on PG15 errors `column "conflicting" does not exist`. `wal_status = 'lost'` + is PG15's sole invalidation signal. + * **PG 16** (`160000 <= version < 170000`) — `wal_status` + `conflicting` + (`invalidation_reason`/`synced` were added in PG17 and error here). + * **PG 17+** (`version >= 170000`) — also `invalidation_reason` (Postgres's authoritative + invalidation field) and `synced` (true on a standby holding a slot synced from the primary). + + `wal_status = 'lost'` = WAL removed; `conflicting = true` = standby recovery conflict; any + non-null `invalidation_reason` = invalidated. All are unrecoverable → fail-closed halt. """ @spec slot_invalidation_status(String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, :invalid_identifier} def slot_invalidation_status(slot_name, version) do with :ok <- Identifier.validate(slot_name) do cols = - if version >= 170_000, - do: "wal_status, conflicting, invalidation_reason, synced", - else: "wal_status, conflicting" + cond do + version >= 170_000 -> "wal_status, conflicting, invalidation_reason, synced" + version >= 160_000 -> "wal_status, conflicting" + true -> "wal_status" + end {:ok, "SELECT #{cols} FROM pg_replication_slots " <> diff --git a/test/replicant/connection_test.exs b/test/replicant/connection_test.exs index 88661d8..27d90c8 100644 --- a/test/replicant/connection_test.exs +++ b/test/replicant/connection_test.exs @@ -565,6 +565,17 @@ defmodule Replicant.ConnectionTest do assert Connection.classify_slot_status([["reserved", true, nil, false]]) == {:invalidated, :conflict} end + + # PG15 has NO `conflicting` column, so the invalidation query returns a 1-col row + # `[wal_status]` (probe-confirmed). `wal_status = 'lost'` is PG15's sole invalidation + # signal; anything else is :ok. + test "PG15 1-col row: a reserved slot is :ok" do + assert Connection.classify_slot_status([["reserved"]]) == :ok + end + + test "PG15 1-col row: wal_status 'lost' is an invalidation (WAL removed → data gap)" do + assert Connection.classify_slot_status([["lost"]]) == {:invalidated, :wal_lost} + end end describe "handle_result(:publication_check)" do diff --git a/test/replicant/query_builder_test.exs b/test/replicant/query_builder_test.exs index 624d9ad..a43d8d5 100644 --- a/test/replicant/query_builder_test.exs +++ b/test/replicant/query_builder_test.exs @@ -114,7 +114,20 @@ defmodule Replicant.QueryBuilderTest do end describe "slot_invalidation_status/2" do - test "PG16 (version < 170000) selects only wal_status + conflicting (invalidation_reason errors on PG16)" do + test "PG15 (version < 160000) selects ONLY wal_status (conflicting errors on PG15)" do + # `conflicting` was added in PG16; on PG15 `SELECT ... conflicting` errors + # `column \"conflicting\" does not exist` (probe-confirmed). PG15's sole invalidation + # signal is `wal_status = 'lost'`. + assert {:ok, sql} = QueryBuilder.slot_invalidation_status("replicant_orders", 150_019) + assert sql =~ "wal_status" + refute sql =~ "conflicting" + refute sql =~ "invalidation_reason" + refute sql =~ "synced" + assert sql =~ "pg_replication_slots" + assert sql =~ "slot_name = 'replicant_orders'" + end + + test "PG16 (160000 <= version < 170000) selects wal_status + conflicting (invalidation_reason errors on PG16)" do assert {:ok, sql} = QueryBuilder.slot_invalidation_status("replicant_orders", 160_014) assert sql =~ "wal_status" assert sql =~ "conflicting" From 9e7cb1128f242a2e7e7559f042507eecb7a6fb3a Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:27:00 -0400 Subject: [PATCH 2/8] test(integration): prove version-gated failover across PostgreSQL 15-18 (R05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `test/integration/version_behavior_test.exs`, tagged `:integration` only (not `:pg17`), so it runs against whatever major `REPLICANT_TEST_URL` points at and branches on the live `server_version_num`: - failover slots are created on PG17/18 and structurally rejected on PG15/16 (`{:config, :failover_unsupported}` halt, no slot created) — PG15/16 reject the FAILOVER slot option (`unrecognized option: failover`, probe-confirmed); - the version-gated invalidation query runs on the live catalog (on PG15, selecting `conflicting` as pre-R05 would error); - each run emits an `R05-SUBSTRATE-RECEIPT pg=` line and, when `EXPECTED_PG_MAJOR` is set, asserts the live major matches it. This is the CI matrix's per-row non-vacuity proof: the same test runs on every version and proves the version-appropriate behavior. Green on live PostgreSQL 15/16/17/18. --- test/integration/version_behavior_test.exs | 178 +++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 test/integration/version_behavior_test.exs diff --git a/test/integration/version_behavior_test.exs b/test/integration/version_behavior_test.exs new file mode 100644 index 0000000..ba0b6b6 --- /dev/null +++ b/test/integration/version_behavior_test.exs @@ -0,0 +1,178 @@ +defmodule Replicant.Integration.VersionBehaviorTest do + @moduledoc """ + Cross-version transport + version-gated failover coverage (ticket R05, spec §12 Definition + of Done). Tagged `:integration` only (NOT `:pg17`), so it runs against WHATEVER live server + `REPLICANT_TEST_URL` points at — PostgreSQL 15, 16, 17, or 18 — and branches on the live + `server_version_num`. This is the CI matrix's per-row proof: each version row runs the SAME + test and proves the version-appropriate behaviour, so a row that pointed at the wrong version + (or skipped integration) is caught. + + Observes the REAL code path (Config → Connection → QueryBuilder → PG), not a hand-rolled + driver. Failover is proved created where PostgreSQL supports it (17, 18) and structurally + rejected where it does not (15, 16 → `{:config, :failover_unsupported}` halt). + """ + use ExUnit.Case, async: false + + alias Replicant.{Connection, QueryBuilder} + alias Replicant.Test.{PG16, RecordingSink} + + @moduletag :integration + + setup do + {:ok, ctrl} = Postgrex.start_link(PG16.pg_opts()) + {:ok, _} = RecordingSink.start_link() + RecordingSink.reset() + + slot = "repl_r05_ver_#{System.unique_integer([:positive])}" + pub = "repl_r05_pub_#{System.unique_integer([:positive])}" + + Postgrex.query!(ctrl, "CREATE TABLE IF NOT EXISTS r05_ver (id int PRIMARY KEY)", []) + Postgrex.query!(ctrl, "DROP PUBLICATION IF EXISTS #{pub}", []) + Postgrex.query!(ctrl, "CREATE PUBLICATION #{pub} FOR TABLE r05_ver", []) + drop_slot(ctrl, slot) + + on_exit(fn -> + Replicant.stop(slot) + PG16.wait_until(fn -> Registry.lookup(Replicant.Registry, {slot, :pipeline}) == [] end, 200) + {:ok, c} = Postgrex.start_link(PG16.pg_opts()) + drop_slot(c, slot) + Postgrex.query!(c, "DROP PUBLICATION IF EXISTS #{pub}", []) + end) + + %{ctrl: ctrl, slot: slot, pub: pub, version: server_version_num(ctrl)} + end + + @tag timeout: 30_000 + test "substrate receipt — the live server major matches EXPECTED_PG_MAJOR (CI matrix-row proof)", + %{version: version} do + major = div(version, 10_000) + + # The discoverable receipt CI greps for to prove THIS matrix row actually ran integration + # tests against the version it claims (non-vacuity — a skipped/mis-wired row emits nothing). + IO.puts("R05-SUBSTRATE-RECEIPT pg=#{major} version_num=#{version}") + + assert major in [15, 16, 17, 18], + "R05 supports PostgreSQL 15-18; live server_version_num=#{version} is out of range" + + case System.get_env("EXPECTED_PG_MAJOR") do + nil -> + :ok + + "" -> + :ok + + expected -> + assert major == String.to_integer(expected), + "matrix row expected PG#{expected} but the live server is PG#{major} " <> + "(version_num=#{version}) — REPLICANT_TEST_URL points at the wrong container" + end + end + + @tag timeout: 60_000 + test "the version-gated invalidation query runs on the live server and a healthy slot is :ok", + %{ctrl: ctrl, slot: slot, version: version} do + # Directly exercises the per-version column gate against the real catalog. On PG15 the + # query is `wal_status`-only; selecting `conflicting` (as pre-R05) would error here. + Postgrex.query!(ctrl, "SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot]) + + {:ok, status_sql} = QueryBuilder.slot_invalidation_status(slot, version) + + cond do + version >= 170_000 -> + assert status_sql =~ "invalidation_reason" + + version >= 160_000 -> + assert status_sql =~ "conflicting" + refute status_sql =~ "invalidation_reason" + + true -> + assert status_sql =~ "wal_status" + refute status_sql =~ "conflicting" + end + + rows = Postgrex.query!(ctrl, status_sql, []).rows + assert Connection.classify_slot_status(rows) == :ok + end + + @tag timeout: 60_000 + test "failover is version-gated: created on PG17+, structurally rejected on PG<17 (halt)", + %{ctrl: ctrl, slot: slot, pub: pub, version: version} do + if version >= 170_000 do + {:ok, _pid} = + Replicant.start_link( + connection: PG16.pg_opts(), + slot_name: slot, + publication: pub, + sink: RecordingSink, + go_forward_only: true, + failover: true + ) + + PG16.wait_until(fn -> slot_failover(ctrl, slot) == [[true]] end, 400) + + assert slot_failover(ctrl, slot) == [[true]], + "PG#{div(version, 10_000)} supports failover slots but the slot was not created " <> + "with failover=true — the FAILOVER grammar did not reach PG" + else + :telemetry.attach( + {__MODULE__, :failover_unsup, slot}, + [:replicant, :connection, :slot_invalidated], + fn _e, _m, meta, pid -> send(pid, {:failover_unsup, meta}) end, + self() + ) + + {:ok, _pid} = + Replicant.start_link( + connection: PG16.pg_opts(), + slot_name: slot, + publication: pub, + sink: RecordingSink, + go_forward_only: true, + failover: true + ) + + assert_receive {:failover_unsup, %{reason: :failover_unsupported}}, + 10_000, + "PG#{div(version, 10_000)} rejects failover slots; the pipeline must halt " <> + "{:config, :failover_unsupported} BEFORE emitting FAILOVER to the server" + + # The slot must NOT have been created — the gate halts before CREATE_REPLICATION_SLOT. + # Query by slot_name only: PG<17 has no `failover` column (selecting it would itself error). + assert slot_present?(ctrl, slot) == [], + "a failover-unsupported halt must never create the slot" + + :telemetry.detach({__MODULE__, :failover_unsup, slot}) + end + end + + defp slot_failover(conn, slot), + do: + Postgrex.query!(conn, "SELECT failover FROM pg_replication_slots WHERE slot_name = $1", [ + slot + ]).rows + + # Version-agnostic slot-existence probe — `slot_name` exists on every supported major + # (unlike `failover`, which is PG17+). Used on the PG<17 rejection path. + defp slot_present?(conn, slot), + do: + Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots WHERE slot_name = $1", [ + slot + ]).rows + + defp server_version_num(conn), + do: + Postgrex.query!(conn, "SHOW server_version_num", []).rows + |> hd() + |> hd() + |> String.to_integer() + + defp drop_slot(conn, slot) do + Postgrex.query!( + conn, + "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1", + [slot] + ) + rescue + _ -> :ok + end +end From 53767b3cc6a4084042ddb03530879490f800969b Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:27:13 -0400 Subject: [PATCH 3/8] ci: run the test matrix on PostgreSQL 15-18 with substrate-receipt discovery (R05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the CI matrix from PG16/17 to PostgreSQL 15, 16, 17, 18. Each row: - uses the operator-approved local port mapping (5615/5599/5617/5618) — `localhost:5432` is never the substrate — and pins the postgres image by manifest-list digest (a moved tag cannot silently change the tested image); - asserts the started container's `server_version_num` matches the matrix major BEFORE the suite runs (a mis-pinned digest reds, never a silent green against the wrong version); - exports `EXPECTED_PG_MAJOR` and, after `mix test`, greps the output for the `R05-SUBSTRATE-RECEIPT pg=` line the integration suite emits — so a row that skipped integration or wired the wrong version reds rather than passing vacuously (the grep is unanchored: ExUnit's inline progress dots prefix the receipt line). --- .github/workflows/ci.yml | 52 +++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81e76ca..2e307f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,27 +18,37 @@ env: jobs: test: - name: test (PG${{ matrix.postgres }}) + name: test (PG${{ matrix.pg.major }}) runs-on: ubuntu-latest strategy: fail-fast: false + # R05 supported-version matrix: PostgreSQL 15, 16, 17, 18. Each row uses the + # operator-approved local port mapping (5615/5599/5617/5618) — `localhost:5432` is + # never the substrate — and pins the image by manifest-list digest (supply-chain: a + # moved tag cannot silently change the tested image). `major` is exported as + # EXPECTED_PG_MAJOR so the integration suite fails a row wired to the wrong container. matrix: - postgres: ["16", "17"] + pg: + - { major: "15", port: "5615", digest: "sha256:5f72c7b5bd616308ccfd2e74d6be16fb06364e5eecbb815fe9dc6ab9761d2111" } + - { major: "16", port: "5599", digest: "sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5" } + - { major: "17", port: "5617", digest: "sha256:e38411452a464af89e5adadb8d223bf53b898d47d6ef918b2d58c08707350449" } + - { major: "18", port: "5618", digest: "sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941" } env: MIX_ENV: test - REPLICANT_TEST_URL: postgres://postgres@localhost:5432/postgres + REPLICANT_TEST_URL: postgres://postgres@localhost:${{ matrix.pg.port }}/postgres + EXPECTED_PG_MAJOR: ${{ matrix.pg.major }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Start PostgreSQL ${{ matrix.postgres }} with logical replication + - name: Start PostgreSQL ${{ matrix.pg.major }} with logical replication # A `services:` block cannot pass `-c wal_level=logical` to the container, so run # Postgres directly with the flags (mirrors AGENTS.md). Without wal_level=logical the # integration suite would silently skip and CI would be green-but-vacuous. run: | docker run -d --name pg \ -e POSTGRES_HOST_AUTH_METHOD=trust \ - -p 5432:5432 \ - postgres:${{ matrix.postgres }}@${{ matrix.postgres == '16' && 'sha256:95206741a5b214807675e14165369d05b93a9cf692223b616d07cca227e74b0b' || 'sha256:7958605b474b3d264a969cb3a123d6aa00ad1e1fe9da8a69984dabb704d93317' }} \ + -p ${{ matrix.pg.port }}:5432 \ + postgres:${{ matrix.pg.major }}@${{ matrix.pg.digest }} \ -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10 for _ in $(seq 1 30); do docker exec pg pg_isready -U postgres && break @@ -46,6 +56,17 @@ jobs: done docker exec pg pg_isready -U postgres || { echo "Postgres never became ready"; exit 1; } + - name: Assert the live server is the expected major (substrate wiring) + # Fail closed if the started container's version does not match the matrix row — a + # mis-pinned digest or wrong tag is caught BEFORE the suite runs, never a silent + # green against the wrong version. + run: | + got=$(docker exec pg psql -U postgres -tAc "SELECT current_setting('server_version_num')") + echo "server_version_num=$got" + major=$(( got / 10000 )) + test "$major" = "${{ matrix.pg.major }}" \ + || { echo "::error::started PG$major but the matrix row expects PG${{ matrix.pg.major }}"; exit 1; } + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 with: # Pinned to the dev toolchain (.tool-versions) so the format check, @@ -63,8 +84,8 @@ jobs: priv/plts # Key binds the cache to the pinned toolchain + mix.lock + .tool-versions, # so a toolchain or dep bump invalidates a stale PLT/build cache. - key: ${{ runner.os }}-pg${{ matrix.postgres }}-otp${{ env.OTP_VERSION }}-ex${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock', '.tool-versions') }} - restore-keys: ${{ runner.os }}-pg${{ matrix.postgres }}-otp${{ env.OTP_VERSION }}-ex${{ env.ELIXIR_VERSION }}- + key: ${{ runner.os }}-pg${{ matrix.pg.major }}-otp${{ env.OTP_VERSION }}-ex${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock', '.tool-versions') }} + restore-keys: ${{ runner.os }}-pg${{ matrix.pg.major }}-otp${{ env.OTP_VERSION }}-ex${{ env.ELIXIR_VERSION }}- - run: mix deps.get - name: Assert release runtime @@ -79,7 +100,20 @@ jobs: - run: mix format --check-formatted - run: mix compile --warnings-as-errors - run: mix credo --strict - - run: mix test --warnings-as-errors + - name: Run tests and prove this matrix row ran integration against PG${{ matrix.pg.major }} + # R05 CI discovery: the integration suite emits `R05-SUBSTRATE-RECEIPT pg=` from a + # live-server test. Grepping for THIS row's major proves the row actually exercised the + # integration suite against the version it claims — a skipped or mis-wired row (which + # would print nothing, or a different major) reds here rather than passing vacuously. + # (pipefail is on in the default Actions bash shell, so a `mix test` failure still fails + # the step before the grep.) + run: | + mix test --warnings-as-errors 2>&1 | tee test-output.log + # No `^` anchor: ExUnit prints inline progress dots with no trailing newline, so the + # receipt from IO.puts lands mid-line (`....R05-SUBSTRATE-RECEIPT ...`). The pattern is + # specific enough that a substring match anywhere is unambiguous. + grep -qE "R05-SUBSTRATE-RECEIPT pg=${{ matrix.pg.major }} version_num=" test-output.log \ + || { echo "::error::integration did not run against PG${{ matrix.pg.major }} (no R05-SUBSTRATE-RECEIPT)"; exit 1; } - run: mix dialyzer release-artifact: From 0d5142be677bd72d5ec2f2316118be1120023ae2 Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:27:15 -0400 Subject: [PATCH 4/8] docs: document supported PostgreSQL 15-18 and version-gated failover (R05) - README: replace the "PG16 baseline / forward-compat 17+" note with a tested-version table (15/16/17/18) and the per-version invalidation-column and failover-slot gating; the Livebook runs against 15/16/17/18 in CI. - AGENTS.md: state the supported majors, the operator-approved Docker port mappings (never `localhost:5432`), the new version-behavior test and its `R05-SUBSTRATE-RECEIPT`/`EXPECTED_PG_MAJOR` CI-discovery contract, and that the `:pg17` failover tests run on PG17 and PG18. - CHANGELOG: Added (proven 15-18 support + CI matrix/discovery) and Fixed (the PG15 `conflicting`-column query error) under Unreleased. --- AGENTS.md | 32 ++++++++++++++++++++++---------- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 21 ++++++++++++++++----- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b72f6bc..d82ae57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,16 +94,28 @@ Bypass with `git commit --no-verify` (CI still enforces both on push). modes. It never self-signs fixtures. An independent docker-PG16 capture (`test/integration/pg16_conformance_test.exs`) corroborates it against a live server. -- **Integration + crash-injection tests** (`test/integration/**`): gate - on `REPLICANT_TEST_URL` pointing at a live PG16 with `wal_level=logical`; - skip when unset. Spin PG16 with - `docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5599:5432 postgres:16 -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10` - then `export REPLICANT_TEST_URL="postgres://postgres@localhost:5599/postgres"`. -- **PG17 forward-compat tests** (`test/integration/pg17_failover_test.exs`, tagged `:pg17`): - run against a PG17 server. Spin one alongside PG16 and point `REPLICANT_TEST_URL` at it: - `docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5617:5432 postgres:17 -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10` - then `export REPLICANT_TEST_URL="postgres://postgres@localhost:5617/postgres"`. The `:pg17` - tests are auto-excluded (skipped, never vacuously passed) when the server is < 17. +- **Supported PostgreSQL versions: 15, 16, 17, 18.** Behavior is version-gated by + `server_version_num`: the slot-invalidation query selects only the columns that exist on + the connected major (PG15 → `wal_status`; PG16 → `+ conflicting`; PG17/18 → `+ + invalidation_reason, synced`), and failover slots are created on PG17/18 but structurally + rejected on PG15/16 (`{:config, :failover_unsupported}` halt — PG15/16 reject the FAILOVER + slot option). The CI matrix runs the full suite on all four majors. +- **Integration + crash-injection tests** (`test/integration/**`): gate on + `REPLICANT_TEST_URL` pointing at a live PostgreSQL with `wal_level=logical`; skip when + unset. **Operator-approved Docker port mappings (never `localhost:5432`): PG15 → 5615, + PG16 → 5599, PG17 → 5617, PG18 → 5618.** Spin any major with + `docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p :5432 postgres: -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10` + then `export REPLICANT_TEST_URL="postgres://postgres@localhost:/postgres"`. Run the + whole matrix locally by spinning all four and running `mix test` against each URL in turn. +- **Version-behavior tests** (`test/integration/version_behavior_test.exs`, tagged + `:integration`): run against whatever major `REPLICANT_TEST_URL` points at and branch on + the live version — proving failover is created on PG17+ and rejected on PG<17, and that the + version-gated invalidation query runs (on PG15 selecting `conflicting` would error). Each + run emits an `R05-SUBSTRATE-RECEIPT pg=` line; CI greps for the row's expected major + (via `EXPECTED_PG_MAJOR`) to prove the matrix row actually ran integration non-vacuously. +- **PG17+ failover tests** (`test/integration/pg17_failover_test.exs`, tagged `:pg17`): run + against a PG17 or PG18 server; auto-excluded (skipped, never vacuously passed) when the + server is < 17. - **TDD:** write the test first. ## Docs & lifecycle-artifact policy diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f0c46..751f93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Proven support for PostgreSQL 15, 16, 17, and 18, with version-gated capabilities.** The CI + matrix now runs the full suite (Docker-only, `wal_level=logical`) against all four majors on the + operator-approved port mappings (`5615`/`5599`/`5617`/`5618`; `localhost:5432` is never used), + each matrix row asserting its live `server_version_num` matches the expected major and grepping + for an `R05-SUBSTRATE-RECEIPT pg=` line emitted by a live integration test — so a skipped + or mis-wired row reds rather than passing vacuously. A new + `test/integration/version_behavior_test.exs` runs against whatever major the substrate is and + branches on the live version: failover slots are proved created on PG17/18 and structurally + rejected on PG15/16 (`{:config, :failover_unsupported}` — those majors reject the `FAILOVER` + slot option). + - **Typed logical-slot consistent-point callback for go-forward append consumers.** The optional `Replicant.Sink` callback `handle_slot_origin/2` receives the LSN a go-forward stream begins at, on every connect and reconnect, before `START_REPLICATION`, for BOTH a @@ -28,6 +39,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 proves the new-slot origin falls in the source-WAL creation window and the reused origin advances and is bracketed by the live slot state across a forced reconnect. +### Fixed + +- **The slot-invalidation query no longer errors on PostgreSQL 15.** + `pg_replication_slots.conflicting` was added in PG16; the previous PG<17 query selected + `wal_status, conflicting`, so on PG15 it errored `column "conflicting" does not exist` — crashing + a PG15 pipeline at the invalidation check into a reconnect storm. The query is now gated in three + tiers by `server_version_num` (PG15 → `wal_status`; PG16 → `+ conflicting`; PG17+ → `+ + invalidation_reason, synced`), and `classify_slot_status/1` handles the PG15 single-column row + (`wal_status = 'lost'` is PG15's sole invalidation signal). Proven red-first at the unit level and + verified against live PostgreSQL 15/16/17/18. + ### Security - **Telemetry metadata and measurements are now validated by a closed key set AND a per-key diff --git a/README.md b/README.md index 6794c71..bfb6ea4 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,20 @@ fire-and-forget `wal_end + 1` ack does not have. ## PostgreSQL version support -Replicant targets **PostgreSQL 16 as the tested baseline** and is forward-compatible with -**17+**. On PG17+ it reads the authoritative `invalidation_reason` slot column (a superset of -the PG16 `wal_status`/`conflicting` signals) and supports **failover slots** for HA. +Replicant is **tested on PostgreSQL 15, 16, 17, and 18** — the CI matrix runs the full suite +against all four majors (Docker-only, `wal_level=logical`). Capabilities are gated by the +server's `server_version_num`, so a single build runs correctly across the range: + +| Capability | PG15 | PG16 | PG17 | PG18 | +|---|:---:|:---:|:---:|:---:| +| Logical streaming, snapshot, checkpoint, exactly-once | ✅ | ✅ | ✅ | ✅ | +| Slot-invalidation columns queried | `wal_status` | `+ conflicting` | `+ invalidation_reason, synced` | same as 17 | +| Failover slots (`failover: true`) | ❌ rejected | ❌ rejected | ✅ | ✅ | + +The slot-invalidation query selects only the columns that exist on the connected major +(`conflicting` was added in PG16, `invalidation_reason`/`synced` in PG17), so it never errors +on an older server. On PG17+ Replicant reads the authoritative `invalidation_reason` column (a +superset of the PG15/16 signals) and supports **failover slots** for HA. ### Failover slots (PG17+) @@ -110,7 +121,7 @@ Pass `failover: true` to `Replicant.start_link/1` to create the replication slot slot_name: "replicant_orders", publication: "orders_pub", sink: MyApp.OrdersSink, - failover: true # PG17+ only; on PG16 the pipeline halts {:config, :failover_unsupported} + failover: true # PG17+ only; on PG15/16 the pipeline halts {:config, :failover_unsupported} ) After a failover, repoint the connection at the promoted primary — the slot already exists @@ -154,7 +165,7 @@ demonstrates the unchanged-TOAST sentinel, transaction-granularity exactly-once, snapshot/backfill, and logical-decoding messages. Click the badge to open it in [Livebook](https://livebook.dev), or read it rendered on [HexDocs](https://hexdocs.pm/replicant/getting_started.html). The notebook's code -is executed against a live PG16/PG17 on every CI run +is executed against live PostgreSQL 15/16/17/18 on every CI run (`test/integration/livebook_getting_started_test.exs`), so it never drifts from the library. From b739f08acdca2bb77d969219cde0bb36d72c934f Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:31:35 -0400 Subject: [PATCH 5/8] fix(tests): always detach the version gate handler Use a unique telemetry handler id and register on-exit cleanup so a failed failover assertion cannot leak a global handler into later randomized tests. --- test/integration/version_behavior_test.exs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/integration/version_behavior_test.exs b/test/integration/version_behavior_test.exs index ba0b6b6..31933bd 100644 --- a/test/integration/version_behavior_test.exs +++ b/test/integration/version_behavior_test.exs @@ -114,12 +114,17 @@ defmodule Replicant.Integration.VersionBehaviorTest do "PG#{div(version, 10_000)} supports failover slots but the slot was not created " <> "with failover=true — the FAILOVER grammar did not reach PG" else - :telemetry.attach( - {__MODULE__, :failover_unsup, slot}, - [:replicant, :connection, :slot_invalidated], - fn _e, _m, meta, pid -> send(pid, {:failover_unsup, meta}) end, - self() - ) + handler = {__MODULE__, :failover_unsup, make_ref()} + + :ok = + :telemetry.attach( + handler, + [:replicant, :connection, :slot_invalidated], + fn _e, _m, meta, pid -> send(pid, {:failover_unsup, meta}) end, + self() + ) + + on_exit(fn -> :telemetry.detach(handler) end) {:ok, _pid} = Replicant.start_link( @@ -140,8 +145,6 @@ defmodule Replicant.Integration.VersionBehaviorTest do # Query by slot_name only: PG<17 has no `failover` column (selecting it would itself error). assert slot_present?(ctrl, slot) == [], "a failover-unsupported halt must never create the slot" - - :telemetry.detach({__MODULE__, :failover_unsup, slot}) end end From 8efb8b3fb92d9e9f8136cc0f0f2b937bdc368890 Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:39:25 -0400 Subject: [PATCH 6/8] fix(ci): close the version-matrix review gaps Disable persisted checkout credentials in both jobs and assert the PG17+ invalidation query includes the synced catalog field. --- .github/workflows/ci.yml | 4 ++++ test/integration/version_behavior_test.exs | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e307f3..eb25072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: EXPECTED_PG_MAJOR: ${{ matrix.pg.major }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Start PostgreSQL ${{ matrix.pg.major }} with logical replication # A `services:` block cannot pass `-c wal_level=logical` to the container, so run @@ -124,6 +126,8 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 with: otp-version: ${{ env.OTP_VERSION }} diff --git a/test/integration/version_behavior_test.exs b/test/integration/version_behavior_test.exs index 31933bd..f55119d 100644 --- a/test/integration/version_behavior_test.exs +++ b/test/integration/version_behavior_test.exs @@ -80,6 +80,7 @@ defmodule Replicant.Integration.VersionBehaviorTest do cond do version >= 170_000 -> assert status_sql =~ "invalidation_reason" + assert status_sql =~ "synced" version >= 160_000 -> assert status_sql =~ "conflicting" From 9744fb9d91d00a0d24574103b855bd140c9ace35 Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:41:14 -0400 Subject: [PATCH 7/8] docs: remove the prohibited default Postgres port Document all supported majors and use the approved PG16 host mapping in the contributor integration-test command. --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc88529..f2ceac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,9 @@ Thank you for your interest in contributing to Replicant! ## Prerequisites - **Elixir** 1.20.3 and **Erlang/OTP** 29 (the exact local/CI toolchain is in `.tool-versions`) -- **PostgreSQL 16** with `wal_level=logical` (for integration tests) — e.g. - `docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:16 \ +- **PostgreSQL 15, 16, 17, or 18** with `wal_level=logical` (for integration tests). + Use the approved host ports PG15 `5615`, PG16 `5599`, PG17 `5617`, or PG18 `5618` — e.g. + `docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5599:5432 postgres:16 \ -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10` ## Getting Started From ed6945ba1e2cd7f65560cd66c2384a8aef869262 Mon Sep 17 00:00:00 2001 From: Russ Palermo <175215383+palermo-git@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:52:59 -0400 Subject: [PATCH 8/8] test(integration): prove failover halt termination Monitor the unsupported pipeline through shutdown and make matrix teardown propagate cleanup failures while closing every Postgrex connection. --- test/integration/version_behavior_test.exs | 34 +++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/test/integration/version_behavior_test.exs b/test/integration/version_behavior_test.exs index f55119d..295afab 100644 --- a/test/integration/version_behavior_test.exs +++ b/test/integration/version_behavior_test.exs @@ -32,11 +32,25 @@ defmodule Replicant.Integration.VersionBehaviorTest do drop_slot(ctrl, slot) on_exit(fn -> - Replicant.stop(slot) - PG16.wait_until(fn -> Registry.lookup(Replicant.Registry, {slot, :pipeline}) == [] end, 200) - {:ok, c} = Postgrex.start_link(PG16.pg_opts()) - drop_slot(c, slot) - Postgrex.query!(c, "DROP PUBLICATION IF EXISTS #{pub}", []) + try do + Replicant.stop(slot) + + PG16.wait_until( + fn -> Registry.lookup(Replicant.Registry, {slot, :pipeline}) == [] end, + 200 + ) + + {:ok, c} = Postgrex.start_link(PG16.pg_opts()) + + try do + drop_slot(c, slot) + Postgrex.query!(c, "DROP PUBLICATION IF EXISTS #{pub}", []) + after + PG16.stop_conn(c) + end + after + PG16.stop_conn(ctrl) + end end) %{ctrl: ctrl, slot: slot, pub: pub, version: server_version_num(ctrl)} @@ -127,7 +141,7 @@ defmodule Replicant.Integration.VersionBehaviorTest do on_exit(fn -> :telemetry.detach(handler) end) - {:ok, _pid} = + {:ok, pid} = Replicant.start_link( connection: PG16.pg_opts(), slot_name: slot, @@ -137,11 +151,17 @@ defmodule Replicant.Integration.VersionBehaviorTest do failover: true ) + ref = Process.monitor(pid) + assert_receive {:failover_unsup, %{reason: :failover_unsupported}}, 10_000, "PG#{div(version, 10_000)} rejects failover slots; the pipeline must halt " <> "{:config, :failover_unsupported} BEFORE emitting FAILOVER to the server" + assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, + 10_000, + "a failover-unsupported pipeline must terminate after the halt signal" + # The slot must NOT have been created — the gate halts before CREATE_REPLICATION_SLOT. # Query by slot_name only: PG<17 has no `failover` column (selecting it would itself error). assert slot_present?(ctrl, slot) == [], @@ -176,7 +196,5 @@ defmodule Replicant.Integration.VersionBehaviorTest do "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1", [slot] ) - rescue - _ -> :ok end end