Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,57 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- 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
sleep 1
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,
Expand All @@ -63,8 +86,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
Expand All @@ -79,7 +102,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=<major>` 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:
Expand All @@ -90,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 }}
Expand Down
32 changes: 22 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PORT>:5432 postgres:<MAJOR> -c wal_level=logical -c max_wal_senders=10 -c max_replication_slots=10`
then `export REPLICANT_TEST_URL="postgres://postgres@localhost:<PORT>/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=<major>` 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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<major>` 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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+)

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
16 changes: 14 additions & 2 deletions lib/replicant/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]),
Expand Down
28 changes: 19 additions & 9 deletions lib/replicant/query_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 " <>
Expand Down
Loading