diff --git a/.github/workflows/CI-pg-compat.yml b/.github/workflows/CI-pg-compat.yml new file mode 100644 index 0000000000..30475cbd9d --- /dev/null +++ b/.github/workflows/CI-pg-compat.yml @@ -0,0 +1,47 @@ +# PAIRED FILE -- caller half of the CI-pg-compat pair (lives on v3.0). The +# reusable half, .github/workflows/gh-actions-reusable/ci-pg-compat.yml, is +# staged in THIS repo for review but must be merged to the `GH-Actions` +# branch FIRST, at path `.github/workflows/ci-pg-compat.yml`, before (never +# after) this caller file merges to v3.0 -- see doc/GH-Actions/README.md +# "Merge order" (~line 816): `workflow_run`/`workflow_call` references are +# only resolved against files that already exist on the target branch, so a +# caller landing before its reusable exists on GH-Actions fails immediately +# with "Unable to resolve action". See doc/GH-Actions/README.md (~lines +# 42-170) for the full two-branch caller/reusable split rationale. +name: CI-pg-compat + +on: + schedule: + - cron: '0 3 * * *' + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + pg-compat: + # Runs on: the nightly schedule, a manual dispatch, or a pull_request + # that carries the 'pg-compat' label (checked on every listed pull_request + # type, including 'labeled', so adding the label to an already-open PR + # triggers a run without needing a new commit). Unlike the TAP families, + # this does NOT chain off CI-trigger/CI-builds -- it builds ProxySQL + # inline in the reusable job, so it doesn't need CI-builds' cache to + # exist first (nightly/label runs have no guaranteed prior CI-builds run + # to restore from). + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'pg-compat') + # write-all: reusable-workflow permissions are the intersection of + # caller + callee: the callee at ci-pg-compat.yml@GH-Actions also + # declares write-all (needed for actions/upload-artifact's write scope + # under the pull_request event, matching CI-3p-postgresql.yml's + # documented rationale). + permissions: write-all # NOSONAR githubactions:S8234 — see the note above; matches all other caller workflows + uses: sysown/proxysql/.github/workflows/ci-pg-compat.yml@GH-Actions # NOSONAR githubactions:S7637 — branch ref matches all other caller workflows + secrets: inherit # NOSONAR githubactions:S7635 — matches all other caller workflows + with: + trigger: ${{ toJson(github) }} diff --git a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml new file mode 100644 index 0000000000..fd73a86ac0 --- /dev/null +++ b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml @@ -0,0 +1,165 @@ +# STAGED FILE -- this is the reusable half of the CI-pg-compat pair. +# It is authored here (on v3.0, under gh-actions-reusable/) for review, but +# it does NOT run from here. It must be merged to the `GH-Actions` branch +# at path `.github/workflows/ci-pg-compat.yml` FIRST, before (or in the same +# merge window as, but not after) the caller `.github/workflows/CI-pg-compat.yml` +# lands on `v3.0` -- see doc/GH-Actions/README.md "Merge order" (~line 816): +# a caller referencing `ci-pg-compat.yml@GH-Actions` before that file exists +# on GH-Actions fails immediately with "Unable to resolve action". See the +# two-branch caller/reusable split explained in doc/GH-Actions/README.md +# (~lines 42-170): callers (`CI-*.yml`, uppercase) live on `v3.0`; reusables +# (`ci-*.yml`, lowercase) live on `GH-Actions`. +name: CI-pg-compat + +on: + workflow_dispatch: + workflow_call: + inputs: + trigger: + type: string + +# No env.SHA/trigger-JSON parsing here (unlike the workflow_run-triggered +# reusables, e.g. ci-legacy-g4.yml): those need it because their caller is +# invoked BY workflow_run, whose own github.sha is the default branch tip, +# not the real source commit -- the real sha only exists inside the passed +# `trigger` JSON. This caller triggers directly via pull_request/schedule/ +# workflow_dispatch, so github.sha here (a workflow_call callee inherits the +# caller's context) already IS the right commit; `inputs.trigger` is kept +# only for parity with the sibling callers' `with: trigger: ...` shape and +# isn't parsed for a sha. checkout below uses actions/checkout@v4's default +# ref (the triggering ref), so no untrusted github.event.* field is ever +# substituted into a `ref:`. + +jobs: + pg-compat: + runs-on: ubuntu-22.04 + # Generous budget: a from-scratch `PROXYSQL31=1 make debug` (deps -> lib + # -> src) on a 2-core GH-hosted runner is the dominant cost here (there + # is no build-cache restore in this job, unlike the CI-builds-fed TAP + # families -- this suite runs inline, like the CI-3p-* family, since its + # schedule/label triggers have no guaranteed prior CI-builds run to + # restore a cache from). + timeout-minutes: 120 + permissions: write-all + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # This job only builds and tests; it never pushes. Leaving the job's + # write-all token persisted in .git/config would expose it to every + # subsequent step, including the third-party driver images this suite + # builds and runs. + persist-credentials: false + + # Inline build (CI-3p-* model, not the CI-trigger/CI-builds cache-chain + # model used by the TAP families): no ccache pattern exists elsewhere + # in this repo's workflows (checked both branches) to reuse, so this + # is a plain build for v1. PROXYSQL31=1 is required -- bare `make` + # would leave FFTO/TSDB symbols out and is not what any tier actually + # ships; debug is required because the isolated harness + # (start-proxysql-isolated.bash / ensure-infras.bash) issues + # debug-only admin commands. + - name: Build ProxySQL (debug, PROXYSQL31) + run: PROXYSQL31=1 make -j$(nproc) debug + + # start-proxysql-isolated.bash runs ProxySQL (and its filesystem helpers) + # from the LOCAL-ONLY tag `proxysql-ci-base:latest`, which nothing on a + # fresh runner provides -- without this step infra startup dies before a + # single pg-compat test runs. Every sibling TAP workflow pulls the image + # from GHCR and retags it; this mirrors ci-legacy-g4.yml, retry loop + # included, because both the login and the pull have been observed to + # fail transiently with a client timeout. + - name: Log in to GHCR and pull CI base image + env: + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + attempt=0 + max_attempts=5 + while [ $attempt -lt $max_attempts ]; do + attempt=$((attempt + 1)) + echo ">>> GHCR login+pull attempt ${attempt}/${max_attempts}" + if echo "$GHCR_TOKEN" | docker login ghcr.io \ + -u "$GHCR_USER" --password-stdin \ + && docker pull ghcr.io/sysown/proxysql-ci-base:latest; then + echo ">>> GHCR login+pull OK on attempt ${attempt}" + docker tag ghcr.io/sysown/proxysql-ci-base:latest \ + proxysql-ci-base:latest + exit 0 + fi + if [ $attempt -lt $max_attempts ]; then + sleep_for=$((attempt * 10)) + echo ">>> attempt ${attempt} failed; sleeping ${sleep_for}s" + sleep $sleep_for + fi + done + echo ">>> all ${max_attempts} GHCR attempts failed" + exit 1 + + # Stand up the pg-compat infra: dbdeployer PG17 primary+2-replica + # backend, Toxiproxy sidecar, and the ProxySQL container built above + # (ensure-infras.bash starts ProxySQL itself via + # start-proxysql-isolated.bash if it isn't already running -- see + # test/infra/control/ensure-infras.bash step 2). Never manage Docker + # by hand here; this script is the only supported entry point. + - name: Stand up infra (backends + Toxiproxy + ProxySQL) + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + TAP_GROUP: pg-compat + run: test/infra/control/ensure-infras.bash + + # Non-gating (discovery phase, spec sec 2.1): the suite's job right now + # is to build a failure inventory in xfail.toml, not to be all-green. + # `|| true` keeps this step (and therefore the job) from failing the + # workflow on real/uncatalogued divergences during discovery. Promote + # to gating by dropping `|| true` (and tightening xfail.toml) once the + # suite is green and stable -- see test/pg-compat/README.md. + # + # --junitxml path: run-pg-compat.bash's container runs with --rm, so a + # report written to the container's own filesystem (e.g. /tmp) would + # be destroyed on exit and never reach this runner -- traced and fixed + # in run-pg-compat.bash, which now bind-mounts a host directory + # (default "${WORKSPACE}/pg-compat-reports", override via + # PGCOMPAT_REPORT_DIR) to /pg-compat-reports inside the container. + # Writing the report there is what makes it visible to the upload + # step below. + - name: Run pg-compat suite (non-gating, discovery phase) + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + run: test/pg-compat/run-pg-compat.bash --junitxml=/pg-compat-reports/pg-compat.xml -rxX || true + + # The pg-compat container's default user is root, so the bind-mounted + # report directory is root-owned on the host afterwards; chown it back + # to the runner user before upload-artifact (which runs as the + # non-root runner account) tries to read it. Same pattern already + # used for docker-written logs in ci-3p-postgresql.yml. + - name: Fix report ownership + if: always() + run: sudo chown -R "$(id -u):$(id -g)" "${{ github.workspace }}/pg-compat-reports" || true + + - name: Publish report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pg-compat-report + path: ${{ github.workspace }}/pg-compat-reports/pg-compat.xml + if-no-files-found: warn + + # Teardown always runs, mirroring ci-legacy-g4.yml's cleanup step: + # stop the ProxySQL container first, then tear down the backend + + # Toxiproxy infra. destroy-infras.bash is test/infra/control's + # documented teardown entry point (paired with ensure-infras.bash). + - name: Cleanup + if: always() + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + TAP_GROUP: pg-compat + run: | + set +e + docker logs "proxysql.${INFRA_ID}" 2>&1 | tail -50 || true + test/infra/control/stop-proxysql-isolated.bash + test/infra/control/destroy-infras.bash diff --git a/.gitignore b/.gitignore index e27f4e6798..341ebaac8f 100644 --- a/.gitignore +++ b/.gitignore @@ -231,6 +231,10 @@ test/tap/tests/parsersql_digest_test test/tap/tests/setparser_parsersql_test deps/protobuf/protobuf-*/ +# pg-compat report output (run-pg-compat.bash's default host bind-mount +# target; see test/pg-compat/README.md "Report output") +/pg-compat-reports/ + # Stray build artifact: run-tests-isolated.bash creates the real mysqlbinlog # symlink at runtime under test-scripts/deps/; never commit this one (it was # accidentally committed once with an absolute /home path). diff --git a/docs/superpowers/plans/2026-07-08-pgsql-sp1-tap-coverage-gaps.md b/docs/superpowers/plans/2026-07-08-pgsql-sp1-tap-coverage-gaps.md new file mode 100644 index 0000000000..df88551b3c --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp1-tap-coverage-gaps.md @@ -0,0 +1,1049 @@ +# PostgreSQL SP-1 — TAP Coverage Gaps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the known behavioral gaps in ProxySQL's PostgreSQL TAP suite — a deliberate frontend auth-method matrix (cleartext/md5/scram), a systematic data-type/binary-encoding matrix, server-side cursors, pool-churn/session-isolation, and a LISTEN/NOTIFY per-mode contract test — all in the existing TAP/C++ harness, gating per-PR. + +**Architecture:** New `test/tap/tests/pgsql-*-t.cpp` files driven through two existing clients: **libpq** (via the `PGConnPtr` pattern) for functional/data tests, and the hand-rolled raw-socket **`pg_lite_client`** for byte-level control (auth framing, result formats, portal suspend). The auth matrix requires first extending `pg_lite_client` to speak MD5 and SCRAM-SHA-256 (today it only does cleartext), reusing the vendored `deps/libscram` client-side functions rather than hand-rolling crypto. Each test registers in `test/tap/groups/groups.json` under the pgsql16-backed `legacy-g4`/`mysql-*-g4` group set and runs under the isolated Docker harness. + +**Tech Stack:** C++17, TAP (`tap.h`, `command_line.h`), libpq (`libpq-fe.h`), raw sockets (`pg_lite_client.{h,cpp}`), `deps/libscram` (SCRAM-SHA-256 client), OpenSSL (MD5, already linked), Docker-based `test/infra` harness, `run-tests-isolated.bash`. + +## Global Constraints + +- **Debug build required** for the isolated harness (`proxysql-tester.py` issues `#ifdef DEBUG` admin commands). Build with `PROXYSQL31=1 make debug` and pass the SAME tier flag on every make; `make clean` when switching tiers (per `CLAUDE.md`). +- **Never manually set up Docker** — always use `test/infra/control/ensure-infras.bash` / `run-tests-isolated.bash`. After rebuilding proxysql, re-run `test/infra/control/start-proxysql-isolated.bash` to swap the binary. +- **Single-test runs** use the `TEST_PY_TAP_INCL` regex filter against the test's real group — never invent a throwaway group. +- **Frontend auth method** is the server variable `pgsql-authentication_method` (int, range 1–3): `1`=CLEAR_TEXT_PASSWORD, `2`=MD5_PASSWORD, `3`=SASL_SCRAM_SHA_256. Change it via admin `SET pgsql-authentication_method=N; LOAD PGSQL VARIABLES TO RUNTIME;`. Default is `3`. +- **CommandLine members** (from `test/tap/tap/command_line.h`): frontend/unprivileged test conn = `cl.pgsql_host` / `cl.pgsql_port` (6133) / `cl.pgsql_username` (`testuser`) / `cl.pgsql_password` (`testuser`); admin = `cl.admin_host` / `cl.admin_port` / `cl.admin_username` / `cl.admin_password`; direct backend = `cl.pgsql_server_host` / `cl.pgsql_server_port`. Guard every `main()` with `if (cl.getEnv()) return exit_status();`. +- **Discovery-phase framing (spec §2.1):** these SP-1 tests target existing libpq-path behavior and are expected to pass; where a case exercises the young native backend path (`pgsql-use_native_backend_protocol=on`), mark it xfail rather than failing the suite. +- **Makefile:** plain single-file libpq tests need NO Makefile edit (wildcard `*-t.cpp` + generic `%-t` rule). Tests that compile `pg_lite_client.cpp` need an explicit rule; tests that also link SCRAM need `-lscram -lusual -Wl,--allow-multiple-definition` appended. + +--- + +## Relationship to PR #5865 (PostgreSQL auth) — READ FIRST + +PR **#5865** ("SCRAM verifier & md5 credential storage with SCRAM/md5 backend pass-through", open, base `v3.0`) overlaps the auth portion of this plan and must be coordinated with: + +- **It already adds** `pgsql-verifier_auth-t` (~10 integration assertions), `pgsql-verifier_passthrough-t` (backend pass-through), and `pgsql_reconcile_unit-t` (9 unit cases) — registered in the **same** `legacy-g4` + `mysql-*-g4` group set this plan uses. +- **It already covers**, via **libpq**: the credential-storage-type × floor matrix (plaintext / `md5` / `SCRAM-SHA-256$…` verifier, users created at runtime with `PQencryptPasswordConn()`), floor reconciliation with `pgsql-authentication_method` 1/2/3, out-of-range floor (4) clamping, SCRAM-not-downgraded-under-cleartext-floor, anti-enumeration (identical error templates), and malformed-verifier-rejected-at-LOAD. +- **It does NOT** assert the wire-level auth **challenge type**, and **runs no queries** — its own note: *"Verifies FRONTEND auth only (connect succeeds/fails); no queries are run."* libpq hides which challenge (cleartext/md5/SASL) ProxySQL actually presented. +- It keeps `pgsql-authentication_method` an integer floor 1–3 (unchanged), and does **not** add SCRAM-SHA-256-PLUS / channel binding. It does **not** touch `pg_lite_client`. + +**Consequences for this plan:** +1. **De-duplicate.** Do NOT re-test what #5865 covers (storage-type × floor success/fail, anti-enumeration, malformed verifier, backend pass-through). This plan's auth test is repositioned to the **wire-level complement**: assert the *actual challenge type* ProxySQL presents for each floor and that the authenticated session executes a query — neither of which #5865 can do through libpq. +2. **The MD5/SCRAM enabler (Tasks 2–3) stands unchanged** — #5865 doesn't touch `pg_lite_client`, and SP-2 + the data-type/cursor tests need raw-client MD5/SCRAM regardless. +3. **Merge order.** Both touch `groups.json` and add pgsql auth tests. Develop this plan **on top of #5865** (or rebase onto it once merged) to avoid `groups.json` conflicts and to reuse its runtime user-creation pattern (`PQencryptPasswordConn()`) for the optional storage-type extension in Task 3 Step 7. + +--- + +## File Structure + +**New test files (all in `test/tap/tests/`):** +- `pgsql-auth_method_matrix-t.cpp` — frontend cleartext/md5/scram success + failure paths (uses `pg_lite_client`). +- `pgsql-datatype_matrix-t.cpp` — per-type text+binary round-trips asserting value, type OID, format code (uses `pg_lite_client`). +- `pgsql-server_side_cursors-t.cpp` — DECLARE/FETCH/MOVE/CLOSE + extended-protocol portal suspension (libpq + `pg_lite_client`). +- `pgsql-pool_churn-t.cpp` — connection storm vs max_connections + session-state isolation across multiplexed reuse (libpq). +- `pgsql-listen_notify_contract-t.cpp` — per-mode LISTEN rejection / NOTIFY-as-query contract (libpq). + +**Modified harness files:** +- `test/tap/tests/pg_lite_client.h` — declare MD5 + SCRAM auth helpers. +- `test/tap/tests/pg_lite_client.cpp` — implement MD5 (Task 2) and SCRAM (Task 3) in `handleAuthentication`. +- `test/tap/tests/Makefile` — explicit build rules for the four `pg_lite_client`-using tests (Task 1/2/3/4/5), with `-lscram -lusual` on the auth-matrix rule. +- `test/tap/groups/groups.json` — register the five new tests. + +**Interfaces produced by the harness tasks (consumed by later tasks):** +- `pg_lite_client` gains no signature changes to `connect()`; MD5/SCRAM are handled internally inside `handleAuthentication(const std::string& password)`. After Task 3, `PgConnection::connect(host, port, dbname, user, password)` succeeds against a ProxySQL frontend configured for cleartext, md5, or scram. + +--- + +## Task 1: Wire-level auth-challenge test scaffold + cleartext case + +**Purpose (post-#5865): the wire-level complement.** #5865 already proves *connect success/fail* per storage-type × floor through libpq. This test proves the thing libpq hides — that ProxySQL presents the **correct challenge type on the wire** for the configured floor, and that the authenticated session **runs a query**. Task 1 establishes the file, the admin floor-toggling helper, a tiny `pg_lite_client` accessor exposing the observed challenge type, and the cleartext case (challenge type `3`, already supported). + +**Files:** +- Create: `test/tap/tests/pgsql-auth_method_matrix-t.cpp` +- Modify: `test/tap/tests/pg_lite_client.h` + `pg_lite_client.cpp` (add `getLastAuthType()` accessor) +- Modify: `test/tap/tests/Makefile` (add explicit rule compiling `pg_lite_client.cpp`) +- Modify: `test/tap/groups/groups.json` (register the test) + +**Interfaces:** +- Consumes: `pg_lite_client.h` `PgConnection::connect/execute`, `command_line.h` `CommandLine`. +- Produces: `PgConnection::getLastAuthType()` (first non-zero auth request type observed during `connect()`); helpers `set_frontend_auth_method(MYSQL*, int)` and `try_frontend_login(user, password, int& observed_auth_type)` reused by Tasks 2–3. +- **Challenge-type codes** (PostgreSQL `Authentication*` request type ints): cleartext = `3`, md5 = `5`, SASL/SCRAM = `10`. + +- [ ] **Step 1a: Add the `getLastAuthType()` accessor to pg_lite_client** + +In `test/tap/tests/pg_lite_client.h`, add a public member + getter to `PgConnection` (near `getSocket()`): + +```cpp + inline int getLastAuthType() const { return last_auth_type_; } +``` +and in the `private:` data section: +```cpp + int last_auth_type_ = 0; +``` +In `test/tap/tests/pg_lite_client.cpp`, inside `handleAuthentication`, set it the first time a non-zero `authType` is seen — add `if (last_auth_type_ == 0 && authType != 0) last_auth_type_ = authType;` immediately after `authType` is computed from the `AUTH_TYPE` message (so cleartext records `3`; Tasks 2–3 will make it record `5`/`10`). + +- [ ] **Step 1b: Write the failing test (cleartext case)** + +Create `test/tap/tests/pgsql-auth_method_matrix-t.cpp`: + +```cpp +#include +#include +#include +#include // admin interface is reached via the MySQL client +#include "pg_lite_client.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; + +// Admin connection (MySQL protocol) used to flip pgsql-authentication_method. +static MYSQL* admin_connect() { + MYSQL* conn = mysql_init(NULL); + if (!mysql_real_connect(conn, cl.admin_host, cl.admin_username, cl.admin_password, + NULL, cl.admin_port, NULL, 0)) { + diag("admin connect failed: %s", mysql_error(conn)); + mysql_close(conn); + return NULL; + } + return conn; +} + +static bool set_frontend_auth_method(MYSQL* admin, int method) { + std::string q = "SET pgsql-authentication_method=" + std::to_string(method); + if (mysql_query(admin, q.c_str())) { diag("SET failed: %s", mysql_error(admin)); return false; } + if (mysql_query(admin, "LOAD PGSQL VARIABLES TO RUNTIME")) { diag("LOAD failed: %s", mysql_error(admin)); return false; } + return true; +} + +// Attempts a frontend login with pg_lite_client, running a query to prove the +// session is usable. On success, observed_auth_type = the challenge type ProxySQL +// presented (3=cleartext, 5=md5, 10=scram). Returns true on successful auth+query. +static bool try_frontend_login(const std::string& user, const std::string& password, + int& observed_auth_type) { + observed_auth_type = 0; + try { + PgConnection c(2000); + c.connect(cl.pgsql_host, cl.pgsql_port, user /*dbname==user in this infra*/, user, password); + observed_auth_type = c.getLastAuthType(); + c.execute("SELECT 1"); // #5865 runs NO queries; proving the session works is our value-add + c.disconnect(); + return true; + } catch (const PgException& e) { + diag("login threw: %s", e.what()); + return false; + } +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + + // Per method: (login succeeds + query runs) AND (observed challenge type matches floor). + // Task 1 lands cleartext only (2 assertions); Tasks 2-3 add md5, scram, and failures. + plan(2); + + MYSQL* admin = admin_connect(); + if (!admin) BAIL_OUT("cannot reach admin"); + + // --- Cleartext floor (method = 1) -> expect challenge type 3 on the wire --- + set_frontend_auth_method(admin, 1); // affects NEW frontend connections + int auth_type = 0; + bool logged_in = try_frontend_login(cl.pgsql_username, cl.pgsql_password, auth_type); + ok(logged_in, "cleartext floor: login + query succeed"); + ok(auth_type == 3, "cleartext floor: ProxySQL presented challenge type 3 (got %d)", auth_type); + + // restore default before exit + set_frontend_auth_method(admin, 3); + mysql_close(admin); + return exit_status(); +} +``` + +- [ ] **Step 2: Add the explicit Makefile rule** + +In `test/tap/tests/Makefile`, alongside the other `pg_lite_client.cpp` rules (near line 370), add: + +```make +pgsql-auth_method_matrix-t: pgsql-auth_method_matrix-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -o $@ +``` + +(The `-lscram -lusual` is added now so Task 3 needs no further Makefile change.) + +- [ ] **Step 3: Register in groups.json** + +In `test/tap/groups/groups.json`, add an entry mirroring `pgsql-scram_cache_invalidation-t` (the pgsql16-backed group set): + +```json + "pgsql-auth_method_matrix-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], +``` + +- [ ] **Step 4: Build proxysql (debug) + the test, then start infra** + +```bash +PROXYSQL31=1 make -j$(nproc) debug +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 test/infra/control/ensure-infras.bash +``` + +- [ ] **Step 5: Run the single test, expect PASS** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-auth_method_matrix-t" \ + test/infra/control/run-tests-isolated.bash +``` +Expected: 2/2 assertions pass (cleartext already works). If the cleartext login fails, confirm `pg_lite_client` cleartext path (`handleAuthentication` authType==3) and that `pgsql_users` contains `testuser`. + +- [ ] **Step 6: Commit** + +```bash +git add test/tap/tests/pgsql-auth_method_matrix-t.cpp test/tap/tests/Makefile test/tap/groups/groups.json +git commit -m "test(pgsql): auth-method matrix scaffold + cleartext case" +``` + +--- + +## Task 2: MD5 frontend auth (pg_lite_client + matrix case) + +Extend `pg_lite_client` to answer an `AuthenticationMD5Password` (authType 5) challenge, then add the md5 success + wrong-password cases. + +**Files:** +- Modify: `test/tap/tests/pg_lite_client.cpp` (`handleAuthentication`, new `sendMD5Password`) +- Modify: `test/tap/tests/pg_lite_client.h` (declare `sendMD5Password`) +- Modify: `test/tap/tests/pgsql-auth_method_matrix-t.cpp` (add md5 cases, bump plan) + +**Interfaces:** +- Consumes: OpenSSL `MD5()` (already linked via `-lcrypto`), `PgConnection::user_` (private member set in `connect()`). +- Produces: MD5 handling inside `handleAuthentication`; no public signature change. + +- [ ] **Step 1: Write the failing test (add md5 cases)** + +In `pgsql-auth_method_matrix-t.cpp`, bump `plan(2)` → `plan(4)` and after the cleartext block add: + +```cpp + // --- MD5 floor (method = 2) -> expect challenge type 5 on the wire --- + set_frontend_auth_method(admin, 2); + int md5_auth = 0; + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password, md5_auth), + "md5 floor: login + query succeed"); + ok(md5_auth == 5, "md5 floor: ProxySQL presented challenge type 5 (got %d)", md5_auth); +``` + +(No extra `pg_lite_client` change is needed to observe the type — the generic `last_auth_type_` capture from Task 1 Step 1a records `5` as soon as the MD5 branch added below runs.) + +- [ ] **Step 2: Rebuild the test and run — expect FAIL** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-auth_method_matrix-t" test/infra/control/run-tests-isolated.bash +``` +Expected: the md5 login assertion FAILS — `pg_lite_client` throws `Unsupported authentication method: 5`. + +- [ ] **Step 3: Declare the MD5 helper** + +In `test/tap/tests/pg_lite_client.h`, in the `private:` section of `PgConnection` (near `sendPassword`), add: + +```cpp + void sendMD5Password(const std::string& password, const uint8_t salt[4]); +``` + +- [ ] **Step 4: Implement MD5 in pg_lite_client.cpp** + +In `test/tap/tests/pg_lite_client.cpp`, add the include near the top: + +```cpp +#include +``` + +Add the helper (near `sendPassword`, ~line 343): + +```cpp +static std::string md5_hex(const std::string& in) { + unsigned char digest[MD5_DIGEST_LENGTH]; + MD5(reinterpret_cast(in.data()), in.size(), digest); + static const char* hx = "0123456789abcdef"; + std::string out; + out.reserve(MD5_DIGEST_LENGTH * 2); + for (int i = 0; i < MD5_DIGEST_LENGTH; ++i) { + out.push_back(hx[digest[i] >> 4]); + out.push_back(hx[digest[i] & 0x0f]); + } + return out; +} + +// PostgreSQL MD5 auth: "md5" + md5( md5(password + user) + salt ) +void PgConnection::sendMD5Password(const std::string& password, const uint8_t salt[4]) { + std::string inner = md5_hex(password + user_); + std::string with_salt = inner; + with_salt.append(reinterpret_cast(salt), 4); + std::string token = "md5" + md5_hex(with_salt); + std::vector packet; + writeStringToBuffer(packet, token); // null-terminated C string + sendMessage('p', packet); +} +``` + +In `handleAuthentication`, add a branch after the `authType == 3` block, before the `else { throw ... }`: + +```cpp + else if (authType == 5) { // AuthenticationMD5Password (4-byte salt follows) + if (buffer.size() < 8) throw PgException("Invalid MD5 auth message"); + uint8_t salt[4]; + memcpy(salt, buffer.data() + 4, 4); + sendMD5Password(password, salt); + readMessage(type, buffer); + if (type == AUTH_TYPE) { + authType = ntohl(*reinterpret_cast(buffer.data())); + if (authType == 0) return; + } + // fall through to error handling on non-OK + } +``` + +- [ ] **Step 5: Rebuild + run — expect PASS** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-auth_method_matrix-t" test/infra/control/run-tests-isolated.bash +``` +Expected: 4/4 pass. + +- [ ] **Step 6: Commit** + +```bash +git add test/tap/tests/pg_lite_client.h test/tap/tests/pg_lite_client.cpp test/tap/tests/pgsql-auth_method_matrix-t.cpp +git commit -m "test(pgsql): pg_lite_client MD5 frontend auth + matrix md5 case" +``` + +--- + +## Task 3: SCRAM-SHA-256 frontend auth (pg_lite_client + matrix case + failure paths) + +Extend `pg_lite_client` to complete a SASL/SCRAM-SHA-256 exchange using the vendored `deps/libscram` client functions, then add the scram success case and wrong-password failure cases for all three methods. + +**Files:** +- Modify: `test/tap/tests/pg_lite_client.cpp` (SASL branch + `sendSASLInitial`/`sendSASLResponse` helpers) +- Modify: `test/tap/tests/pg_lite_client.h` (declare helpers) +- Modify: `test/tap/tests/pgsql-auth_method_matrix-t.cpp` (scram + wrong-password cases) + +**Interfaces:** +- Consumes: `deps/libscram/include/scram.h` — `scram_state_init`, `build_client_first_message`, `read_server_first_message`, `build_client_final_message`, `read_server_final_message`, `verify_server_signature`, `free_scram_state`, `PgCredentials`, `ScramState`. Linked via the `-lscram -lusual` already on this test's Makefile rule (Task 1). +- Produces: SASL handling inside `handleAuthentication`. + +- [ ] **Step 1: Write the failing test (add scram + wrong-password cases)** + +In `pgsql-auth_method_matrix-t.cpp`, bump `plan(4)` → `plan(9)` and add: + +```cpp + // --- SCRAM floor (method = 3) -> expect challenge type 10 on the wire --- + set_frontend_auth_method(admin, 3); + int scram_auth = 0; + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password, scram_auth), + "scram floor: login + query succeed"); + ok(scram_auth == 10, "scram floor: ProxySQL presented SASL/SCRAM challenge type 10 (got %d)", scram_auth); + + // --- Wrong-password failure paths, one per floor (challenge type irrelevant) --- + int ignore = 0; + set_frontend_auth_method(admin, 1); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "cleartext floor: wrong password rejected"); + set_frontend_auth_method(admin, 2); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "md5 floor: wrong password rejected"); + set_frontend_auth_method(admin, 3); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "scram floor: wrong password rejected"); +``` + +- [ ] **Step 2: Rebuild + run — expect FAIL** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-auth_method_matrix-t" test/infra/control/run-tests-isolated.bash +``` +Expected: the scram success assertion FAILS (`Unsupported authentication method: 10`). The wrong-password md5/cleartext assertions should already pass; scram wrong-password will also fail to run until SASL is implemented. + +- [ ] **Step 3: Declare SASL helpers** + +In `test/tap/tests/pg_lite_client.h` `private:` section add: + +```cpp + void doSASLAuth(const std::string& password, const std::vector& mechListMsg); +``` + +- [ ] **Step 4: Implement the SASL exchange using libscram** + +In `test/tap/tests/pg_lite_client.cpp` add near the top: + +```cpp +extern "C" { +#include "scram.h" +} +``` + +Add the helper (near `sendPassword`): + +```cpp +// Completes a SCRAM-SHA-256 SASL exchange as the CLIENT, reusing deps/libscram. +// mechListMsg is the AuthenticationSASL(10) payload after the 4-byte authType: +// a sequence of null-terminated mechanism names terminated by an extra null. +void PgConnection::doSASLAuth(const std::string& password, + const std::vector& /*mechListMsg*/) { + ScramState* st = scram_state_init(); + PgCredentials cred; + memset(&cred, 0, sizeof(cred)); + snprintf(cred.name, sizeof(cred.name), "%s", user_.c_str()); + snprintf(cred.passwd, sizeof(cred.passwd), "%s", password.c_str()); + cred.has_scram_keys = false; + + // 1) SASLInitialResponse ('p'): mechanism name + Int32 length + client-first-message + char* client_first = build_client_first_message(st); // e.g. "n,,n=,r=" + if (!client_first) { free_scram_state(st); throw PgException(std::string("scram client-first: ") + scram_error()); } + { + std::vector pkt; + const char* mech = "SCRAM-SHA-256"; + writeStringToBuffer(pkt, mech); // null-terminated mechanism + int32_t clen = htonl((int32_t)strlen(client_first)); + const uint8_t* cp = reinterpret_cast(&clen); + pkt.insert(pkt.end(), cp, cp + 4); // Int32 length + pkt.insert(pkt.end(), client_first, client_first + strlen(client_first)); + sendMessage('p', pkt); + } + + // 2) Expect AuthenticationSASLContinue (authType 11) with server-first-message + char type; std::vector buffer; + readMessage(type, buffer); + if (type != AUTH_TYPE || buffer.size() < 4 || + ntohl(*reinterpret_cast(buffer.data())) != 11) { + free(client_first); free_scram_state(st); + throw PgException("expected AuthenticationSASLContinue(11)"); + } + std::string server_first(reinterpret_cast(buffer.data()) + 4, buffer.size() - 4); + char* server_nonce = nullptr; char* salt = nullptr; int saltlen = 0; int iterations = 0; + if (!read_server_first_message(st, const_cast(server_first.c_str()), + &server_nonce, &salt, &saltlen, &iterations)) { + free(client_first); free_scram_state(st); + throw PgException(std::string("scram read server-first: ") + scram_error()); + } + + // 3) SASLResponse ('p'): client-final-message (with proof) + char* client_final = build_client_final_message(st, &cred, server_nonce, salt, saltlen, iterations); + if (!client_final) { free(client_first); free_scram_state(st); throw PgException(std::string("scram client-final: ") + scram_error()); } + { + std::vector pkt(client_final, client_final + strlen(client_final)); + sendMessage('p', pkt); + } + + // 4) Expect AuthenticationSASLFinal (authType 12) with server-final (v=ServerSignature) + readMessage(type, buffer); + if (type != AUTH_TYPE || buffer.size() < 4 || + ntohl(*reinterpret_cast(buffer.data())) != 12) { + free(client_first); free(client_final); free_scram_state(st); + throw PgException("expected AuthenticationSASLFinal(12)"); + } + { + std::string server_final(reinterpret_cast(buffer.data()) + 4, buffer.size() - 4); + char server_sig[256] = {0}; + if (!read_server_final_message(const_cast(server_final.c_str()), server_sig) || + !verify_server_signature(st, &cred, server_sig)) { + free(client_first); free(client_final); free_scram_state(st); + throw PgException("scram server signature verification failed"); + } + } + free(client_first); free(client_final); free_scram_state(st); + + // 5) Expect AuthenticationOk (0) + readMessage(type, buffer); + if (type == AUTH_TYPE && ntohl(*reinterpret_cast(buffer.data())) == 0) return; + throw PgException("scram: no AuthenticationOk after SASLFinal"); +} +``` + +In `handleAuthentication`, add the branch: + +```cpp + else if (authType == 10) { // AuthenticationSASL (mechanism list follows) + doSASLAuth(password, buffer); + return; // doSASLAuth consumes through AuthenticationOk + } +``` + +- [ ] **Step 5: Rebuild + run — observe, adjust framing if needed, expect PASS** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-auth_method_matrix-t" test/infra/control/run-tests-isolated.bash +``` +Expected: 9/9 pass. If the scram case fails on `read_server_first_message` or signature verify, `diag()` the raw `client_first`/`server_first` strings and confirm two libscram specifics: (a) whether `build_client_first_message` already includes the `n,,` GS2 header (if not, prepend it before sending); (b) that `PgCredentials.passwd` (plaintext) is the field `build_client_final_message` derives `SaltedPassword` from. Adjust and re-run. + +- [ ] **Step 6: Commit** + +```bash +git add test/tap/tests/pg_lite_client.h test/tap/tests/pg_lite_client.cpp test/tap/tests/pgsql-auth_method_matrix-t.cpp +git commit -m "test(pgsql): pg_lite_client SCRAM-SHA-256 frontend auth + matrix scram/failure cases" +``` + +- [ ] **Step 7 (OPTIONAL — only after #5865 is merged): wire-level no-downgrade assertion for a SCRAM-verifier user** + +This is the one storage-type case worth adding at the wire level, because it proves #5865's assertion 5 (*"SCRAM verifier NOT downgraded under cleartext floor"*) in a way #5865's libpq test cannot — by observing the challenge byte. Skip entirely until #5865 lands (it provides the verifier-user creation path). + +Add near the top of `main`, before the failure block, and bump `plan(9)` → `plan(11)`: + +```cpp + // Requires #5865: create a user whose stored secret is a SCRAM verifier. + // Reuse #5865's runtime pattern: PQencryptPasswordConn(conn, "verifier_pw", "scram_user", "scram-sha-256") + // then INSERT INTO pgsql_users(username,password,...) VALUES('scram_user', , ...); LOAD PGSQL USERS TO RUNTIME. + // (Factor #5865's helper out or copy its ~5-line create_verifier_user() here.) + create_verifier_user(admin, "scram_user", "verifier_pw"); // from #5865 + + set_frontend_auth_method(admin, 1); // cleartext FLOOR... + int vt = 0; + bool v_ok = try_frontend_login("scram_user", "verifier_pw", vt); + ok(v_ok, "verifier user authenticates under cleartext floor"); + ok(vt == 10, "no-downgrade: verifier user still challenged with SCRAM(10) under cleartext floor (got %d)", vt); +``` + +If #5865 is not yet merged when SP-1 is implemented, leave this step unchecked and keep `plan(9)`; add it in a follow-up once #5865 lands. Re-run and commit as `test(pgsql): wire-level no-downgrade assertion for SCRAM verifier (depends on #5865)`. + +--- + +## Task 4: Data-type / binary-encoding matrix + +Systematic per-type round-trips through `pg_lite_client`, each type asserted in **both** text and binary result format, checking the returned value, the column type OID, and the format code. + +**Files:** +- Create: `test/tap/tests/pgsql-datatype_matrix-t.cpp` +- Modify: `test/tap/tests/Makefile` (explicit rule with `pg_lite_client.cpp`, no scram needed) +- Modify: `test/tap/groups/groups.json` + +**Interfaces:** +- Consumes: `PgConnection::prepareStatement/bindStatementSingleFormat/executePortal` OR `executeParams(stmtName, query, params, resultFormats)`; `PgResult` accessors (`getValue`, `columnFormat`, `isNull`). Result rows come back via `readResult()` — confirm in Step 2 whether `executeParams` populates a `PgResult`; if `readResult()` is unimplemented for a path, use `readMessage`/`BufferReader` to parse `RowDescription`(T)/`DataRow`(D) directly (the header exposes both). +- Produces: nothing consumed downstream. + +- [ ] **Step 1: Write the failing test** + +Create `test/tap/tests/pgsql-datatype_matrix-t.cpp`: + +```cpp +#include +#include +#include "pg_lite_client.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; + +struct Case { + const char* label; + const char* select_expr; // e.g. "SELECT '\\xdeadbeef'::bytea" + const char* expected_text; // expected value in TEXT format + int32_t expected_oid; // PostgreSQL type OID + const char* expected_binary_hex; // exact wire bytes in BINARY format +}; + +// Everything observed for one round-trip of a case at a given result format. +struct Observed { + bool got_result = false; // RowDescription+DataRow returned (no ErrorResponse) + int32_t oid = 0; // type OID from RowDescription + int16_t col_format = -1; // per-column result format code (0=text, 1=binary) + bool is_null = true; // DataRow value length == -1 ? + std::string text_value; // value decoded as text (meaningful when col_format==0) + std::vector raw_bytes; // raw DataRow payload for column 0 (both formats) +}; + +// Lowercase hex of a raw payload, for comparison and diagnostics. +static std::string to_hex(const std::vector& bytes); + +// One representative literal per type; expand freely -- adding a row is the unit +// of work. expected_binary_hex is the exact payload PostgreSQL's *_send() emits, +// so the binary assertion compares bytes rather than merely checking the OID. +// +// Excerpt only -- the shipped test carries the full table (bool, int4, int8, +// float8, numeric, text_utf8, bytea, uuid, timestamptz, jsonb, int4_array, inet) +// and is the source of truth for the expected encodings: +// test/tap/tests/pgsql-datatype_matrix-t.cpp +static const std::vector cases = { + { "int4", "SELECT 2147483647::int4", "2147483647", 23, + "7fffffff" }, + // A genuine timestamptz (OID 1184). Applying AT TIME ZONE 'UTC' would yield + // timestamp *without* time zone (OID 1114) and never exercise timestamptz at + // all; instead the session pins TimeZone=UTC so the text form is deterministic. + { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz", + "2020-01-01 00:00:00+00", 1184, + "00023e0786c26000" }, + { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17, + "deadbeef" }, +}; + +// Runs one case through pg_lite_client at the given result format (0=text,1=binary), +// capturing the RowDescription OID + per-column format code and the raw DataRow bytes. +static bool run_case(const Case& c, int16_t fmt, Observed& obs); + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + + // For each case: 1 text assertion (value+oid) + 1 binary assertion + // (oid + format code + exact payload bytes). + plan((int)cases.size() * 2); + + for (const auto& c : cases) { + Observed t; + bool ok_text = run_case(c, 0, t); + ok(ok_text && t.col_format == 0 && t.oid == c.expected_oid && t.text_value == c.expected_text, + "%s text: oid=%d value='%s'", c.label, t.oid, t.text_value.c_str()); + + // The binary half must compare the DataRow payload byte-for-byte against + // the expected encoding. Asserting only the OID is NOT enough: a silent + // text fallback, a truncated payload, or a byte-order regression all keep + // the OID intact and would pass. Each Case therefore carries the exact + // bytes PostgreSQL's *_send() emits for its literal. + Observed b; + bool ok_bin = run_case(c, 1, b); + ok(ok_bin && b.col_format == 1 && b.oid == c.expected_oid + && !b.is_null && to_hex(b.raw_bytes) == c.expected_binary_hex, + "%s binary: oid=%d payload=%s (expected %s)", + c.label, b.oid, to_hex(b.raw_bytes).c_str(), c.expected_binary_hex); + } + return exit_status(); +} +``` + +- [ ] **Step 2: Implement `run_case` using the raw message path** + +Append to the file (parses `RowDescription`/`DataRow` directly, which the header's `BufferReader` + message constants support): + +```cpp +static bool run_case(const Case& c, int16_t fmt, Observed& obs) { + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + + // Pin the session time zone so the TEXT rendering of timestamptz is + // deterministic regardless of the backend's configured TimeZone. + conn.execute("SET TIME ZONE 'UTC'"); + conn.consumeInputUntilReady(); + + // Extended protocol: unnamed prepared statement, single result format = fmt. + // These queries take NO bind parameters, so the param-format array must be + // empty: bindStatementSingleFormat() would send a 1-element param-format + // array for a 0-param Bind, tripping issue #5899. bindStatementEx() with an + // explicit empty paramFormats array is the protocol-correct 0-param bind. + conn.prepareStatement("", c.select_expr, false, {}); + conn.bindStatementEx("", "", {}, {}, { fmt }, false); + conn.describePortal("", false); + conn.executePortal("", 0, true); // sync + + // Read: ParseComplete(1), BindComplete(2), RowDescription(T), DataRow(D), CommandComplete(C), ReadyForQuery(Z) + char type; std::vector buf; + while (true) { + conn.readMessage(type, buf); + if (type == PgConnection::ROW_DESCRIPTION) { + BufferReader r(buf); + int16_t nfields = r.readInt16(); + if (nfields >= 1) { + r.readString(); // field name + r.readInt32(); // table oid + r.readInt16(); // column attr + obs.oid = r.readInt32(); // type oid + r.readInt16(); // type size + r.readInt32(); // type modifier + obs.col_format = r.readInt16(); // per-column result FORMAT CODE + } + } else if (type == PgConnection::DATA_ROW) { + BufferReader r(buf); + int16_t ncols = r.readInt16(); + if (ncols >= 1) { + int32_t len = r.readInt32(); + if (len >= 0) { + obs.raw_bytes = r.readBytes(len); + obs.is_null = false; + // Decoded as text for the text-format assertion; in binary + // format the payload is opaque and text_value is unused. + obs.text_value.assign(obs.raw_bytes.begin(), obs.raw_bytes.end()); + obs.got_result = true; + } else { + obs.is_null = true; + obs.got_result = true; + } + } + } else if (type == PgConnection::READY_FOR_QUERY) { + break; + } else if (type == PgConnection::ERROR_RESPONSE) { + conn.disconnect(); + return false; + } + } + conn.disconnect(); + return obs.got_result; + } catch (const PgException& e) { + diag("%s fmt=%d threw: %s", c.label, (int)fmt, e.what()); + return false; + } +} +``` + +- [ ] **Step 3: Add Makefile rule** + +```make +pgsql-datatype_matrix-t: pgsql-datatype_matrix-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ +``` + +- [ ] **Step 4: Register in groups.json** + +```json + "pgsql-datatype_matrix-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], +``` + +- [ ] **Step 5: Build + run** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-datatype_matrix-t" test/infra/control/run-tests-isolated.bash +``` +Expected: all `cases.size()*2` pass. Any OID/value mismatch is a real finding — `diag` shows the observed value; record genuine transparency divergences per spec §2.1 rather than loosening the assertion. + +- [ ] **Step 6: Commit** + +```bash +git add test/tap/tests/pgsql-datatype_matrix-t.cpp test/tap/tests/Makefile test/tap/groups/groups.json +git commit -m "test(pgsql): data-type/binary-encoding matrix (text+binary, oid+value)" +``` + +--- + +## Task 5: Server-side cursors + portal suspension + +DECLARE/FETCH/MOVE/CLOSE over libpq (simple protocol) plus extended-protocol portal suspension (`Execute` with a max-row count → `PortalSuspended`) via `pg_lite_client`. + +**Files:** +- Create: `test/tap/tests/pgsql-server_side_cursors-t.cpp` +- Modify: `test/tap/tests/Makefile` (explicit rule, `pg_lite_client.cpp`) +- Modify: `test/tap/groups/groups.json` + +**Interfaces:** +- Consumes: libpq `PQexec`/`PQntuples`; `PgConnection::executePortal(portalName, maxRows, send_sync)`, message constant `PORTAL_SUSPENDED` (`'s'`). +- Produces: nothing downstream. + +- [ ] **Step 1: Write the failing test** + +Create `test/tap/tests/pgsql-server_side_cursors-t.cpp`: + +```cpp +#include +#include +#include "libpq-fe.h" +#include "pg_lite_client.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +static PGConnPtr backend_conn() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + PGconn* c = PQconnectdb(ss.str().c_str()); + return PGConnPtr(c, &PQfinish); +} + +// Extended-protocol portal suspension: Execute with maxRows=2 over a 5-row result. +static bool portal_suspends_at_2() { + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + conn.prepareStatement("", "SELECT g FROM generate_series(1,5) g", false, {}); + conn.bindStatementSingleFormat("", "", {}, 0, { 0 }, false); + conn.executePortal("", 2, true); // maxRows=2 -> expect 2 DataRows then PortalSuspended + char type; std::vector buf; int rows = 0; bool suspended = false; + while (true) { + conn.readMessage(type, buf); + if (type == PgConnection::DATA_ROW) rows++; + else if (type == PgConnection::PORTAL_SUSPENDED) { suspended = true; } + else if (type == PgConnection::READY_FOR_QUERY) break; + else if (type == PgConnection::ERROR_RESPONSE) { conn.disconnect(); return false; } + } + conn.disconnect(); + return rows == 2 && suspended; + } catch (const PgException& e) { diag("portal test threw: %s", e.what()); return false; } +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(4); + + PGConnPtr c = backend_conn(); + ok(c && PQstatus(c.get()) == CONNECTION_OK, "connected for cursor test"); + + // DECLARE / FETCH / MOVE / CLOSE inside a transaction (cursors require a txn). + PQexec(c.get(), "BEGIN"); + PQexec(c.get(), "DECLARE cur CURSOR FOR SELECT g FROM generate_series(1,10) g"); + PGresult* r = PQexec(c.get(), "FETCH 3 cur"); + ok(PQntuples(r) == 3, "FETCH 3 returns 3 rows"); + PQclear(r); + r = PQexec(c.get(), "MOVE 2 cur"); // skip 2 + PQclear(r); + r = PQexec(c.get(), "FETCH 10 cur"); // remaining 5 + ok(PQntuples(r) == 5, "MOVE 2 then FETCH returns remaining 5 rows"); + PQclear(r); + PQexec(c.get(), "CLOSE cur"); + PQexec(c.get(), "COMMIT"); + + ok(portal_suspends_at_2(), "extended-protocol portal suspends at maxRows=2"); + + return exit_status(); +} +``` + +- [ ] **Step 2: Add Makefile rule + groups.json entry** + +```make +pgsql-server_side_cursors-t: pgsql-server_side_cursors-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ +``` +```json + "pgsql-server_side_cursors-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], +``` + +- [ ] **Step 3: Build + run — expect PASS** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-server_side_cursors-t" test/infra/control/run-tests-isolated.bash +``` +Expected: 4/4. If portal suspension does not occur (rows != 2 or no `'s'`), that is a genuine multiplexing/portal finding — record per §2.1. + +- [ ] **Step 4: Commit** + +```bash +git add test/tap/tests/pgsql-server_side_cursors-t.cpp test/tap/tests/Makefile test/tap/groups/groups.json +git commit -m "test(pgsql): server-side cursors (DECLARE/FETCH/MOVE) + portal suspension" +``` + +--- + +## Task 6: Pool churn + session-state isolation + +A connection storm exceeding `pgsql-max_connections`, plus the classic pooler-correctness check: session state set on one frontend connection must not leak to another via a reused backend. + +**Files:** +- Create: `test/tap/tests/pgsql-pool_churn-t.cpp` +- Modify: `test/tap/groups/groups.json` (no Makefile rule needed — pure libpq) + +**Interfaces:** +- Consumes: libpq; admin (MySQL client) to read pool counters / set `pgsql-max_connections` if needed. +- Produces: nothing downstream. + +- [ ] **Step 1: Write the failing test** + +Create `test/tap/tests/pgsql-pool_churn-t.cpp`: + +```cpp +#include +#include +#include +#include +#include "libpq-fe.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +static PGConnPtr mk() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + return PGConnPtr(PQconnectdb(ss.str().c_str()), &PQfinish); +} + +static std::string scalar(PGconn* c, const char* q) { + PGresult* r = PQexec(c, q); + std::string v = (PQresultStatus(r) == PGRES_TUPLES_OK && PQntuples(r) > 0) ? PQgetvalue(r, 0, 0) : ""; + PQclear(r); + return v; +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(3); + + // 1) Connection storm: open many short-lived connections; none should error out. + bool all_ok = true; + for (int i = 0; i < 100; ++i) { + PGConnPtr c = mk(); + if (!c || PQstatus(c.get()) != CONNECTION_OK) { all_ok = false; break; } + if (scalar(c.get(), "SELECT 1") != "1") { all_ok = false; break; } + } + ok(all_ok, "100 sequential short connections all succeed (no pool leak/exhaustion)"); + + // 2) Session-state isolation across multiplexed reuse. + // Connection A sets a session GUC; a fresh connection B must NOT observe it. + { + PGConnPtr a = mk(); + PQexec(a.get(), "SET application_name = 'churn_A'"); + std::string a_val = scalar(a.get(), "SHOW application_name"); + ok(a_val == "churn_A", "connection A sees its own SET application_name"); + + PGConnPtr b = mk(); + std::string b_val = scalar(b.get(), "SHOW application_name"); + ok(b_val != "churn_A", "connection B does NOT inherit A's session state (got '%s')", b_val.c_str()); + } + + return exit_status(); +} +``` + +- [ ] **Step 2: Register in groups.json (no Makefile change — pure libpq)** + +```json + "pgsql-pool_churn-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], +``` + +- [ ] **Step 3: Build + run — expect PASS** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-pool_churn-t" test/infra/control/run-tests-isolated.bash +``` +Expected: 3/3. A state-leak (B inherits A) is a real correctness bug — do NOT relax the assertion; capture it. Note: under `multiplexing=false` group variants, reuse behaves differently — the isolation assertion must hold in all variants. + +- [ ] **Step 4: Commit** + +```bash +git add test/tap/tests/pgsql-pool_churn-t.cpp test/tap/groups/groups.json +git commit -m "test(pgsql): pool churn + session-state isolation across reuse" +``` + +--- + +## Task 7: LISTEN/NOTIFY per-mode contract + +Pins the current contract: LISTEN is rejected with SQLSTATE `0A000` on the libpq backend path (both simple and extended protocol); NOTIFY-as-a-query completes cleanly and leaves the connection usable. The native-path portion is asserted xfail-tolerant per spec §2.2/§3.5. + +**Files:** +- Create: `test/tap/tests/pgsql-listen_notify_contract-t.cpp` +- Modify: `test/tap/groups/groups.json` (pure libpq — no Makefile rule) + +**Interfaces:** +- Consumes: libpq `PQexec`/`PQresultStatus`/`PQresultErrorField(PG_DIAG_SQLSTATE)`; admin (MySQL client) to toggle `pgsql-use_native_backend_protocol`. +- Produces: nothing downstream. + +- [ ] **Step 1: Write the failing test** + +Create `test/tap/tests/pgsql-listen_notify_contract-t.cpp`: + +```cpp +#include +#include +#include +#include "libpq-fe.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +static PGConnPtr mk() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + return PGConnPtr(PQconnectdb(ss.str().c_str()), &PQfinish); +} + +static std::string sqlstate_of(PGresult* r) { + const char* s = PQresultErrorField(r, PG_DIAG_SQLSTATE); + return s ? s : ""; +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(4); + + PGConnPtr c = mk(); + ok(c && PQstatus(c.get()) == CONNECTION_OK, "connected for listen/notify contract"); + + // LISTEN over simple protocol -> 0A000 feature_not_supported (libpq path). + PGresult* r = PQexec(c.get(), "LISTEN chan1"); + ok(PQresultStatus(r) == PGRES_FATAL_ERROR && sqlstate_of(r) == "0A000", + "simple LISTEN rejected with 0A000 (got status=%d sqlstate=%s)", + PQresultStatus(r), sqlstate_of(r).c_str()); + PQclear(r); + + // LISTEN over extended protocol (PQexecParams uses Parse/Bind/Execute) -> same 0A000. + r = PQexecParams(c.get(), "LISTEN chan2", 0, NULL, NULL, NULL, NULL, 0); + ok(PQresultStatus(r) == PGRES_FATAL_ERROR && sqlstate_of(r) == "0A000", + "extended LISTEN rejected with 0A000 (got sqlstate=%s)", sqlstate_of(r).c_str()); + PQclear(r); + + // NOTIFY as a plain query completes cleanly and the connection stays usable. + PGConnPtr c2 = mk(); + PGresult* rn = PQexec(c2.get(), "NOTIFY chan1, 'hello'"); + bool notify_ok = (PQresultStatus(rn) == PGRES_COMMAND_OK); + PQclear(rn); + PGresult* rq = PQexec(c2.get(), "SELECT 1"); + bool still_usable = (PQresultStatus(rq) == PGRES_TUPLES_OK); + PQclear(rq); + ok(notify_ok && still_usable, "NOTIFY completes cleanly and connection remains usable"); + + return exit_status(); +} +``` + +- [ ] **Step 2: Register in groups.json** + +```json + "pgsql-listen_notify_contract-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], +``` + +- [ ] **Step 3: Build + run — expect PASS on the libpq path** + +```bash +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g4 \ + TEST_PY_TAP_INCL="pgsql-listen_notify_contract-t" test/infra/control/run-tests-isolated.bash +``` +Expected: 4/4 with the default (libpq) backend path. Do not add native-path (`pgsql-use_native_backend_protocol=on`) assertions here yet — per §3.5 the native NOTIFY contract is owned by PR #5882's `pgsql-native_notify-t`; a native-path branch is added only once that lands, marked xfail where incomplete. + +- [ ] **Step 4: Commit** + +```bash +git add test/tap/tests/pgsql-listen_notify_contract-t.cpp test/tap/groups/groups.json +git commit -m "test(pgsql): LISTEN 0A000 rejection + NOTIFY-as-query contract (libpq path)" +``` + +--- + +## Self-Review + +**Spec coverage (SP-1 items → tasks):** +- Auth-method matrix → Tasks 1–3 ✓, **repositioned around PR #5865**: #5865 owns the storage-type × floor success/fail matrix + anti-enumeration + malformed-verifier (via libpq); this plan owns the **wire-level challenge-type** assertion + post-auth query (which libpq can't see), plus the reusable `pg_lite_client` MD5/SCRAM enabler. Optional no-downgrade wire assertion (Task 3 Step 7) is gated on #5865 merging. +- Data-type/binary matrix → Task 4 ✓ (text+binary, OID+value). +- Server-side cursors → Task 5 ✓ (DECLARE/FETCH/MOVE + portal suspend). +- Pool churn / session-isolation → Task 6 ✓. +- LISTEN/NOTIFY per-mode contract → Task 7 ✓ (libpq path pinned; native deferred to #5882 per §3.5). +- Backend-mode axis (§2.2): honored by keeping SP-1 on the default libpq path and explicitly deferring native-path assertions; cert-only frontend auth is left to the existing `pgsql-reg_test_5284_frontend_ssl_enforcement-t` (not duplicated here). + +**Dependency / merge order:** Auth tasks (1–3) coordinate with **PR #5865** (open, base `v3.0`) — see the "Relationship to PR #5865" section. Develop on top of / rebase onto it to avoid `groups.json` conflicts; the enabler and wire-level test do not conflict semantically, only textually in `groups.json`. + +**Placeholder scan:** No TBD/TODO. Three honest verification points are embedded as concrete observe-and-adjust steps (Task 3 Step 5 libscram framing; Task 3 Step 7 optional #5865-gated extension; Task 4 Step 2 `readResult` vs raw-parse) — each names the exact thing to check and the fallback, which is guidance, not a placeholder. + +**Type consistency:** Helper names are stable across tasks (`set_frontend_auth_method`, `try_frontend_login(user, password, int&)`, `run_case`, `mk`, `scalar`, `sqlstate_of`). `pg_lite_client` additions (`getLastAuthType`, `sendMD5Password`, `doSASLAuth`) are declared in the header before use. Message constants (`ROW_DESCRIPTION`, `DATA_ROW`, `PORTAL_SUSPENDED`, `READY_FOR_QUERY`, `ERROR_RESPONSE`, `AUTH_TYPE`) match `pg_lite_client.h`. + +**Open dependency for the implementer:** the auth-matrix test relies on `pgsql_users` containing `testuser` with password `testuser` and a matching database — already true in `docker-pgsql16-single`'s `config.sql` + `docker-pgsql-post.bash`. No infra change is required for SP-1 (frontend auth is a server variable, not a per-user backend setting). diff --git a/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md new file mode 100644 index 0000000000..34255736d9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md @@ -0,0 +1,1041 @@ +# PostgreSQL SP-2 — Polyglot Test-Harness Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up a reusable, driver-driven PostgreSQL test foundation for ProxySQL — a Toxiproxy-fronted primary+2-replica backend, a pytest harness that reconfigures ProxySQL as a test primitive, a 6-target **differential engine** (proxy-vs-direct, both backend-protocol modes, text+binary) and a **`pg_stat_statements` routing oracle** — proven end-to-end with a Python reference driver (psycopg3 + asyncpg), and wired into CI as a nightly + label-gated job. + +**Architecture:** A new backend infra (`infra-dbdeployer-pgsql17-repl`, primary + 2 replicas) exposes each backend through a **Toxiproxy** endpoint; ProxySQL's `pgsql_servers` point at the Toxiproxy ports and use the **automatic** `pgsql_replication_hostgroups` monitor path for read/write split. A new pytest suite under `test/pg-compat/` runs **inside a container joined to the `${INFRA_ID}_backend` docker network** (ProxySQL exposes no host ports), addressing services by DNS. All service endpoints are injected via environment variables so the harness is decoupled from the backend topology. The differential engine drops in SQL cases and asserts ProxySQL is byte-identical to a direct backend; the routing oracle reads `pg_stat_statements` on each backend to prove where queries landed. + +**Tech Stack:** Python 3.11, pytest, psycopg3, asyncpg, Toxiproxy (+ `toxiproxy-python`), PostgreSQL 17, ProxySQL PG monitor (`pg_is_in_recovery()`), dbdeployer (ProxySQL fork v2.2.1) *or* native `postgres:17` Docker fallback, Docker Compose on the external `${INFRA_ID}_backend` network, GitHub Actions (two-branch caller/reusable). + +## Global Constraints + +- **Discovery-phase framing (spec §2.1):** first runs are expected to surface failures. The suite is **non-gating**; its deliverable is a **failure inventory**. Every known divergence is an **xfail entry** in `test/pg-compat/xfail.toml` with `reason` + `mode` + tracking ref; an xfail that starts passing (**xpass**) is reported. Do NOT loosen an assertion to make a real divergence green. +- **Backend-protocol axis (spec §2.2):** the proxy targets run under both `pgsql-use_native_backend_protocol` = `off` (libpq, default) and `on` (native, PR #5882). Native-path cases that are incomplete are marked xfail, not skipped. +- **No host ports.** ProxySQL and backends publish nothing to the host. The harness runs in a container on `${INFRA_ID}_backend` and reaches: ProxySQL PG frontend `proxysql:6133`, ProxySQL admin over PG protocol `proxysql:6132`, backends + Toxiproxy by their DNS aliases. All endpoints come from env vars (never hardcode). +- **Debug proxysql build** required for the isolated harness admin commands (per `CLAUDE.md`); build `PROXYSQL31=1 make debug` and re-run `start-proxysql-isolated.bash` after rebuilds. +- **Never manually manage Docker** — bring infra up via `test/infra/control/ensure-infras.bash`. +- **Automatic RW-split config** (from recon): put ALL servers (incl. primary) in the writer hostgroup, add a `pgsql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type, comment)` row with `check_type='read_only'`, and enable the monitor (`pgsql-monitor_enabled=true`, `monitor_username`/`monitor_password` matching a real PG role). The monitor runs `SELECT pg_is_in_recovery()` and moves replicas to the reader hostgroup. +- **`LOAD PGSQL VARIABLES/SERVERS/USERS/QUERY RULES TO RUNTIME`** is required after each admin config change; `pgsql_replication_hostgroups` loads with `LOAD PGSQL SERVERS TO RUNTIME`. + +--- + +## Greenfield risk register (read before starting) + +| Piece | Status | Mitigation in this plan | +|---|---|---| +| dbdeployer PostgreSQL topology | **Confirmed supported** by the maintainer (2026-07-08); exact PG deploy flags still to capture | **Task 1 captures the exact command** (no go/no-go gate); commit to the dbdeployer path (Task 2a). Native `postgres:17` (Task 2b) remains only an emergency fallback. | +| Toxiproxy | Greenfield (0 refs in repo) | Task 3 adds it as a sidecar container + a bootstrap script; harness reads its host via env. | +| `pg_stat_statements` | Greenfield | Task 2 bakes `shared_preload_libraries` into `postgresql.conf` + `CREATE EXTENSION` in post-provision. | +| Host-published ports | Greenfield (none exist) | Harness runs **in-container** on the infra network (Task 5); no host ports needed. | +| Nightly + label CI | Greenfield (no `schedule`/label workflow) | Task 10 uses the standard idiom + inline proxysql build (like `CI-3p-*`). | +| Auto `pgsql_replication_hostgroups` | Exists, unused by infra | Task 4 enables it (exact SQL below). | +| pytest (non-TAP) suite in CI | Partial precedent (`test/scripts/mysqlx/*.py`, `mysqlx-soak`) | Task 5/10 model the runner on it. | + +--- + +## File Structure + +**New infra (Task 2 — one of these two, per Task 1's decision):** +- `test/infra/infra-dbdeployer-pgsql17-repl/` — dbdeployer path: `docker/{Dockerfile,build.sh,entrypoint.sh}`, `bin/docker-pgsql-post.bash`, `bin/docker-proxy-post.bash`, `docker-compose.yml`, `docker-compose-init.bash`, `docker-compose-destroy.bash`, `.env`, `conf/proxysql/infra-config.sql`. +- *(fallback)* extend the native `test/infra/infra-pgsql17-repl/` pattern to a `-3node` variant. + +**New harness (Tasks 5–9) under `test/pg-compat/`:** +- `conftest.py` — fixtures: proxysql admin (config primitive), the 6 differential targets, direct-backend + toxiproxy clients, per-test config snapshot/restore. +- `harness/proxysql.py` — admin-driven config mutation + `LOAD ... TO RUNTIME`; snapshot/restore. +- `harness/targets.py` — connection factories for the 6 differential targets. +- `harness/diff.py` — differential comparison engine. +- `harness/oracle.py` — `pg_stat_statements` routing oracle. +- `harness/toxi.py` — Toxiproxy wrapper (used by SP-4; a thin stub + wiring self-check here). +- `harness/xfail.py` — loads `xfail.toml`, applies xfail/xpass semantics. +- `drivers/python/adapter.py` — reference driver adapter (psycopg3 + asyncpg). +- `behaviors/` — driver-agnostic behavior modules: `connect.py`, `transactions.py`, `prepared.py`, `rw_split.py`, `session_isolation.py`. +- `cases/NNN_slug.sql` — drop-in differential cases. +- `xfail.toml` — the failure inventory. +- `requirements.txt`, `Dockerfile`, `run-pg-compat.bash`, `README.md`. + +**New CI (Task 10):** +- `.github/workflows/CI-pg-compat.yml` (caller, `v3.0`) + `gh-actions-reusable/ci-pg-compat.yml` (reusable, lands on `GH-Actions`). + +--- + +## Task 1: Capture the exact dbdeployer PostgreSQL deploy command + +**dbdeployer PG support is confirmed** by the maintainer (2026-07-08), so this is no longer a go/no-go gate — it only captures the **exact** flags/ports for a PG primary+2-replica sandbox against the pinned fork (v2.2.1), which Task 2a needs verbatim. Commit to the dbdeployer path (Task 2a); native `postgres:17` (Task 2b) is retained only as an emergency fallback if the pinned fork misbehaves in CI. + +**Files:** +- Create: `test/pg-compat/SPIKE-dbdeployer-pg.md` (findings + decision) + +- [ ] **Step 1: Check the pinned dbdeployer fork for PG support** + +```bash +docker run --rm --network=host ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq curl xz-utils >/dev/null + curl -fsSL "https://github.com/ProxySQL/dbdeployer/releases/download/v2.2.1/dbdeployer-2.2.1.linux_amd64.tar.gz" | tar -xz -C /usr/local/bin/ + chmod +x /usr/local/bin/dbdeployer* + ln -sf /usr/local/bin/dbdeployer-2.2.1.linux_amd64 /usr/local/bin/dbdeployer + echo "=== available flavors ==="; dbdeployer downloads list | grep -i -E "postgres|pg" || echo "NO postgres downloads" + echo "=== deploy help (flavor/type flags) ==="; dbdeployer deploy replication --help 2>&1 | grep -i -E "flavor|type|postgres" || echo "NO postgres deploy flags" +' +``` +Expected: either PG flavors/flags appear (dbdeployer path viable) or not (fallback). + +- [ ] **Step 2: If PG appears, prototype a 3-node PG replication sandbox** + +Inside the same container image, attempt (adjust flags per Step 1 output): +```bash +dbdeployer downloads get-unpack # or: dbdeployer unpack +dbdeployer deploy replication 17 --topology=... --nodes=3 --bind-address=0.0.0.0 --base-port=5432 +dbdeployer sandboxes # confirm 3 nodes, note the ports +``` +Record the exact working command and port scheme, or the error proving it's unsupported. + +- [ ] **Step 3: Record the exact command** + +Create `test/pg-compat/SPIKE-dbdeployer-pg.md` recording the working PG deploy command, the resulting node ports, and the sandbox dir name — Task 2a copies these verbatim. (Support is already confirmed; only the exact flags/ports are being captured.) If the pinned fork unexpectedly fails, note it and fall back to Task 2b. + +- [ ] **Step 4: Commit** + +```bash +git add test/pg-compat/SPIKE-dbdeployer-pg.md +git commit -m "spike(pg-compat): capture exact dbdeployer PostgreSQL deploy command + ports" +``` + +--- + +## Task 2: New primary + 2-replica infra (with pg_stat_statements) + +Build the backend infra chosen in Task 1. Both variants must end at the same contract: three PG 17 servers (1 primary + 2 streaming replicas) reachable on the `${INFRA_ID}_backend` network, each with `pg_stat_statements` preloaded and the extension created, plus a `monitor` login role and `testuser`/`postgres` app roles. Do the variant your spike selected. + +**Files (Task 2a — dbdeployer):** +- Create: `test/infra/infra-dbdeployer-pgsql17-repl/` (mirror `infra-dbdeployer-mysql84-gr/` layout). + +**Files (Task 2b — native fallback):** +- Create: `test/infra/infra-pgsql17-repl-3node/` (mirror `infra-pgsql17-repl/`, add a third `pgdb3` service). + +**Interfaces:** +- Produces (both variants): DNS aliases and ports consumed by later tasks, published as env vars in the infra's `env.sh` — a `_HOST`/`_PORT` **pair per node**: `PGCOMPAT_PRIMARY_HOST`/`PGCOMPAT_PRIMARY_PORT`, `PGCOMPAT_REPLICA1_HOST`/`PGCOMPAT_REPLICA1_PORT`, `PGCOMPAT_REPLICA2_HOST`/`PGCOMPAT_REPLICA2_PORT`. There is deliberately **no** single `PGCOMPAT_BACKEND_PORT`: the dbdeployer variant puts all three nodes in one container on three different ports, so a shared port var cannot address the replicas. (dbdeployer → one host, three ports; native → three hosts, each on 5432.) + +- [ ] **Step 1: Scaffold the infra directory** + +Copy the selected reference tree and rename. For **2b** (lower-risk fallback), start from the working PG infra: +```bash +cp -r test/infra/infra-pgsql17-repl test/infra/infra-pgsql17-repl-3node +``` +For **2a**, copy the dbdeployer reference: +```bash +cp -r test/infra/infra-dbdeployer-mysql84-gr test/infra/infra-dbdeployer-pgsql17-repl +``` + +- [ ] **Step 2: Add the third replica (fallback 2b) or set the PG deploy (2a)** + +**2b:** In `docker-compose.yml` add a `pgdb3` service cloned from `pgdb2` (new alias `pgsql3.${INFRA}`), add `conf/pgsql/pgsql3/{postgresql.conf,pg_hba.conf}` cloned from pgsql2, and give it its own replication slot (the primary's `init-replication.sh` already creates `replica_slot_2/3/4`, so assign `replica_slot_3` to pgdb3). Update `.env` (`WHG=00`, `RHG=01`). + +**2a:** In `docker/entrypoint.sh` replace the MySQL `dbdeployer deploy replication` invocation with the PG command recorded in the Task 1 spike (`--nodes=3`, PG flavor), and in `docker/Dockerfile` replace the `dbdeployer downloads get-unpack mysql-...` line with the PG tarball. Update `docker/build.sh`'s default tag to `proxysql/ci-infra:dbdeployer-pgsql17-repl`. + +- [ ] **Step 3: Enable pg_stat_statements in postgresql.conf (all nodes)** + +Add to each node's `postgresql.conf` (2b: `conf/pgsql/pgsql{1,2,3}/postgresql.conf`; 2a: the dbdeployer per-node conf template or a `-c` flag in entrypoint): +``` +shared_preload_libraries = 'pg_stat_statements' +pg_stat_statements.track = all +``` +(Requires the server to start with it preloaded — it is a bake-in, not a runtime SET.) + +- [ ] **Step 4: Create roles + extension in the post-provision script** + +In `bin/docker-pgsql-post.bash` (modeled on `docker-pgsql16-single/bin/docker-pgsql-post.bash`), after the existing user loop, add the `monitor` role and the extension on every database, and ensure `testuser`/`postgres`: +```bash +# monitor role for automatic replication_hostgroups (must match pgsql-monitor_username/password) +docker exec "${CONTAINER}" psql -X -Upostgres -c "SET client_min_messages='error';" \ + -c "DROP USER IF EXISTS monitor;" -c "CREATE USER monitor WITH PASSWORD 'monitor';" +# pg_stat_statements extension (routing oracle) — create in each app DB +for DB in postgres testuser; do + docker exec "${CONTAINER}" psql -X -Upostgres -d "$DB" -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;" +done +``` +For 2b, run the extension create on the **primary** only (it propagates via SQL, but pg_stat_statements is per-node/local — create it on each node's post script, or run against pgsql1/2/3 individually). Confirm `SELECT * FROM pg_stat_statements LIMIT 1;` works on each node. + +- [ ] **Step 5: Publish endpoints via env.sh** + +Create `test/tap/groups/pg-compat/env.sh` (group dir) exporting the endpoints and infra selection: +```bash +export INFRA_TYPE="infra-pgsql17-repl-3node" # or infra-dbdeployer-pgsql17-repl +export PGCOMPAT_PRIMARY_HOST="pgsql1.${INFRA_ID}" +export PGCOMPAT_PRIMARY_PORT="5432" +export PGCOMPAT_REPLICA1_HOST="pgsql2.${INFRA_ID}" +export PGCOMPAT_REPLICA1_PORT="5432" +export PGCOMPAT_REPLICA2_HOST="pgsql3.${INFRA_ID}" +export PGCOMPAT_REPLICA2_PORT="5432" +``` +Each node carries its own port so the same contract covers the dbdeployer +variant, where all three nodes share `dbdeployer1` and differ only by port +(16710/16711/16712). +and `test/tap/groups/pg-compat/infras.lst` with the single infra name (per the `infras.lst` mechanism in `ensure-infras.bash`). + +- [ ] **Step 6: Bring it up and verify replication + extension** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=pg-compat test/infra/control/ensure-infras.bash +# verify from a throwaway container on the network: +docker run --rm --network "dev-$USER_backend" postgres:17 bash -c ' + PGPASSWORD=testuser psql -h pgsql1.dev-'"$USER"' -U testuser -d testuser -c "SELECT pg_is_in_recovery();" # f (primary) + PGPASSWORD=testuser psql -h pgsql2.dev-'"$USER"' -U testuser -d testuser -c "SELECT pg_is_in_recovery();" # t (replica) + PGPASSWORD=testuser psql -h pgsql3.dev-'"$USER"' -U testuser -d testuser -c "SELECT pg_is_in_recovery();" # t (replica) +' +``` +Expected: primary `f`, both replicas `t`, and `pg_stat_statements` present on each. + +- [ ] **Step 7: Commit** + +```bash +git add test/infra/infra-pgsql17-repl-3node test/tap/groups/pg-compat # (or the dbdeployer dir) +git commit -m "infra(pg-compat): primary + 2-replica PG17 infra with pg_stat_statements" +``` + +--- + +## Task 3: Toxiproxy sidecar + backend endpoint indirection + +Insert Toxiproxy between ProxySQL and each PG backend so backends are individually degradable later (SP-4). ProxySQL will point at Toxiproxy; the differential engine's *direct* targets bypass Toxiproxy and hit PG directly. + +**Files:** +- Modify: the infra `docker-compose.yml` (add a `toxiproxy` service) +- Create: `/bin/toxiproxy-bootstrap.sh` (create one proxy per backend) +- Modify: `/bin/docker-proxy-post.bash` (invoke the bootstrap before applying ProxySQL config) +- Modify: `test/tap/groups/pg-compat/env.sh` (export toxiproxy endpoints) + +**Interfaces:** +- Produces: env vars `PGCOMPAT_TOXI_ADMIN` (`toxiproxy.${INFRA_ID}:8474`) and per-backend proxy listen addresses `PGCOMPAT_TOXI_PRIMARY` / `_REPLICA1` / `_REPLICA2` (e.g. `toxiproxy.${INFRA_ID}:6001/6002/6003`), consumed by Task 4's ProxySQL config and Task 6/7 self-checks. + +- [ ] **Step 1: Add the toxiproxy service to docker-compose.yml** + +```yaml + toxiproxy: + hostname: toxiproxy.${INFRA} + image: ghcr.io/shopify/toxiproxy:2.9.0 + container_name: ${COMPOSE_PROJECT}-toxiproxy-1 + command: ["-host", "0.0.0.0"] + networks: + backend: + aliases: + - toxiproxy.${INFRA} +``` + +- [ ] **Step 2: Write the bootstrap script** + +Create `/bin/toxiproxy-bootstrap.sh` — creates one passthrough proxy per backend via the Toxiproxy admin API (no toxics yet; SP-4 adds toxics): +```bash +#!/usr/bin/env bash +set -euo pipefail +TOXI="toxiproxy.${INFRA_ID}:8474" +mk() { # name listen_port upstream_host + curl -fsS -XPOST "http://${TOXI}/proxies" -d "{\"name\":\"$1\",\"listen\":\"0.0.0.0:$2\",\"upstream\":\"$3:5432\",\"enabled\":true}" +} +mk pg_primary 6001 "pgsql1.${INFRA_ID}" +mk pg_replica1 6002 "pgsql2.${INFRA_ID}" +mk pg_replica2 6003 "pgsql3.${INFRA_ID}" +``` +Run it from a container on the network (add its invocation to `docker-proxy-post.bash` before the ProxySQL config step, using a `docker run --rm --network ${INFRA_ID}_backend curlimages/curl ...` or by `docker exec` into the toxiproxy container). + +- [ ] **Step 3: Export toxiproxy endpoints in env.sh** + +Append to `test/tap/groups/pg-compat/env.sh`: +```bash +export PGCOMPAT_TOXI_ADMIN="toxiproxy.${INFRA_ID}:8474" +export PGCOMPAT_TOXI_PRIMARY="toxiproxy.${INFRA_ID}:6001" +export PGCOMPAT_TOXI_REPLICA1="toxiproxy.${INFRA_ID}:6002" +export PGCOMPAT_TOXI_REPLICA2="toxiproxy.${INFRA_ID}:6003" +``` + +- [ ] **Step 4: Bring up + verify a query flows through Toxiproxy** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=pg-compat test/infra/control/start-proxysql-isolated.bash +docker run --rm --network "dev-$USER_backend" postgres:17 \ + bash -c 'PGPASSWORD=testuser psql -h toxiproxy.dev-'"$USER"' -p 6001 -U testuser -d testuser -c "SELECT 1"' +``` +Expected: `1` — proving the primary is reachable through its Toxiproxy proxy. + +- [ ] **Step 5: Commit** + +```bash +git add test/infra/*/docker-compose.yml test/infra/*/bin/toxiproxy-bootstrap.sh test/infra/*/bin/docker-proxy-post.bash test/tap/groups/pg-compat/env.sh +git commit -m "infra(pg-compat): Toxiproxy sidecar with one proxy per backend" +``` + +--- + +## Task 4: ProxySQL config — automatic replication hostgroups via Toxiproxy + +Point ProxySQL at the Toxiproxy endpoints and enable the automatic monitor-driven read/write split. + +**Files:** +- Modify: `/conf/proxysql/infra-config.sql` + +- [ ] **Step 1: Write infra-config.sql (all servers in WHG, via Toxiproxy, auto-split)** + +Replace the static-split config with the automatic path. Note servers point at **Toxiproxy** host/ports (from Task 3), placed initially in the **writer** hostgroup; the monitor demotes replicas: +```sql +DELETE FROM pgsql_servers WHERE comment LIKE '%${INFRA}%'; +-- All three backends in the WRITER hostgroup, addressed via Toxiproxy. +INSERT INTO pgsql_servers (hostgroup_id, hostname, port, max_connections, comment) VALUES + (${WHG}, 'toxiproxy.${INFRA}', 6001, 200, 'pg_primary ${INFRA}'), + (${WHG}, 'toxiproxy.${INFRA}', 6002, 200, 'pg_replica1 ${INFRA}'), + (${WHG}, 'toxiproxy.${INFRA}', 6003, 200, 'pg_replica2 ${INFRA}'); + +-- Automatic writer/reader assignment via pg_is_in_recovery(). +DELETE FROM pgsql_replication_hostgroups WHERE writer_hostgroup=${WHG}; +INSERT INTO pgsql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type, comment) + VALUES (${WHG}, ${RHG}, 'read_only', 'pg auto rw-split ${INFRA}'); + +LOAD PGSQL SERVERS TO RUNTIME; -- loads replication_hostgroups too +SAVE PGSQL SERVERS TO DISK; + +DELETE FROM pgsql_users WHERE comment LIKE '%${INFRA}%'; +REPLACE INTO pgsql_users (username, password, active, default_hostgroup, comment) VALUES + ('postgres', '${ROOT_PASSWORD}', 1, ${WHG}, '${INFRA}'), + ('testuser', 'testuser', 1, ${WHG}, '${INFRA}'); +LOAD PGSQL USERS TO RUNTIME; +SAVE PGSQL USERS TO DISK; + +-- Read/write split query rules (route SELECTs to reader HG). +DELETE FROM pgsql_query_rules WHERE destination_hostgroup IN (${WHG}, ${RHG}); +INSERT INTO pgsql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply) VALUES + (${WHG}01, 1, '^SELECT.*FOR UPDATE', ${WHG}, 1), + (${RHG}01, 1, '^SELECT', ${RHG}, 1); +LOAD PGSQL QUERY RULES TO RUNTIME; +SAVE PGSQL QUERY RULES TO DISK; + +-- Enable the monitor (drives the automatic split). +UPDATE global_variables SET variable_value='true' WHERE variable_name='pgsql-monitor_enabled'; +UPDATE global_variables SET variable_value='monitor' WHERE variable_name IN ('pgsql-monitor_username','pgsql-monitor_password'); +LOAD PGSQL VARIABLES TO RUNTIME; +SAVE PGSQL VARIABLES TO DISK; +``` + +- [ ] **Step 2: Apply + verify the monitor moved replicas to the reader HG** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=pg-compat test/infra/control/start-proxysql-isolated.bash +# after ~monitor_read_only_interval, check runtime hostgroups over the PG admin (port 6132): +docker exec proxysql.dev-$USER env PGPASSWORD=admin psql -h127.0.0.1 -p6132 -Uadmin -dadmin \ + -c "SELECT hostgroup_id, hostname, port FROM runtime_pgsql_servers ORDER BY hostgroup_id;" +``` +Expected: exactly one server in `${WHG}` (the primary, port 6001) and two in `${RHG}` (replicas, 6002/6003) — the monitor demoted the read-only backends. + +- [ ] **Step 3: Commit** + +```bash +git add test/infra/*/conf/proxysql/infra-config.sql +git commit -m "config(pg-compat): automatic pgsql_replication_hostgroups via Toxiproxy" +``` + +--- + +## Task 5: pytest harness skeleton (in-container runner + config primitive) + +Create the pytest suite and the container runner. Prove the wiring with one trivial test that connects through ProxySQL and reconfigures it via admin. + +**Files:** +- Create: `test/pg-compat/{requirements.txt,Dockerfile,run-pg-compat.bash,conftest.py,pytest.ini,README.md}` +- Create: `test/pg-compat/harness/proxysql.py` +- Create: `test/pg-compat/tests/test_smoke.py` + +**Interfaces:** +- Consumes: env vars from Task 2/3 (`PGCOMPAT_*`) + `INFRA_ID`. +- Produces: `harness.proxysql.Admin` (methods `set_var`, `load_vars`, `snapshot`, `restore`, `query`), and pytest fixtures `admin`, `proxy_conn` used by all later tests. + +- [ ] **Step 1: requirements + container image + runner** + +`test/pg-compat/requirements.txt`: +``` +psycopg[binary]==3.2.* +asyncpg==0.30.* +pytest==8.* +tomli==2.* +``` +`test/pg-compat/Dockerfile`: +```dockerfile +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl && rm -rf /var/lib/apt/lists/* +WORKDIR /pg-compat +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENTRYPOINT ["pytest", "-q"] +``` +`test/pg-compat/run-pg-compat.bash` (mirrors `run-tests-isolated.bash:273` — runs pytest in a container joined to the infra network): +```bash +#!/usr/bin/env bash +set -euo pipefail +: "${INFRA_ID:?}"; : "${WORKSPACE:?}" +NETWORK="${INFRA_ID}_backend" +source "${WORKSPACE}/test/tap/groups/pg-compat/env.sh" +docker build -t proxysql-pg-compat:latest "${WORKSPACE}/test/pg-compat" +docker run --rm --network "${NETWORK}" \ + -e INFRA_ID -e PGCOMPAT_PRIMARY_HOST -e PGCOMPAT_PRIMARY_PORT \ + -e PGCOMPAT_REPLICA1_HOST -e PGCOMPAT_REPLICA1_PORT \ + -e PGCOMPAT_REPLICA2_HOST -e PGCOMPAT_REPLICA2_PORT \ + -e PGCOMPAT_TOXI_ADMIN -e PGCOMPAT_TOXI_PRIMARY \ + -e PGCOMPAT_TOXI_REPLICA1 -e PGCOMPAT_TOXI_REPLICA2 \ + -e PGCOMPAT_PROXY_HOST="proxysql" -e PGCOMPAT_PROXY_PORT="6133" \ + -e PGCOMPAT_ADMIN_HOST="proxysql" -e PGCOMPAT_ADMIN_PORT="6132" \ + proxysql-pg-compat:latest "$@" +``` + +- [ ] **Step 2: Write the admin config primitive** + +`test/pg-compat/harness/proxysql.py`: +```python +import os +import psycopg + +def _admin_dsn(): + host = os.environ["PGCOMPAT_ADMIN_HOST"]; port = os.environ["PGCOMPAT_ADMIN_PORT"] + # ProxySQL admin speaks the PG protocol on 6132; user/pass = admin/admin. + return f"host={host} port={port} user=admin password=admin dbname=admin sslmode=disable" + +class Admin: + def __init__(self): + self.conn = psycopg.connect(_admin_dsn(), autocommit=True) + + def query(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def set_var(self, name, value): + self.query(f"SET {name}={value!r}" if isinstance(value, str) else f"SET {name}={value}") + + def load_vars(self): + self.query("LOAD PGSQL VARIABLES TO RUNTIME") + + def snapshot(self, var_names): + rows = self.query( + "SELECT variable_name, variable_value FROM global_variables WHERE variable_name IN (%s)" + % ",".join(repr(v) for v in var_names)) + return dict(rows) + + def restore(self, saved): + for name, value in saved.items(): + self.set_var(name, value) + self.load_vars() +``` + +- [ ] **Step 3: conftest fixtures + smoke test** + +`test/pg-compat/conftest.py`: +```python +import os +import psycopg +import pytest +from harness.proxysql import Admin + +@pytest.fixture(scope="session") +def admin(): + return Admin() + +def _proxy_dsn(dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"]; p = os.environ["PGCOMPAT_PROXY_PORT"] + return f"host={h} port={p} user=testuser password=testuser dbname={dbname} sslmode=disable" + +@pytest.fixture +def proxy_conn(): + conn = psycopg.connect(_proxy_dsn(), autocommit=True) + yield conn + conn.close() +``` +`test/pg-compat/tests/test_smoke.py`: +```python +def test_proxy_select_one(proxy_conn): + with proxy_conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone()[0] == 1 + +def test_admin_reconfig_roundtrip(admin): + saved = admin.snapshot(["pgsql-authentication_method"]) + admin.set_var("pgsql-authentication_method", 1); admin.load_vars() + val = admin.query("SELECT variable_value FROM global_variables WHERE variable_name='pgsql-authentication_method'") + assert val[0][0] == "1" + admin.restore(saved) +``` +`test/pg-compat/pytest.ini`: +```ini +[pytest] +testpaths = tests +``` + +- [ ] **Step 4: Run — expect PASS** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=pg-compat test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_smoke.py +``` +Expected: 2 passed. Proves in-container reachability of ProxySQL frontend (6133) and admin-over-PG (6132), and the config primitive. + +- [ ] **Step 5: Commit** + +```bash +git add test/pg-compat +git commit -m "test(pg-compat): pytest harness skeleton + admin config primitive + smoke" +``` + +--- + +## Task 6: Differential engine (6 targets) + first case + self-check + +Run each SQL case against 6 targets and assert ProxySQL is byte-identical to a direct backend. Includes a self-check proving the engine detects divergence. + +**Files:** +- Create: `test/pg-compat/harness/targets.py`, `harness/diff.py` +- Create: `test/pg-compat/cases/001_scalars.sql`, `cases/002_bytea_json_array.sql` +- Create: `test/pg-compat/tests/test_differential.py`, `tests/test_differential_selfcheck.py` + +**Interfaces:** +- Consumes: `Admin` (to toggle `pgsql-use_native_backend_protocol`), env endpoints (proxy + `PGCOMPAT_PRIMARY_HOST`). +- Produces: `targets.all_targets()` → list of `Target(name, connect(), result_format)`; `diff.run_case(sql, targets)` → per-target `CaseResult` + a `compare(results)` returning `(ok, diff_text)`. + +- [ ] **Step 1: Write the failing differential test** + +`test/pg-compat/tests/test_differential.py`: +```python +import glob, os, pytest +from harness import targets, diff + +CASE_FILES = sorted(glob.glob(os.path.join(os.path.dirname(__file__), "..", "cases", "*.sql"))) + +@pytest.mark.parametrize("case_file", CASE_FILES, ids=[os.path.basename(f) for f in CASE_FILES]) +def test_case_is_transparent(admin, case_file): + tgts = targets.all_targets(admin) + results = diff.run_case(case_file, tgts) + ok, detail = diff.compare(results) + assert ok, f"{os.path.basename(case_file)} diverged:\n{detail}" +``` + +- [ ] **Step 2: Implement targets.py (6 targets)** + +`test/pg-compat/harness/targets.py`: +```python +import os +from dataclasses import dataclass +from typing import Callable +import psycopg + +def _dsn(host, port, dbname="testuser", user="testuser", pw="testuser"): + return f"host={host} port={port} user={user} password={pw} dbname={dbname} sslmode=disable" + +def _proxy(): return _dsn(os.environ["PGCOMPAT_PROXY_HOST"], os.environ["PGCOMPAT_PROXY_PORT"]) +def _direct(): return _dsn(os.environ["PGCOMPAT_PRIMARY_HOST"], os.environ["PGCOMPAT_PRIMARY_PORT"]) + +@dataclass +class Target: + name: str + dsn: str + binary: bool + native_backend: bool | None # None = direct (not applicable) + +def all_targets(admin): + # proxy targets exist twice: native backend OFF and ON. + return [ + Target("proxy_libpq_text", _proxy(), False, False), + Target("proxy_libpq_binary", _proxy(), True, False), + Target("proxy_native_text", _proxy(), False, True), + Target("proxy_native_binary", _proxy(), True, True), + Target("direct_text", _direct(), False, None), + Target("direct_binary", _direct(), True, None), + ] +``` + +- [ ] **Step 3: Implement diff.py** + +`test/pg-compat/harness/diff.py` — parses case metadata, sets the backend mode per target, runs, and compares status tag + column names + type OIDs + rows: +```python +import re +from fnmatch import fnmatchcase + +import psycopg + +def _parse(case_file): + sql = open(case_file).read() + # Metadata regexes run on the ORIGINAL text, comment lines included. + skip = set(re.findall(r"--\s*skip-targets:\s*(.+)", sql)) + only = set(re.findall(r"--\s*only-targets:\s*(.+)", sql)) + # Strip comment lines PER LINE *before* splitting on ";". Filtering + # ";"-delimited chunks that start with "--" instead discards an ENTIRE case + # whose first line is a metadata comment: with one trailing ";" the whole + # file is a single chunk beginning with "--", so the comment AND the SQL are + # thrown away together and zero statements run -- a vacuous pass. Every + # shipped case starts with such a comment, so this is not a corner case. + body = "\n".join(l for l in sql.splitlines() if not l.strip().startswith("--")) + stmts = [s.strip() for s in body.split(";") if s.strip()] + return stmts, (skip.pop().split() if skip else []), (only.pop().split() if only else []) + +def _run_on(target, stmts, admin): + if target.native_backend is not None: + admin.set_var("pgsql-use_native_backend_protocol", "true" if target.native_backend else "false") + admin.load_vars() + out = [] + with psycopg.connect(target.dsn, autocommit=True) as conn: + for s in stmts: + with conn.cursor(binary=target.binary) as cur: + cur.execute(s) + cols = [(d.name, d.type_code) for d in (cur.description or [])] + rows = cur.fetchall() if cur.description else None + out.append((cur.statusmessage, cols, rows)) + return out + +def run_case(case_file, targets, admin=None): + stmts, skip, only = _parse(case_file) + results = {} + for t in targets: + # Glob matching, so a documented pattern such as "proxy_native_*" + # actually selects the native targets; a plain name with no + # metacharacter still compares as an exact match. + if any(fnmatchcase(t.name, p) for p in skip): continue + if only and not any(fnmatchcase(t.name, p) for p in only): continue + results[t.name] = _run_on(t, stmts, admin) + return results + +def compare(results): + # Every proxy_* result must equal its format-matched direct_* baseline. + def base(name): return "direct_binary" if name.endswith("binary") else "direct_text" + diffs = [] + for name, res in results.items(): + if name.startswith("direct"): continue + b = results.get(base(name)) + if res != b: + diffs.append(f"{name} != {base(name)}\n got: {res}\n base: {b}") + return (not diffs, "\n".join(diffs)) +``` +Note: `diff.run_case` needs `admin` — thread it through the test (`diff.run_case(case_file, tgts, admin)`); update the Step 1 call to pass `admin`. + +- [ ] **Step 4: First cases** + +`test/pg-compat/cases/001_scalars.sql`: +```sql +-- transactional: false +SELECT true, 2147483647::int4, 9223372036854775807::int8, 1.5::float8, 12345.6789::numeric, 'héllo'::text; +``` +`test/pg-compat/cases/002_bytea_json_array.sql`: +```sql +-- transactional: false +SELECT '\xdeadbeef'::bytea, '{"a":1}'::jsonb, ARRAY[1,2,3]::int4[], '192.168.0.1'::inet, '00000000-0000-0000-0000-000000000001'::uuid; +``` + +- [ ] **Step 5: Run — expect PASS (or recorded xfail per §2.1)** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_differential.py +``` +Expected: proxy targets match direct. Any real divergence (esp. on `proxy_native_*`, the young path) is recorded in `xfail.toml` (Task 9), NOT silenced. + +- [ ] **Step 6: Self-check — a rewrite rule must make a case fail** + +`test/pg-compat/tests/test_differential_selfcheck.py`: +```python +from harness import targets, diff + +def test_engine_detects_divergence(admin): + # Install a query rule that rewrites the result, making proxy != direct. + admin.query("INSERT INTO pgsql_query_rules (rule_id,active,match_digest,replace_pattern,re_modifiers,apply) " + "VALUES (990001,1,'SELECT 1 AS canary','SELECT 2 AS canary','CASELESS',1)") + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + try: + tgts = targets.all_targets(admin) + results = diff.run_case_sql("SELECT 1 AS canary", tgts, admin) # inline-SQL helper variant + ok, _ = diff.compare(results) + assert not ok, "differential engine FAILED to detect an injected rewrite divergence" + finally: + admin.query("DELETE FROM pgsql_query_rules WHERE rule_id=990001") + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") +``` +Add a small `run_case_sql(sql, targets, admin)` helper to `diff.py` (same as `run_case` but takes a SQL string instead of a file). + +- [ ] **Step 7: Run self-check + commit** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_differential_selfcheck.py +git add test/pg-compat/harness/targets.py test/pg-compat/harness/diff.py test/pg-compat/cases test/pg-compat/tests/test_differential.py test/pg-compat/tests/test_differential_selfcheck.py +git commit -m "test(pg-compat): 6-target differential engine + cases + divergence self-check" +``` + +--- + +## Task 7: Routing oracle (pg_stat_statements) + self-check + +Prove *where* a query landed by reading `pg_stat_statements` per backend. + +**Files:** +- Create: `test/pg-compat/harness/oracle.py`, `tests/test_routing_oracle.py` + +**Interfaces:** +- Consumes: direct connections to each backend (`PGCOMPAT_PRIMARY_HOST`/`_REPLICA1`/`_REPLICA2`), the proxy connection. +- Produces: `oracle.reset_all()`, `oracle.calls_for(pattern)` → `{primary:int, replica1:int, replica2:int}`. + +- [ ] **Step 1: Write the failing oracle test** + +`test/pg-compat/tests/test_routing_oracle.py`: +```python +from harness import oracle + +def test_select_lands_on_a_reader(proxy_conn): + oracle.reset_all() + for _ in range(20): + with proxy_conn.cursor() as cur: + cur.execute("SELECT 42 AS oracle_probe") + cur.fetchone() + counts = oracle.calls_for("%oracle_probe%") + # read/write split: SELECTs must hit readers (replicas), not the primary/writer. + assert counts["primary"] == 0, f"SELECT hit the primary: {counts}" + assert counts["replica1"] + counts["replica2"] == 20, f"reads not on replicas: {counts}" +``` + +- [ ] **Step 2: Implement oracle.py** + +```python +import os +import psycopg + +def _c(host, port): + return psycopg.connect( + f"host={host} port={port} user=testuser password=testuser dbname=testuser sslmode=disable", + autocommit=True) + +# Each backend carries its own port: under dbdeployer all three share a host +# and differ only by port, so keying on host alone cannot reach the replicas. +_BACKENDS = { + "primary": ("PGCOMPAT_PRIMARY_HOST", "PGCOMPAT_PRIMARY_PORT"), + "replica1": ("PGCOMPAT_REPLICA1_HOST", "PGCOMPAT_REPLICA1_PORT"), + "replica2": ("PGCOMPAT_REPLICA2_HOST", "PGCOMPAT_REPLICA2_PORT"), +} + +def reset_all(): + for host_env, port_env in _BACKENDS.values(): + with _c(os.environ[host_env], os.environ[port_env]) as conn, conn.cursor() as cur: + cur.execute("SELECT pg_stat_statements_reset()") + +def calls_for(pattern): + out = {} + for name, env in _BACKENDS.items(): + with _c(os.environ[env]) as conn, conn.cursor() as cur: + cur.execute("SELECT COALESCE(SUM(calls),0) FROM pg_stat_statements WHERE query LIKE %s", (pattern,)) + out[name] = int(cur.fetchone()[0]) + return out +``` + +- [ ] **Step 3: Run — expect PASS** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_routing_oracle.py +``` +Expected: primary=0, replicas sum to 20. (If the monitor hasn't demoted replicas yet, the reads could land on the primary — that is a real config/monitor finding, recorded per §2.1, not silenced.) + +- [ ] **Step 4: Self-check — a writer-pinned query shows 0 on replicas** + +Append to `tests/test_routing_oracle.py`: +```python +def test_write_pins_to_primary(proxy_conn): + oracle.reset_all() + with proxy_conn.cursor() as cur: + cur.execute("CREATE TEMP TABLE oracle_w (id int)") + cur.execute("INSERT INTO oracle_w VALUES (1)") + counts = oracle.calls_for("%oracle_w%") + assert counts["replica1"] == 0 and counts["replica2"] == 0, f"write leaked to a replica: {counts}" +``` + +- [ ] **Step 5: Run + commit** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_routing_oracle.py +git add test/pg-compat/harness/oracle.py test/pg-compat/tests/test_routing_oracle.py +git commit -m "test(pg-compat): pg_stat_statements routing oracle + write-pin self-check" +``` + +--- + +## Task 8: Shared behavior set + Python driver adapter + +Encode the driver-agnostic behavior set once, behind a small adapter interface, so SP-3 can add Java/Go/Node adapters against the same behaviors. + +**Files:** +- Create: `test/pg-compat/drivers/python/adapter.py` +- Create: `test/pg-compat/behaviors/{__init__.py,connect.py,transactions.py,prepared.py,session_isolation.py}` +- Create: `test/pg-compat/tests/test_behaviors.py` + +**Interfaces:** +- Produces: adapter protocol `Adapter` with methods `connect()`, `exec_simple(sql)`, `exec_params(sql, params, binary)`, `prepare(name, sql)`, `exec_prepared(name, params)`, `begin()/commit()/rollback()`, `close()`. Each behavior is `def run(adapter) -> None` raising on failure. + +- [ ] **Step 1: Write the failing behavior test** + +`test/pg-compat/tests/test_behaviors.py`: +```python +import pytest +from drivers.python.adapter import PsycopgAdapter +from behaviors import connect, transactions, prepared, session_isolation + +BEHAVIORS = [connect, transactions, prepared, session_isolation] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=[b.__name__.split(".")[-1] for b in BEHAVIORS]) +def test_behavior_python(behavior): + behavior.run(PsycopgAdapter) +``` + +- [ ] **Step 2: Implement the Python adapter** + +`test/pg-compat/drivers/python/adapter.py`: +```python +import os +import psycopg + +class PsycopgAdapter: + def __init__(self, dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"]; p = os.environ["PGCOMPAT_PROXY_PORT"] + self.conn = psycopg.connect( + f"host={h} port={p} user=testuser password=testuser dbname={dbname} sslmode=disable", + autocommit=True) + + def exec_simple(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def exec_params(self, sql, params, binary=False): + with self.conn.cursor(binary=binary) as cur: + cur.execute(sql, params) + return cur.fetchall() if cur.description else None + + def begin(self): self.conn.autocommit = False + def commit(self): self.conn.commit(); self.conn.autocommit = True + def rollback(self): self.conn.rollback(); self.conn.autocommit = True + def close(self): self.conn.close() +``` + +- [ ] **Step 3: Implement the behaviors** + +`test/pg-compat/behaviors/connect.py`: +```python +def run(Adapter): + a = Adapter() + assert a.exec_simple("SELECT 1")[0][0] == 1 + a.close() +``` +`test/pg-compat/behaviors/transactions.py`: +```python +def run(Adapter): + a = Adapter() + a.exec_simple("DROP TABLE IF EXISTS tx_t") + a.exec_simple("CREATE TABLE tx_t (id int)") + a.begin() + a.exec_simple("INSERT INTO tx_t VALUES (1)") + a.rollback() + assert a.exec_simple("SELECT count(*) FROM tx_t")[0][0] == 0, "rollback did not discard the insert" + a.begin(); a.exec_simple("INSERT INTO tx_t VALUES (2)"); a.commit() + assert a.exec_simple("SELECT count(*) FROM tx_t")[0][0] == 1 + a.close() +``` +`test/pg-compat/behaviors/prepared.py`: +```python +def run(Adapter): + a = Adapter() + # Reuse a parameterized statement many times across multiplexed backends. + for i in range(50): + r = a.exec_params("SELECT $1::int + $2::int", (i, 1)) + assert r[0][0] == i + 1 + a.close() +``` +`test/pg-compat/behaviors/session_isolation.py`: +```python +def run(Adapter): + a = Adapter() + a.exec_simple("SET application_name = 'behavior_A'") + assert a.exec_simple("SHOW application_name")[0][0] == "behavior_A" + b = Adapter() + assert b.exec_simple("SHOW application_name")[0][0] != "behavior_A", "session state leaked across connections" + a.close(); b.close() +``` +`test/pg-compat/behaviors/__init__.py`: empty. + +- [ ] **Step 4: Run + commit** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash tests/test_behaviors.py +git add test/pg-compat/drivers test/pg-compat/behaviors test/pg-compat/tests/test_behaviors.py +git commit -m "test(pg-compat): shared behavior set + Python (psycopg3) driver adapter" +``` + +--- + +## Task 9: xfail catalogue (discovery-phase reporting) + +Make expected failures first-class so the suite is a living failure inventory rather than red-on-divergence. + +**Files:** +- Create: `test/pg-compat/xfail.toml`, `test/pg-compat/harness/xfail.py` +- Modify: `test/pg-compat/conftest.py` (apply xfail markers), `pytest.ini` + +**Interfaces:** +- Produces: `xfail.load()` → list of `{test_id, mode, reason, ref}`; a pytest hook that marks matching test IDs xfail(strict=False) so xpass is reported. + +- [ ] **Step 1: Define the catalogue format** + +`test/pg-compat/xfail.toml`: +```toml +# Each entry documents a KNOWN divergence discovered in the discovery phase (spec §2.1). +# strict=false semantics: a listed test that starts PASSING is reported as xpass (fix landed). +[[xfail]] +test_id = "tests/test_differential.py::test_case_is_transparent[002_bytea_json_array.sql]" +mode = "native" # libpq | native | both +reason = "proxy_native_binary bytea OID mismatch on the young native backend path" +ref = "PR #5882" +``` +(Seed it empty except for whatever the first real runs surface.) + +- [ ] **Step 2: Implement the loader + pytest hook** + +`test/pg-compat/harness/xfail.py`: +```python +import os, tomli + +def load(): + path = os.path.join(os.path.dirname(__file__), "..", "xfail.toml") + if not os.path.exists(path): + return [] + with open(path, "rb") as f: + return tomli.load(f).get("xfail", []) +``` +Append to `test/pg-compat/conftest.py`: +```python +import pytest +from harness import xfail as _xfail + +_XFAILS = { e["test_id"]: e for e in _xfail.load() } + +def pytest_collection_modifyitems(config, items): + for item in items: + rel = item.nodeid + entry = _XFAILS.get(rel) + if entry: + item.add_marker(pytest.mark.xfail(reason=f'{entry["reason"]} ({entry["ref"]})', strict=False)) +``` + +- [ ] **Step 3: Verify xfail/xpass semantics** + +Add a temporary known-failing case, list it in `xfail.toml`, run, and confirm it reports `xfailed`; remove the injected failure and confirm it reports `xpassed`. Then delete the temporary case. +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER test/pg-compat/run-pg-compat.bash -rxX +``` +Expected: the summary shows `xfailed`/`xpassed` lines, not `failed`. + +- [ ] **Step 4: Commit** + +```bash +git add test/pg-compat/xfail.toml test/pg-compat/harness/xfail.py test/pg-compat/conftest.py +git commit -m "test(pg-compat): xfail catalogue with xpass reporting (discovery phase)" +``` + +--- + +## Task 10: CI workflow — nightly + label-gated, inline build + +Wire the suite into CI as a non-gating nightly job that also runs on labeled PRs. It builds ProxySQL inline (like `CI-3p-*`) rather than chaining off `CI-builds`, and follows the two-branch caller/reusable split. + +**Files:** +- Create: `.github/workflows/CI-pg-compat.yml` (caller, on `v3.0`) +- Create: `gh-actions-reusable/ci-pg-compat.yml` (reusable body; merge to `GH-Actions` FIRST per `doc/GH-Actions/README.md:816-828`) + +**Interfaces:** +- Consumes: `ensure-infras.bash`, `run-pg-compat.bash`. Produces: a CI job publishing the pytest summary + xfail/xpass report as an artifact. + +- [ ] **Step 1: Caller workflow (schedule + label gate)** + +`.github/workflows/CI-pg-compat.yml`: +```yaml +name: CI-pg-compat +on: + schedule: + - cron: '0 3 * * *' + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +jobs: + pg-compat: + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'pg-compat') + permissions: write-all + uses: sysown/proxysql/.github/workflows/ci-pg-compat.yml@GH-Actions + secrets: inherit + with: + trigger: ${{ toJson(github) }} +``` + +- [ ] **Step 2: Reusable body (inline build + infra + pytest)** + +`gh-actions-reusable/ci-pg-compat.yml` (lands on `GH-Actions`; models the `CI-3p-*` inline-build pattern + the `ci-legacy-g4` infra steps in `README.md:355-484`): +```yaml +name: CI-pg-compat +on: + workflow_call: + inputs: + trigger: { required: false, type: string } + +jobs: + pg-compat: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Build ProxySQL (debug, PROXYSQL31) + run: PROXYSQL31=1 make -j$(nproc) debug + - name: Stand up infra (backends + Toxiproxy + ProxySQL) + env: { INFRA_ID: ci-${{ github.run_id }}, WORKSPACE: ${{ github.workspace }}, TAP_GROUP: pg-compat } + run: test/infra/control/ensure-infras.bash + - name: Run pg-compat suite (non-gating, discovery phase) + env: { INFRA_ID: ci-${{ github.run_id }}, WORKSPACE: ${{ github.workspace }} } + run: test/pg-compat/run-pg-compat.bash --junitxml=/tmp/pg-compat.xml -rxX || true # non-gating + - name: Publish report + if: always() + uses: actions/upload-artifact@v4 + with: { name: pg-compat-report, path: /tmp/pg-compat.xml } +``` +Note the `|| true` — per §2.1 this job is **reporting, not gating**, during the discovery phase; promote to gating (drop `|| true`, tighten xfail) once green and stable. + +- [ ] **Step 3: Validate locally (act or a manual dry run of the steps)** + +Run the reusable steps by hand on a dev box (build → ensure-infras → run-pg-compat) to confirm the sequence works end to end, since `schedule`/label triggers can only be exercised on the branch. + +- [ ] **Step 4: Commit (two commits, two branches)** + +```bash +# reusable first, on GH-Actions: +git add gh-actions-reusable/ci-pg-compat.yml +git commit -m "ci(pg-compat): reusable workflow (inline build + infra + pytest, non-gating)" +# caller on v3.0: +git add .github/workflows/CI-pg-compat.yml +git commit -m "ci(pg-compat): nightly + pg-compat-label caller workflow" +``` + +--- + +## Self-Review + +**Spec coverage (SP-2 items → tasks):** +- New dbdeployer PG infra (primary + 2 replicas) → Tasks 1–2 ✓ (with honest dbdeployer spike + native fallback; §Greenfield register). +- Toxiproxy fault layer → Task 3 ✓ (sidecar + per-backend proxies; toxics themselves are SP-4). +- Automatic `pgsql_replication_hostgroups` routing → Task 4 ✓ (exact SQL, verified via `runtime_pgsql_servers`). +- pytest shared behavior-set runner → Tasks 5, 8 ✓ (in-container runner + adapter interface for SP-3). +- 6-target differential engine → Task 6 ✓ (proxy-libpq/native × text/binary vs direct; divergence self-check). +- `pg_stat_statements` routing oracle → Task 7 ✓ (+ write-pin self-check). +- Config-as-primitive → Task 5 ✓ (`Admin` snapshot/restore + `LOAD ... TO RUNTIME`). +- Backend-mode axis (§2.2) → Task 6 threads `pgsql-use_native_backend_protocol` off/on into the target set. +- Discovery-phase xfail catalogue (§2.1) → Task 9 ✓; CI non-gating → Task 10 ✓. + +**Placeholder scan:** No TBD/TODO. The genuinely-unknown pieces are concrete **spikes with commands and a decision gate** (Task 1 dbdeployer; Task 2's variant fork), not placeholders. Task 6 Step 3 flags one wiring detail (thread `admin` into `run_case`) explicitly with the fix. + +**Type consistency:** `Admin` methods (`set_var`, `load_vars`, `snapshot`, `restore`, `query`) are used consistently in Tasks 5–9. `Target` fields (`name`, `dsn`, `binary`, `native_backend`) match between `targets.py` and `diff.py`. `oracle` API (`reset_all`, `calls_for`) matches its test. Adapter methods (`exec_simple`, `exec_params`, `begin/commit/rollback`, `close`) match `PsycopgAdapter` and the behaviors. Env-var names (`PGCOMPAT_*`) are identical across `env.sh`, `run-pg-compat.bash`, and the harness. + +**Cross-plan dependency:** SP-2 is independent of SP-1 (different harness) but shares the `pgsql-use_native_backend_protocol` axis framing (spec §2.2) and the discovery-phase policy (§2.1). SP-3 (more drivers) and SP-4 (chaos) build directly on Tasks 3, 6, 8. + +**Highest residual risk:** Task 1 (dbdeployer-PG). If the spike fails, Task 2b (native `postgres:17`, extending the proven `infra-pgsql17-repl`) keeps the entire rest of the plan unchanged — every downstream task consumes env-injected endpoints, not a specific topology. Surface the spike outcome to the maintainer before building Task 2. diff --git a/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md b/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md new file mode 100644 index 0000000000..4dd5c20cfb --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md @@ -0,0 +1,249 @@ +# PostgreSQL SP-3 — Driver Matrix Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run the SP-2 driver-agnostic behavior contract (connect, transactions, prepared statements, session isolation) through three additional real-world driver stacks — **Go/pgx**, **Java/pgjdbc**, **Node.js/node-postgres + Prisma** — each with its own protocol implementation and prepared-statement strategy, orchestrated by the existing pytest harness so the xfail catalogue, junit report, and CI wiring apply unchanged. + +**Architecture:** Each language ships ONE self-contained behavior program implementing the 4-behavior contract behind a uniform CLI (` ` → exit 0/1, diagnostics on stderr). Programs are compiled/installed into the existing runner image via a multi-stage Dockerfile extension (the container is the only place toolchains are guaranteed — Java doesn't exist on the dev host). Thin pytest subprocess wrappers under `tests/` give each (language × behavior) pair a stable nodeid (`tests/test_behaviors_go.py::test_behavior_go[transactions]`) so the exact-nodeid xfail catalogue works as-is. **Scope decision (user-approved 2026-07-08): behaviors only** — the differential engine stays Python/psycopg (its comparison unit is psycopg's decode semantics; per-language differential runners are a possible SP-3b, not this plan). + +**Tech Stack:** Go 1.22 + pgx v5, Java 21 (Temurin) + pgjdbc 42.7.x, Node 22 + pg (node-postgres) 8.x + Prisma 5.x, multi-stage Docker on `python:3.11-slim`, pytest subprocess wrappers, existing `run-pg-compat.bash`/CI. + +## Global Constraints + +- **The behavior contract is FROZEN.** The four behaviors' semantics must match `test/pg-compat/behaviors/*.py` exactly (they are the cross-driver contract): same assertions, same trap adaptations. Do not change the Python behaviors. +- **Trap adaptations every port MUST reproduce** (from the SP-1/SP-2 findings — the Python behaviors' docstrings are the reference): + - Session-isolation probe = `SET TimeZone = 'Antarctica/Troll'` / `SHOW TimeZone` (NEVER `application_name` — it's in ProxySQL's `ignore_vars`). Close A **before** opening B; assert B ≠ the distinctive value. + - Transactions: every verification `SELECT count(*) ... AS verify_read` runs inside its own explicit BEGIN/COMMIT (pins to the writer; a bare `^SELECT` routes to a replica → replication-lag flake). + - Every connection string pins **`client_encoding=UTF8`** (backend DBs are SQL_ASCII; ProxySQL imposes UTF8 — recorded finding in `xfail.toml`). Driver syntax: pgx/node-pg DSN param `client_encoding=UTF8`; pgjdbc URL does NOT accept it directly — use `options=-c%20client_encoding=UTF8` in the JDBC URL (verify empirically; `SET client_encoding` after connect is the fallback). + - Placeholders are driver-native: pgx `$1,$2` · pgjdbc `?` · node-pg `$1,$2` (Python's `%s` is psycopg-specific). +- **Env contract (read, never invent):** programs read `PGCOMPAT_PROXY_HOST` (default `proxysql`) / `PGCOMPAT_PROXY_PORT` (default `6133`), connect as `testuser`/`testuser`, db `testuser`, sslmode/ssl disabled. No other env vars needed by behavior programs. +- **CLI contract (uniform across languages):** ` ` where `` ∈ {connect, transactions, prepared, session_isolation}; exit 0 = pass, exit 1 = behavior assertion failed (human-readable reason on stderr), exit 2 = usage/infra error. No output on stdout needed for pass. +- **Table names are per-language** to be parallel-safe: `behavior_tx_t_go`, `behavior_tx_t_java`, `behavior_tx_t_node`, `behavior_tx_t_prisma` (Python keeps `behavior_tx_t`). Each program drops its table in a finally-equivalent. +- **Nodeid stability:** pytest wrappers use `@pytest.mark.parametrize(..., ids=[...])` with the literal behavior names so xfail.toml keys are stable (`tests/test_behaviors_.py::test_behavior_[]`). +- **Docker builds need `--network=host` in this environment** (documented in `run-pg-compat.bash`); harmless on GitHub runners. All toolchains live in the IMAGE (multi-stage), not the host — Java does not exist on the dev host at all. +- **Discovery-phase (spec §2.1):** a driver behavior that genuinely fails through ProxySQL is a FINDING — never weaken the program's assertion; add an `[[xfail]]` entry with reason+ref (this is exactly what the catalogue is for; Prisma is the most likely candidate). +- **Verify runs:** `WORKSPACE=$(pwd) INFRA_ID=sdd-sp2 test/pg-compat/run-pg-compat.bash ` against the standing `sdd-sp2` infra (`ensure-infras.bash` first if down). Do NOT touch `sdd-pg1`, `dev-rene*`, `iss5883`. +- **Version pins:** pgx `v5.7.x`, pgjdbc `42.7.x` (exact jar version pinned in the Dockerfile), pg (node) `8.x`, Prisma `5.x` — record exact chosen versions in a comment + the README table. + +--- + +## File Structure + +**New (this plan):** +- `test/pg-compat/drivers/go/behaviors.go` + `go.mod`/`go.sum` — Go behavior program (pgx v5). +- `test/pg-compat/drivers/java/Behaviors.java` — Java behavior program (single file, pgjdbc on the classpath). +- `test/pg-compat/drivers/node/behaviors.js` + `package.json`/`package-lock.json` — Node behavior program (pg). +- `test/pg-compat/drivers/prisma/` — `behaviors.mjs`, `schema.prisma`, package files — Prisma behavior program. +- `test/pg-compat/tests/test_behaviors_go.py`, `test_behaviors_java.py`, `test_behaviors_node.py`, `test_behaviors_prisma.py` — subprocess wrappers. +- `test/pg-compat/tests/_subproc.py` — the one shared subprocess helper (run program, assert exit 0, surface stderr). + +**Modified:** +- `test/pg-compat/Dockerfile` — multi-stage: Go builder (static binary), Java builder (javac) + JRE in final, Node runtime + npm ci; final stage remains `python:3.11-slim`-based. +- `test/pg-compat/README.md` — driver matrix table (language, driver, version, prepared-statement strategy, placeholder syntax). +- `docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md` — §6 SP-3 stub updated to the approved behaviors-only scope (+ SP-3b stub for per-language differential runners). +- `.github/workflows/gh-actions-reusable/ci-pg-compat.yml` — timeout bump only if measured necessary (Task 6 decides on evidence). + +**Interfaces produced (consumed by every task):** +- CLI contract as in Global Constraints; binaries land in the image at `/pg-compat/bin/behaviors-go`, `/pg-compat/bin/Behaviors.class`+wrapper `behaviors-java`, `/pg-compat/bin/behaviors-node` (wrapper invoking `node /pg-compat/drivers/node/behaviors.js`), `/pg-compat/bin/behaviors-prisma`. +- `tests/_subproc.py`: `def run_behavior(program: str, behavior: str) -> None` — runs `[program, behavior]`, `pytest.fail` with captured stderr on nonzero exit; `pytest.skip(f"{program} not in image")` if the binary is absent (lets partial images run). + +--- + +## Task 1: Multi-language runner image + CLI/subprocess scaffolding + +Extend the Dockerfile with the three toolchains (multi-stage; final image stays lean), add the shared subprocess helper, and prove the wiring with stub programs that only implement `connect`. Real behaviors land per-language in Tasks 2–4 — this task makes the image+harness seam work end to end. + +**Files:** +- Modify: `test/pg-compat/Dockerfile` +- Create: `test/pg-compat/tests/_subproc.py`, `test/pg-compat/drivers/go/{behaviors.go,go.mod}`, `test/pg-compat/drivers/java/Behaviors.java`, `test/pg-compat/drivers/node/{behaviors.js,package.json}` (stubs: `connect` only, other behaviors exit 2 "not implemented") +- Create: `test/pg-compat/tests/test_behaviors_go.py` (+ java, node variants) with ONLY the `connect` param active this task (`BEHAVIORS = ["connect"]`; Tasks 2–4 extend the list per language) + +**Interfaces:** +- Produces: the Dockerfile stages + `/pg-compat/bin/behaviors-{go,java,node}` layout, `run_behavior()` helper, wrapper test files. Tasks 2–4 only edit their language's program + extend their `BEHAVIORS` list. + +- [ ] **Step 1: Extend the Dockerfile (multi-stage)** + +Replace `test/pg-compat/Dockerfile` with: + +```dockerfile +# ---- Go builder: static behavior binary (no runtime needed in final) ---- +FROM golang:1.22-bookworm AS gobuild +WORKDIR /src +COPY drivers/go/ . +RUN CGO_ENABLED=0 go build -o /out/behaviors-go . + +# ---- Java builder: compile against a pinned pgjdbc jar ---- +FROM eclipse-temurin:21-jdk AS javabuild +WORKDIR /src +# Pin the driver version explicitly; record bumps in README's driver table. +ARG PGJDBC_VERSION=42.7.4 +RUN curl -fsSLo /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" +COPY drivers/java/Behaviors.java . +RUN javac -cp /pgjdbc.jar Behaviors.java -d /out + +# ---- Node deps: install node-postgres against the lockfile ---- +FROM node:22-bookworm-slim AS nodebuild +WORKDIR /app +COPY drivers/node/package.json drivers/node/package-lock.json* ./ +RUN npm ci --omit=dev || npm install --omit=dev +COPY drivers/node/behaviors.js . + +# ---- Final: python base + JRE + node runtime + artifacts ---- +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 curl default-jre-headless \ + && rm -rf /var/lib/apt/lists/* +# Node runtime copied from the official image (bookworm-glibc compatible). +COPY --from=nodebuild /usr/local/bin/node /usr/local/bin/node +WORKDIR /pg-compat +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +# Language artifacts under /pg-compat/bin with uniform CLI wrappers. +COPY --from=gobuild /out/behaviors-go /pg-compat/bin/behaviors-go +COPY --from=javabuild /out/ /pg-compat/bin/java-classes/ +COPY --from=javabuild /pgjdbc.jar /pg-compat/bin/pgjdbc.jar +COPY --from=nodebuild /app /pg-compat/node-app +RUN printf '#!/bin/sh\nexec java -cp /pg-compat/bin/java-classes:/pg-compat/bin/pgjdbc.jar Behaviors "$@"\n' > /pg-compat/bin/behaviors-java \ + && printf '#!/bin/sh\nexec node /pg-compat/node-app/behaviors.js "$@"\n' > /pg-compat/bin/behaviors-node \ + && chmod +x /pg-compat/bin/behaviors-* +ENTRYPOINT ["pytest", "-q"] +``` + +(If `COPY --from=nodebuild /usr/local/bin/node` misses shared libs at runtime, fall back to `apt-get install nodejs` from bookworm — decide empirically, document in the report.) + +- [ ] **Step 2: Shared subprocess helper** + +`test/pg-compat/tests/_subproc.py`: + +```python +"""Run a per-language behavior program and translate its exit code into +pytest semantics. The CLI contract: ` ` -> exit 0 pass, +exit 1 assertion-failure (reason on stderr), exit 2 usage/infra error.""" +import os +import subprocess + +import pytest + +def run_behavior(program, behavior): + if not os.path.exists(program): + pytest.skip(f"{program} not present in this image") + r = subprocess.run( + [program, behavior], capture_output=True, text=True, timeout=120, + env=os.environ.copy(), + ) + if r.returncode == 0: + return + detail = f"{program} {behavior} -> exit {r.returncode}\nstderr:\n{r.stderr}\nstdout:\n{r.stdout}" + if r.returncode == 2: + pytest.fail(f"infra/usage error (not a behavior failure): {detail}") + pytest.fail(detail) +``` + +- [ ] **Step 3: Stub programs (connect only) + wrapper tests** + +Each stub implements `connect` fully (open → `SELECT 1` → assert 1 → close) and exits 2 with "not implemented" for the other names. Wrapper test file pattern (`tests/test_behaviors_go.py`; java/node identical with names swapped): + +```python +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-go" +BEHAVIORS = ["connect"] # Tasks 2-4 extend per language + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_go(behavior): + run_behavior(PROGRAM, behavior) +``` + +Stub sources: keep the real connection code (it is Task-common): read `PGCOMPAT_PROXY_HOST`/`PGCOMPAT_PROXY_PORT`, user/pass/db `testuser`, ssl off, `client_encoding=UTF8`. (Full per-language programs land in Tasks 2–4 — write the stubs so extending = filling in function bodies, not restructuring.) + +- [ ] **Step 4: Build + run — expect 3 new `connect` passes** + +```bash +WORKSPACE=$(pwd) INFRA_ID=sdd-sp2 test/pg-compat/run-pg-compat.bash tests/test_behaviors_go.py tests/test_behaviors_java.py tests/test_behaviors_node.py -v +``` +Expected: 3 passed (go/java/node × connect). Full suite still 16+3 passed, 2 skipped. + +- [ ] **Step 5: Commit** + +```bash +git add test/pg-compat/Dockerfile test/pg-compat/tests/_subproc.py test/pg-compat/tests/test_behaviors_*.py test/pg-compat/drivers/go test/pg-compat/drivers/java test/pg-compat/drivers/node +git commit -m "test(pg-compat): multi-language runner image + behavior CLI scaffolding (connect x3)" +``` + +--- + +## Task 2: Go/pgx behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/go/behaviors.go` (+`go.sum`), extend `BEHAVIORS` in `tests/test_behaviors_go.py` to all four. + +**Key driver facts to encode:** pgx v5 (`github.com/jackc/pgx/v5`) prepares statements automatically via its statement cache (`default_query_exec_mode=cache_statement` default) — the 50× parameterized loop (`SELECT $1::int + $2::int`) exercises real extended-protocol prepared statements. DSN: `postgres://testuser:testuser@$HOST:$PORT/testuser?sslmode=disable&client_encoding=UTF8`. Transactions via `conn.Begin(ctx)`/`tx.Commit(ctx)`; verify-reads inside their own tx (`AS verify_read` alias, table `behavior_tx_t_go`). Session isolation: conn A `SET TimeZone='Antarctica/Troll'` → `SHOW TimeZone` == it → `a.Close(ctx)` → conn B `SHOW TimeZone` != it. All four behaviors behind the CLI switch; cleanup via `defer` + explicit final `DROP TABLE IF EXISTS`. + +- [ ] **Step 1:** Extend `BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"]` in the wrapper; run → RED (exit 2 not-implemented for the three new ones). +- [ ] **Step 2:** Implement the three behaviors in `behaviors.go` per the frozen contract (mirror `behaviors/*.py` assertions exactly; the Python files are the spec — read them). +- [ ] **Step 3:** Rebuild image + run `tests/test_behaviors_go.py -v` → 4 passed. Run twice (idempotent). A genuine failure through ProxySQL = finding: keep it failing, add `[[xfail]]` with reason+ref, report it. +- [ ] **Step 4:** Full suite green (± catalogued xfails). Commit: `test(pg-compat): Go/pgx behavior program (full contract)`. + +--- + +## Task 3: Java/pgjdbc behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/java/Behaviors.java`, extend `tests/test_behaviors_java.py`. + +**Key driver facts to encode:** pgjdbc placeholders are `?`; pgjdbc switches a reused `PreparedStatement` to a **server-side named statement after `prepareThreshold` (default 5) executions** — reuse ONE PreparedStatement object for the 50× loop so the back half runs real named statements through ProxySQL's multiplexing (this is the pgjdbc-specific value of the port). URL: `jdbc:postgresql://$HOST:$PORT/testuser?sslmode=disable&options=-c%20client_encoding%3DUTF8` — VERIFY the options form empirically; fallback: execute `SET client_encoding TO 'UTF8'` right after connect and document. Transactions: `setAutoCommit(false)` … `commit()` … `setAutoCommit(true)`; verify-reads in their own autocommit-off/commit pair (`AS verify_read`, table `behavior_tx_t_java`). Session isolation identical structure (close A before B). Exit codes per the CLI contract; single-file `Behaviors.java` with a `main` dispatching on args[0]. + +- [ ] **Step 1:** Extend BEHAVIORS → RED (exit 2). +- [ ] **Step 2:** Implement; mirror the Python behaviors exactly. +- [ ] **Step 3:** Rebuild + run → 4 passed ×2 runs. pgjdbc's named-statement path failing through ProxySQL would be a HIGH-VALUE finding (this is the classic pooler breaker): keep failing + xfail-catalogue + report prominently. +- [ ] **Step 4:** Full suite green (± catalogued). Commit: `test(pg-compat): Java/pgjdbc behavior program (full contract)`. + +--- + +## Task 4: Node/node-postgres behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/node/behaviors.js` (+lockfile), extend `tests/test_behaviors_node.py`. + +**Key driver facts to encode:** `pg` 8.x; placeholders `$1,$2`; **named prepared statements** via `client.query({name: 'add', text: 'SELECT $1::int + $2::int AS sum', values: [i, 1]})` — reusing the same `name` for the 50× loop makes node-pg Parse once and Bind/Execute repeatedly (its distinct prepared-statement strategy). Connection config from env (`host`, `port`, user/pass/db `testuser`, `ssl: false`); pin encoding via connection string param `client_encoding=UTF8` (or `options`). Transactions via explicit `BEGIN`/`COMMIT`/`ROLLBACK` queries; verify-reads inside their own BEGIN/COMMIT (`AS verify_read`, table `behavior_tx_t_node`). Session isolation: A sets/asserts TZ, `await a.end()` BEFORE `new Client()` B. Exit codes per CLI; async main with try/finally cleanup. + +- [ ] **Step 1:** Extend BEHAVIORS → RED. +- [ ] **Step 2:** Implement (mirror Python behaviors). +- [ ] **Step 3:** Rebuild + run → 4 passed ×2. Findings → xfail catalogue + report. +- [ ] **Step 4:** Full suite green (± catalogued). Commit: `test(pg-compat): Node/node-postgres behavior program (full contract)`. + +--- + +## Task 5: Prisma behavior program (ORM tier — xfail-tolerant) + +Prisma is the notorious pooler-breaker (aggressive prepared statements, its own connection assumptions) — that's exactly why it's in scope. It may legitimately fail through ProxySQL: failures here are FINDINGS for the catalogue, not blockers. + +**Files:** Create `test/pg-compat/drivers/prisma/{behaviors.mjs,schema.prisma,package.json,package-lock.json}`, `tests/test_behaviors_prisma.py`; modify the Dockerfile (extend the node stage: `npx prisma generate` at build time against `schema.prisma`; `binaryTargets = ["debian-openssl-3.0.x"]`). + +**Key facts:** datasource url from `env("PGCOMPAT_PRISMA_URL")` — construct it in the wrapper test/conftest from the PGCOMPAT proxy vars (`postgresql://testuser:testuser@$HOST:$PORT/testuser?sslmode=disable`). Behaviors via `$queryRaw`/`$executeRaw` + `$transaction` (interactive transactions for the txn-wrapped verify reads): `connect` = `SELECT 1`; `transactions` = table `behavior_tx_t_prisma` with $transaction rollback/commit semantics (rollback = throw inside the interactive txn); `prepared` = 50× `$queryRaw\`SELECT ${i}::int + ${1}::int\`` (Prisma always uses prepared statements — the whole point); `session_isolation` = two PrismaClient instances, `SET TimeZone` via `$executeRawUnsafe`, disconnect A before creating B. Note Prisma pools internally (connection_limit=1 in the URL keeps it deterministic-ish; document). + +- [ ] **Step 1:** Dockerfile prisma-generate stage + stub `connect` → wrapper with `BEHAVIORS=["connect"]` → green. +- [ ] **Step 2:** Implement all four; extend BEHAVIORS → run. **Expected outcome is uncertain by design** — record per-behavior results honestly; catalogue genuine ProxySQL-vs-Prisma incompatibilities as `[[xfail]]` entries with precise reasons (these are the deliverable). +- [ ] **Step 3:** Full suite: passes + catalogued xfails only. Run ×2. Commit: `test(pg-compat): Prisma behavior program (ORM tier, findings catalogued)`. + +--- + +## Task 6: Docs, spec sync, CI budget check + +**Files:** Modify `test/pg-compat/README.md`, `docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md` (§6), possibly `.github/workflows/gh-actions-reusable/ci-pg-compat.yml` (timeout only). + +- [ ] **Step 1:** README driver-matrix table: language | driver+version | placeholder syntax | prepared-statement strategy (psycopg auto-prepare@5 / pgx statement-cache / pgjdbc prepareThreshold@5 named / node-pg named / Prisma always) | wrapper nodeid prefix. Plus how to run one language (`run-pg-compat.bash tests/test_behaviors_go.py`). +- [ ] **Step 2:** Spec §6: replace the SP-3 stub with the as-built scope (behaviors-only, subprocess orchestration, drivers list + versions) and add an **SP-3b** stub (per-language differential runners emitting normalized results for Python's compare — deferred pending nightly stability). +- [ ] **Step 3:** Measure the image-build delta (time the docker build before/after SP-3 stages) and the full-suite wall time; bump the reusable's `timeout-minutes` ONLY if evidence demands (report the numbers either way). +- [ ] **Step 4:** Full suite final run ×2 → record the final pass/skip/xfail tally. Commit: `docs(pg-compat): SP-3 driver matrix docs + spec sync (+ CI budget evidence)`. + +--- + +## Self-Review + +**Spec coverage:** SP-3 roadmap items → Java/pgjdbc (Task 3), Go/pgx (Task 2), Node node-postgres + Prisma (Tasks 4–5) — all against the SP-2 `behaviors/` contract (Task 1 seam). Scope deviation from the spec stub's "behaviors + differential cases" is user-approved (2026-07-08, behaviors-only) and gets written back into the spec in Task 6 with an SP-3b stub. CI fan-out from the roadmap ("one matrix job per language") deliberately simplified to the single fat-image job — same coverage, no matrix complexity; revisit at promote-to-gating. + +**Placeholder scan:** Tasks 2–5 say "mirror the Python behaviors" instead of embedding ~150 lines × 4 languages — this is deliberate, not a placeholder: the Python behavior modules ARE the frozen executable spec (Global Constraints), each task names the exact driver-specific deltas (placeholders, prepared-statement mechanism, txn API, encoding pin), and implementers must read the Python files first. The Dockerfile, helper, and wrapper code are complete. + +**Type consistency:** CLI contract, binary paths (`/pg-compat/bin/behaviors-*`), `run_behavior(program, behavior)`, `BEHAVIORS` list pattern, per-language table names, and nodeid shapes are used identically across Tasks 1–6. + +**Risks:** (1) node binary COPY missing shared libs — Task 1 names the fallback; (2) pgjdbc URL encoding-pin syntax — Task 3 mandates empirical verification with a stated fallback; (3) Prisma engine/binaryTarget in slim image — Task 5 pins `debian-openssl-3.0.x`; (4) image size/build time — Task 6 measures and decides the CI budget on evidence. diff --git a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md new file mode 100644 index 0000000000..cb6a75366c --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -0,0 +1,287 @@ +# PostgreSQL Protocol Automated Testing — Design + +**Date:** 2026-07-08 +**Status:** Approved for planning +**Author:** Rene Cannao (with Claude Code) +**Scope of this document:** SP-1 (TAP coverage gaps) and SP-2 (polyglot test-harness foundation), specified in full. SP-3 and SP-4 are captured as a roadmap only. + +--- + +## 1. Motivation + +ProxySQL already has solid PostgreSQL protocol test coverage in its TAP suite (~60 unique `pgsql-*-t.cpp` integration tests + 8 unit tests) — strong on transaction state, extended-query protocol, COPY, query routing, and SSL. Two things are missing: + +1. **Behavioral coverage gaps** in known-thin areas: a deliberate auth-method matrix, systematic data-type/binary-encoding round-trips, server-side cursors, and pool-churn/max-connections behavior. +2. **Whole classes of test *technique*** that the leading PG proxies (PgBouncer, pgcat, pgdog) rely on and ProxySQL lacks: deterministic fault injection (chaos), differential/golden-master transparency testing, a `pg_stat_statements` routing oracle, and a **cross-driver matrix** that runs the same behaviors through many real client drivers. + +This program adds both, phased: fast coverage wins in the existing harness first (SP-1), then the reusable infrastructure for the new techniques (SP-2), then breadth and chaos (SP-3, SP-4). + +### Prior art surveyed (informing this design) + +- **PgBouncer** — pytest harness; shared `QueryRunner` base class hit by both proxy and backend through one API; in-process fault injection (`drop_traffic`/`reject_traffic`/`add_latency` via iptables/`tc`, plus a `socat` MITM proxy); `PortLock` for parallel isolation; systematic packet-buffer boundary matrix; PG-version CI matrix (13/15/16/18). +- **pgcat** — polyglot (Ruby/Python/Rust/Go) drivers against one instance; **Toxiproxy in front of every backend** (1-byte `limit_data` toxic to simulate a hung host, instantly reversible); **`pg_stat_statements` as the routing oracle**; raw-socket protocol diff-testing; programmatic TOML config + live `RELOAD`. +- **pgdog** — 7-language driver matrix; **6-target differential SQL harness** (proxy-vs-direct, text/binary/sharded, asserting identical status, column names, **type OIDs**, rowcounts, payloads via drop-in SQL files); Toxiproxy chaos with **bounded-error-rate assertions**; WAL-state synthesis for un-hookable crash windows; build-once/fan-out CI. + +The two highest-leverage borrowed ideas are the **differential harness** (proxy output must be byte-identical to a direct backend) and the **`pg_stat_statements` routing oracle** (prove *where* a query landed without parsing logs). + +--- + +## 2. Goals and non-goals + +### Goals +- Close the SP-1 behavioral gaps within the existing TAP/C++ harness, gating on every PR. +- Stand up a reusable polyglot test foundation (SP-2) proving the differential + routing-oracle + chaos-ready techniques end-to-end with one reference driver (Python). +- Design SP-2 so SP-3 (more drivers) and SP-4 (chaos suite) are additive, not rewrites. +- Produce, from the first runs, a **catalogue of what ProxySQL currently fails** — the initial phase is diagnostic (see §2.1). + +### 2.1 Operating assumption — the initial phase is discovery, not green CI + +The first runs of these suites (especially SP-2's differential + cross-driver matrix, and anything on the new native backend path — §2.2) are expected to **surface failures**, not pass. There is **no expectation of 100% success** in the initial phase. Concretely: + +- The deliverable of the initial phase is a **failure inventory** — a catalogue of divergences (transparency violations, driver-specific protocol breakage, routing surprises), each triaged per `CLAUDE.md`'s "never dismiss as flaky" policy into: real ProxySQL bug / test-harness bug / known-and-accepted limitation. +- Failing cases are recorded as **expected-failures (xfail) with a reason and a tracking reference**, not deleted or skipped silently. An xfail that starts passing (xpass) is itself reported, so fixes are noticed. +- CI (SP-2) is therefore **non-gating** by construction in this phase; its job is reporting, not blocking. Promotion of individual behaviors to gating happens only once they are green and stable. + +### 2.2 Cross-cutting axis — backend protocol mode (libpq vs native) + +PR #5882 (`feature/pgsql-native-backend-protocol`, open against `v3.0` as of 2026-07-07) introduces an **opt-in native PostgreSQL wire-protocol implementation on the ProxySQL→backend data path**, behind runtime flag `pgsql-use_native_backend_protocol` (default **off**; libpq remains as fallback and for monitor/plugins). The native path already implements native auth (trust/cleartext/MD5/SCRAM-SHA-256, plus SCRAM-SHA-256-PLUS channel binding), the extended-query pipeline, native `COPY ... TO STDOUT`, and **native NOTIFY** — and ships its own differential tests (e.g. `pgsql-native_notify-t`, "27/27 strict prepared-statement cases, byte-equal"). + +Consequences for this design: +- **Backend protocol mode is a first-class test parameter.** Where feasible, SP-1 and SP-2 behaviors run under **both** `pgsql-use_native_backend_protocol = off` and `= on`, and the two are diffed against each other and against direct-PG. This is the single highest-value new axis, because the native path is young and evolving. +- The feature-gap analysis in this document is **path-dependent and will change quickly** as #5882 lands and progresses. Claims below that reference libpq behavior (notably LISTEN/NOTIFY, §3.5) are explicitly scoped to the *libpq* path and are expected to shift; the native path is closing several of them. + +### Non-goals / explicit scope exclusions +- **No single-table sharding / cross-shard tests.** Confirmed from code: ProxySQL PG routing maps one query to exactly one `destination_hostgroup` (`PgSQL_Query_Processor.cpp:356,491`); there is no shard-key, shard-map, scatter/gather, or cross-shard aggregation. All pgdog/pgcat sharding-style tests are out of scope. (This reduces the differential harness from pgdog's 6 targets to 4.) +- **LISTEN/NOTIFY delivery is path-dependent, not a flat exclusion.** On the **libpq** path LISTEN is explicitly rejected (`0A000`, `PgSQL_Session.cpp:865`, `:6547`) with no `NotificationResponse`/`PQnotifies` forwarding; on the **native** path (#5882) NOTIFY support is being added. SP-1 §3.5 therefore pins the *current per-mode contract* rather than assuming a single behavior; forwarding design lives in the native PR, not here (Appendix A updated accordingly). +- **No two-phase-commit crash-safety / logical-replication-resharding tests** (pgdog features ProxySQL doesn't have). +- SP-3 and SP-4 are **not** implemented under this spec; only stubbed as roadmap (§6). + +--- + +## 3. SP-1 — TAP coverage gaps + +**Harness:** existing `test/tap/tests/` (`-t.cpp`), registered in `test/tap/groups/groups.json`. **CI:** per-PR, gating. **Backends:** existing `test/infra/docker-pgsql16-single` (and `infra-pgsql17-repl` where multi-node is needed). Debug build required (per `CLAUDE.md`). + +**Backend-mode note:** where a behavior touches the backend data path (auth, prepared statements, COPY, NOTIFY), the test parameterizes over `pgsql-use_native_backend_protocol` off/on (§2.2) so both paths are covered as the native path matures. Cases that are known-broken on the young native path are marked xfail (§2.1), not skipped. Note SP-1's frontend-facing tests (e.g. `pg_lite_client` auth, §3.0) are independent of backend mode. + +### 3.0 Shared harness enabler — extend `pg_lite_client` + +`test/tap/tests/pg_lite_client.{h,cpp}` is the hand-rolled raw-socket wire client. Today its auth support is limited to `AuthenticationOk` and cleartext (type 3); SCRAM (10) and MD5 (5) throw "Unsupported authentication method". Several SP-1 tests need deliberate control over the auth exchange and over result formats. + +**Work:** add to `pg_lite_client`: +- **MD5 auth** (type 5) — `md5(md5(password+user)+salt)`. +- **SCRAM-SHA-256** (type 10 → SASL) — reuse ProxySQL's vendored `libscram` (`deps/`) rather than re-implementing; the client drives `SASLInitialResponse`/`SASLResponse` and validates the server signature. +- A knob to **force** a specific requested auth type / to assert the auth request type the server sent (so a test can assert "server asked for scram-sha-256", not just "auth succeeded"). + +This is a prerequisite for 3.1 and reused by 3.2/3.3. + +### 3.1 Auth-method matrix — `pgsql-auth_method_matrix-t.cpp` + +Deliberately exercises each method rather than letting libpq auto-negotiate: +- **Success paths:** trust (local), cleartext password, md5, scram-sha-256, cert-only (`hostssl ... cert`). +- **Failure paths:** wrong password per method; unknown user; method-mismatch (server demands scram, client offers md5); expired/rotated password (ties to existing `pgsql-scram_cache_invalidation-t`). +- **Assertions:** the auth request type the frontend received, the final `ReadyForQuery`, and the exact `SQLSTATE` on failure (`28P01` etc.). + +Backend `pg_hba.conf` in `docker-pgsql16-single` already provides scram + cert; add md5 and cleartext user entries as needed (init SQL, not a new topology). + +### 3.2 Data-type / binary-encoding matrix — `pgsql-datatype_matrix-t.cpp` + +Systematic round-trips, **each type in both text and binary result format**, asserting value fidelity *and* the column type OID and format code: +- Scalars: `bool`, `int2/4/8`, `float4/8`, `numeric` (incl. NaN, ±Inf, high precision), `text`/`varchar` (incl. multibyte UTF-8, embedded NUL-adjacent), `bytea` (incl. bytes 0x00–0xFF), `uuid`, `date`/`time`/`timestamp`/`timestamptz` (incl. infinity), `interval`. +- Composite/edge: `jsonb`/`json`, 1-D and **multi-dimensional arrays** (incl. NULL elements, quoting/escaping edge cases), `inet`/`cidr`/`macaddr`. +- **Assertion mechanism:** driven through `pg_lite_client` with explicit result-format codes; overlaps the SP-2 differential engine conceptually but stays in TAP for the per-PR gate. (SP-2 later generalizes this into the proxy-vs-direct diff.) + +### 3.3 Server-side cursors — `pgsql-server_side_cursors-t.cpp` + +- `DECLARE cur CURSOR FOR ...` / `FETCH n` / `MOVE` / `CLOSE` over simple protocol. +- Extended protocol **portal suspension**: `Execute` with a non-zero max-row count → `PortalSuspended` → continue → completion, across multiplexed connections (assert the portal stays pinned to its backend). `pg_lite_client` already supports portals. + +### 3.4 Pool churn / max-connections — `pgsql-pool_churn-t.cpp` + +- Connection storm exceeding `pgsql-max_connections` per hostgroup; assert queuing / clean rejection, no leak (`SHOW ... pool` counters via admin). +- **Session-state isolation across multiplexing:** set a session GUC / prepared statement / temp state on connection A, force backend reuse, assert connection B does not observe A's state. This is the classic pooler-correctness risk and is currently only indirectly covered. + +### 3.5 LISTEN/NOTIFY contract test — `pgsql-listen_notify_contract-t.cpp` + +Pins the **current per-mode contract** (chosen option: contract test + feature note). Because behavior differs by backend protocol mode (§2.2), the test parameterizes over `pgsql-use_native_backend_protocol`: + +- **libpq path (`off`):** `LISTEN chan` over **simple** protocol → asserts `0A000` "LISTEN is not supported"; over **extended** protocol → same rejection (`PgSQL_Session.cpp:6547`). `NOTIFY chan,'payload'` as a plain query → completes cleanly, no hang/crash, connection reusable. `UNLISTEN` asserted consistently. +- **native path (`on`):** asserts whatever contract #5882 lands (its `pgsql-native_notify-t` already covers NOTIFY differentially). This portion is expected to move and is marked xfail where the native path is incomplete, rather than hard-coding today's snapshot. + +This intentionally avoids baking one behavior into the assertion set, since the native path is actively changing what LISTEN/NOTIFY does. Appendix A tracks the forwarding design as owned by the native PR, not this spec. + +### 3.6 SP-1 grouping & CI + +- Register new tests in `groups.json` under an appropriate `legacy-g*` group (following existing pgsql placement), tagged with the correct `@proxysql_min_version` if any behavior is tier-gated. +- All SP-1 tests run in the normal per-PR gating TAP matrix. No new CI workflow. + +--- + +## 4. SP-2 — Polyglot test-harness foundation + +**Harness:** new top-level directory `test/pg-compat/` (Python/pytest). **CI:** nightly cron + opt-in `pg-compat` PR label; non-gating relative to normal PRs. **Reference driver:** Python (psycopg3 + asyncpg). SP-2 proves the whole machine with Python only; SP-3 adds the other languages. + +### 4.1 New backend topology — `test/infra/infra-dbdeployer-pgsql17-repl/` (dbdeployer) + +No primary+2-replica PG topology exists today; SP-2 needs one. **New infras use dbdeployer** — matching the established `test/infra/infra-dbdeployer-*` convention (currently MySQL/MariaDB only; this is the first dbdeployer PG infra). dbdeployer supports PostgreSQL replication sandboxes, so a single container runs dbdeployer to deploy the whole topology internally and expose its ports, exactly like `infra-dbdeployer-mysql84-gr`. + +- **Provisioning:** dbdeployer inside one container image (`proxysql/ci-infra:dbdeployer-pgsql17-repl`), following the sibling layout — `docker/{Dockerfile,build.sh,entrypoint.sh}`, `bin/docker-*-post.bash`, `docker-compose{,-init,-destroy}.bash`, `.env`. dbdeployer deploys **1 primary + 2 replicas** (PG 17) as a replication sandbox. +- **`.env`:** defines `WHG`/`RHG` (and `PREFIX`) hostgroups and the dbdeployer host/port block, per the `infra-dbdeployer-*` pattern. +- **Fault-injection layer:** one **Toxiproxy** endpoint per backend port; ProxySQL's `pgsql_servers` point at the Toxiproxy ports, not the real PG ports, so every backend is individually degradable (latency, `limit_data` slow-loris, reset-peer) without touching the sandbox — the mechanism SP-4 leans on. (Toxiproxy sits between ProxySQL and dbdeployer's exposed PG ports.) +- **Routing config:** use the **automatic** monitor-driven path — populate `pgsql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type='read_only')` and let the monitor's `pg_is_in_recovery()` check (`PgSQL_Monitor.cpp:824,1859`) place primary→writer HG and replicas→reader HG. Deliberately *different* from the static placement in `infra-pgsql17-repl`, because that automatic machinery is what read/write split and SP-4 failover actually depend on. +- Reuses `test/infra/control/` runners; introduces no new manual Docker/dbdeployer steps (all wrapped by the standard `ensure-infras.bash` flow). + +### 4.2 Directory layout — `test/pg-compat/` + +``` +test/pg-compat/ +├── conftest.py # fixtures: proxysql admin conn, backend conns, toxiproxy client, +│ # per-test config snapshot/restore, port isolation +├── harness/ +│ ├── proxysql.py # admin-driven config mutation + LOAD ... TO RUNTIME (test primitive) +│ ├── targets.py # connection factories for the 4 differential targets +│ ├── oracle.py # pg_stat_statements routing oracle (per-backend call counts) +│ ├── toxi.py # thin Toxiproxy wrapper (add/reset toxics) — used by SP-4, stubbed here +│ └── diff.py # differential comparison engine (§4.4) +├── drivers/ +│ └── python/ # reference driver adapter (psycopg3 + asyncpg) +│ └── adapter.py # implements the driver-adapter interface (§4.3) +├── behaviors/ # shared, driver-agnostic behavior set (§4.3) +│ ├── connect.py +│ ├── transactions.py +│ ├── prepared.py +│ ├── rw_split.py +│ └── session_isolation.py +├── cases/ # drop-in SQL cases for the differential harness (§4.4) +│ └── NNN_slug.sql +├── requirements.txt +└── README.md +``` + +### 4.3 Shared behavior set + driver-adapter interface + +The core reuse mechanism: **behaviors are written once, driver-agnostic**, and each driver provides a small **adapter** implementing a fixed interface (open connection, exec-simple, exec-params(text|binary), prepare/execute-named, begin/commit/rollback, close). SP-3 adds Java/Go/Node adapters against the *same* `behaviors/`. + +Behavior modules for SP-2: +- **connect** — startup params, options, unix socket, reconnect. +- **transactions** — BEGIN/COMMIT/ROLLBACK, savepoints, txn-state after errors, idle-in-transaction. +- **prepared** — named + unnamed prepared statements reused across multiplexed backends (the classic pooler breakage); assert result correctness and that ProxySQL's prepared-statement handling stays consistent. +- **rw_split** — SELECT → reader HG, writes/`SELECT ... FOR UPDATE` → writer HG; verified by the routing oracle (§4.5). +- **session_isolation** — session GUC / temp / prepared state must not leak across multiplexed reuse (mirrors SP-1 3.4 but through a real driver). + +### 4.4 Differential engine (golden-master transparency) + +Adapted from pgdog's harness. No sharding, but the **backend-mode axis (§2.2) splits the proxy target in two**, giving **6 targets**: + +| Target | Connection | Result format | +|---|---|---| +| `proxy_libpq_text` | via ProxySQL, `use_native_backend_protocol=off` | text | +| `proxy_libpq_binary` | via ProxySQL, `use_native_backend_protocol=off` | binary | +| `proxy_native_text` | via ProxySQL, `use_native_backend_protocol=on` | text | +| `proxy_native_binary` | via ProxySQL, `use_native_backend_protocol=on` | binary | +| `direct_text` | direct to primary | text | +| `direct_binary` | direct to primary | binary | + +- Each case in `cases/NNN_slug.sql` runs against all targets; the engine asserts **identical** command status tag, column names, **column type OIDs**, row count, and row payloads. Every `proxy_*` must be indistinguishable from `direct_*` — and, valuably, `proxy_libpq_*` vs `proxy_native_*` diffs pinpoint native-path regressions directly. +- This **complements the native PR's own differential tests** (e.g. `pgsql-native_notify-t`, its strict byte-equal prepared-statement cases): those live in TAP and gate the native work; this harness is the broader, driver-driven, case-drop-in golden master across both modes. +- Case metadata in SQL comments (pgdog convention): `-- transactional:`, `-- skip-targets:`, `-- only-targets:` (e.g. `only-targets: proxy_native_*`). +- Per §2.1, divergences in the discovery phase become **xfail entries with a reason**, feeding the failure inventory rather than blocking. +- **Adding a test = dropping in one SQL file.** Initial cases cover the SP-1 data-type matrix expressed as differential cases (bytea, numeric, jsonb, arrays, network, temporal), giving the same coverage through a *second, independent* mechanism (real driver + direct-backend diff) that catches transparency bugs the TAP self-asserting tests can't. + +### 4.5 Routing oracle + +Adapted from pgcat. `harness/oracle.py`: +- Before a behavior, snapshot `pg_stat_statements` per backend (reset or record baseline). +- Run the workload through ProxySQL. +- Read `SELECT sum(calls) FROM pg_stat_statements WHERE query LIKE ...` on each backend to prove *where* each query landed. +- Exact assertions for deterministic routing (write → writer HG only); **margin-of-error** assertions for balanced reads across the 2 replicas. +- Requires `pg_stat_statements` preloaded on every backend (infra config, §4.1). + +### 4.6 Config-as-primitive + +`harness/proxysql.py` mutates ProxySQL config through the **admin interface** (`pgsql_servers`, `pgsql_query_rules`, `pgsql_replication_hostgroups`, `pgsql-*` variables) followed by `LOAD ... TO RUNTIME`, then restores a snapshot in fixture teardown. This lets a single running ProxySQL be reconfigured per test (pool mode, multiplex on/off, HG layout) without restart — the PgBouncer/pgcat pattern. Debug build assumed (admin debug commands). + +### 4.7 CI + +- **New workflow** (e.g. `.github/workflows/CI-pg-compat.yml` caller on `v3.0`, reusable on `GH-Actions` per the two-branch split in `doc/GH-Actions/README.md`). +- **Triggers:** nightly `schedule` + `pull_request` gated on the `pg-compat` label. +- **Shape:** build proxysql (debug, `PROXYSQL31=1`) once → cache → job spins up `infra-dbdeployer-pgsql17-repl` (+ Toxiproxy) via the standard `test/infra/control/` runners → runs `pytest test/pg-compat` across both backend modes (§2.2). SP-3 (as built — see §6) runs all languages from one multi-language runner image in the same job; a per-language matrix fan-out remains an option at promote-to-gating. +- **Not gating** on normal PRs (heavy, multi-toolchain) — and, per §2.1, **reporting-oriented** in the discovery phase: the job publishes the failure inventory / xfail summary rather than going red on expected divergences. Nightly failures triaged per `CLAUDE.md`'s "never dismiss as flaky" policy. + +--- + +## 5. Testing the tests (validation strategy) + +- **Differential engine self-check:** a deliberately non-transparent config (e.g. a query rewrite rule) must make a differential case *fail* — proves the engine detects divergence, not just passes. +- **Routing oracle self-check:** a case pinned to the writer HG must show zero calls on replicas — proves the oracle actually discriminates. +- **Toxiproxy wiring self-check (SP-2 scope):** applying a full-block toxic to a replica must make the monitor shun it and reads reroute — proves the fault layer + monitor path are correctly wired, even though the *chaos suite* itself is SP-4. +- **Expected-failure catalogue (discovery phase, §2.1):** a single source-of-truth file (e.g. `test/pg-compat/xfail.toml`) lists each known-failing case with `reason`, `mode` (libpq/native/both), and a tracking reference. The harness treats listed cases as xfail and **reports xpass** (a listed case that now passes) so fixes are caught. This file *is* the living failure inventory. +- SP-1 tests follow existing TAP conventions and run under the isolated harness (`run-tests-isolated.bash`, debug binary). + +--- + +## 6. Roadmap — SP-3, SP-3b and SP-4 + +- **SP-3 — Driver matrix expansion (AS BUILT, complete 2026-07-08).** Ran the + existing SP-2 `behaviors/` contract (`connect`, `transactions`, `prepared`, + `session_isolation` — frozen, unchanged) through four more driver stacks: + **Go** (pgx v5.7.5), **Java** (pgjdbc 42.7.4), **Node.js** (pg/node-postgres + 8.13.1), and **Node.js/Prisma** (5.22.0, raw-query API only). Each ships one + self-contained CLI program (` `, exit 0/1/2) built into the + pg-compat image by a multi-stage `Dockerfile` extension, invoked from pytest + via subprocess wrappers (`tests/test_behaviors_.py` + + `tests/_subproc.py::run_behavior`) so the existing xfail catalogue, junit + report, and CI wiring apply unchanged. + - **Scope decision (user-approved 2026-07-08): behaviors only.** The + differential engine (§4.4) stays Python/psycopg-only — its comparison + unit is psycopg's row/type decode semantics, which the other languages + don't share — so it was NOT extended to the new drivers in SP-3. See + SP-3b below for that follow-up. + - CI fans out via a **single fat multi-language image**, not a one-job- + per-language matrix as originally sketched below: same coverage (all + five drivers run every CI invocation), no matrix-job complexity. Revisit + the split if/when this suite is promoted to gating. + - **Result: all five driver stacks pass the full behavior contract with + zero `xfail.toml` entries added** — four distinct prepared-statement + strategies (psycopg auto-prepare@5, pgx's default statement-cache, + pgjdbc's server-side NAMED statements after `prepareThreshold=5` — the + classic connection-pooler breaker — and Prisma's always-prepared Rust + engine) all stay transparent through ProxySQL's connection multiplexing. + See `test/pg-compat/README.md`'s "Driver matrix (SP-3)" section for the + full per-language table (versions, placeholder syntax, encoding-pin + mechanism) and the Prisma raw-vs-ORM caveat. +- **SP-3b — Per-language differential runners (stub, deferred).** Extend each + non-Python driver's behavior program with a differential-case runner that + executes the same case files as §4.4 and emits a normalized result + (status, column names, OIDs/type tags, decoded row values) on stdout for + Python's `compare()` to consume — so the differential engine's comparisons + gain Go/Java/Node/Prisma coverage without reimplementing the comparator + once per language. Deferred pending nightly stability of the SP-3 + behaviors-only suite (see `ci-pg-compat.yml`'s non-gating `|| true`); not + scheduled against a specific SP number yet. +- **SP-4 — Chaos & resilience suite.** Build on SP-2's Toxiproxy layer: failover/shunning (1-byte `limit_data` slow-loris), latency toxics, reset-peer, health-check detection and auto-recovery — with **bounded-error-rate assertions** (pgdog/pgcat style: "≤N errors of M", "reroute within T"), exercising the automatic `pgsql_replication_hostgroups` monitor path from §4.1. + +--- + +## Appendix A — NOTIFY forwarding: owned by the native-backend PR (#5882) + +This is **not** a feature this test spec proposes; it is being addressed by the native-backend work. As of 2026-07-07 the native path already ships NOTIFY support and a `pgsql-native_notify-t` differential test. For reference, full LISTEN/NOTIFY support entails: (1) lifting the `LISTEN` rejection in both `PgSQL_Session.cpp` paths; (2) pinning a LISTEN-ing frontend to a dedicated backend (incompatible with transaction-level multiplexing — likely session-mode-only); (3) a backend `NotificationResponse` (async 'A') consumption+forwarding path — the native wire layer makes this reachable in a way the libpq result API did not; (4) `UNLISTEN`/teardown lifecycle. SP-1 §3.5 pins the *current per-mode contract* so neither path silently regresses while #5882 evolves; it does not gate the feature. + +--- + +## Appendix B — Decision log + +| Decision | Choice | +|---|---| +| Primary goal | Both coverage-gaps and new-techniques, **phased** | +| Phase-2 harness | **Polyglot driver matrix** (heaviest, highest driver-breadth) | +| Driver ecosystems | Python (psycopg3+asyncpg+SQLAlchemy), Java (pgjdbc), Go (pgx), Node (node-pg+Prisma) | +| First spec | **SP-1 + SP-2 combined** (this document) | +| CI cadence | SP-1 per-PR gating; **SP-2 nightly + `pg-compat` label**, non-gating | +| Initial-phase expectation | **Discovery, not 100% green** — deliverable is a failure inventory; failing cases = xfail w/ reason (§2.1) | +| Backend-protocol axis | Parameterize over `pgsql-use_native_backend_protocol` off/on (§2.2, tracks PR #5882) | +| LISTEN/NOTIFY | **Per-mode contract test** + note; NOTIFY forwarding owned by #5882 (Appendix A) | +| Sharding tests | **Excluded** (no PG sharding in ProxySQL) | +| Differential targets | **6** (proxy-libpq / proxy-native / direct × text/binary) | +| New infra tooling | **dbdeployer** (`infra-dbdeployer-pgsql17-repl`), per existing `infra-dbdeployer-*` convention | +| Replica routing | **Automatic** `pgsql_replication_hostgroups` + `pg_is_in_recovery()` (primary + 2 replicas) | diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/.env b/test/infra/infra-dbdeployer-pgsql17-repl/.env new file mode 100644 index 0000000000..cfd8cc63c2 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/.env @@ -0,0 +1,13 @@ +PGSQL_VERSION=17.10 + +PREFIX=00 + +WHG=00 +RHG=01 + +# dbdeployer deploys all 3 PG nodes in ONE container; ports are auto-derived +# from the version (15000 + major*100 + minor) and fixed for 17.10. +PG_PRIMARY_HOST=dbdeployer1 +PG_PRIMARY_PORT=16710 +PG_REPLICA1_PORT=16711 +PG_REPLICA2_PORT=16712 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash new file mode 100755 index 0000000000..6fa1fe8c8c --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash @@ -0,0 +1,63 @@ +#!/bin/bash +# Host-side verification of the dbdeployer PostgreSQL replication backend. +# Provisioning (roles/databases/extension) happens inside the container's +# entrypoint; this script is VERIFICATION-ONLY. It asserts: +# - all 3 nodes are reachable and report the expected recovery state (f/t/t) +# - pg_stat_statements is queryable on every node +# - testuser can authenticate over TCP using its password +set -e +set -o pipefail +[ -f .env ] && . .env + +CONTAINER="${COMPOSE_PROJECT}-dbdeployer1-1" + +PRIMARY_PORT="${PG_PRIMARY_PORT:-16710}" +REPLICA1_PORT="${PG_REPLICA1_PORT:-16711}" +REPLICA2_PORT="${PG_REPLICA2_PORT:-16712}" + +# psql runs INSIDE the container (image bakes psql onto PATH); connect over +# loopback (trust) as postgres for the recovery/extension assertions. +pg() { + local port="$1"; shift + docker exec "${CONTAINER}" psql -h 127.0.0.1 -p "${port}" -U postgres -d postgres -tAc "$1" +} + +printf "[%s] PgSQL replication verification (Container: %s)\n" "$(date)" "${CONTAINER}" + +# 1. Reachability + recovery state (primary=f, replicas=t). +declare -A EXPECT=( ["${PRIMARY_PORT}"]="f" ["${REPLICA1_PORT}"]="t" ["${REPLICA2_PORT}"]="t" ) +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: waiting for connectivity..." + MAX_WAIT=60; COUNT=0 + while ! pg "${PORT}" "SELECT 1" >/dev/null 2>&1; do + if [ $COUNT -ge $MAX_WAIT ]; then echo " TIMEOUT"; exit 1; fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) + done + REC=$(pg "${PORT}" "SELECT pg_is_in_recovery();") + if [ "${REC}" != "${EXPECT[$PORT]}" ]; then + echo " FAIL (pg_is_in_recovery=${REC}, expected ${EXPECT[$PORT]})" + exit 1 + fi + echo " OK (pg_is_in_recovery=${REC})" +done + +# 2. pg_stat_statements queryable on every node. +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: pg_stat_statements..." + if ! pg "${PORT}" "SELECT count(*) FROM pg_stat_statements;" >/dev/null 2>&1; then + echo " FAIL (pg_stat_statements not queryable)"; exit 1 + fi + echo " OK" +done + +# 3. testuser TCP password login against every node. +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: testuser TCP password login..." + if ! docker exec -e PGPASSWORD=testuser "${CONTAINER}" \ + psql -h 127.0.0.1 -p "${PORT}" -U testuser -d testuser -tAc "SELECT 1" >/dev/null 2>&1; then + echo " FAIL (testuser could not authenticate)"; exit 1 + fi + echo " OK" +done + +printf "[%s] PgSQL replication verification COMPLETE (f/t/t + pg_stat_statements + testuser OK)\n" "$(date)" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash new file mode 100755 index 0000000000..0bc8cef04e --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -0,0 +1,57 @@ +#!/bin/bash +# Configure ProxySQL for the infra-dbdeployer-pgsql17-repl backend (automatic +# rw-split via Toxiproxy + monitor-driven pg_is_in_recovery() demotion). +# +# Follows the infra-pgsql17-repl pattern: eval-expand the SQL template (so +# ${INFRA_ID}/${WHG}/${RHG}/${ROOT_PASSWORD} are substituted) and pipe it into +# the ProxySQL admin interface over the PG protocol (port 6132), NOT the MySQL +# admin protocol -- ProxySQL's pgsql_* admin tables are only writable there. +set -e +set -o pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "${SCRIPT_DIR}/../.env" ] && . "${SCRIPT_DIR}/../.env" +PROXY_CONTAINER="proxysql.${INFRA_ID}" + +# ROOT_PASSWORD is normally exported by the caller (docker-compose-init.bash / +# start-proxysql-isolated.bash), but ensure-infras.bash's "already running" +# reconfigure path invokes this script directly without it. Re-derive it with +# the same deterministic formula so the 'postgres' pgsql_users row keeps +# matching the password the entrypoint actually set on the role. +ROOT_PASSWORD="${ROOT_PASSWORD:-$(echo -n "${INFRA_ID}" | sha256sum | head -c 10)}" + +echo ">>> Configuring ProxySQL (${PROXY_CONTAINER}) for PGSQL Replication (automatic rw-split via Toxiproxy): ${INFRA}" + +# Wait for ProxySQL admin (MySQL protocol, port 6032) to be reachable. Bounded: +# docker-compose-init.bash re-execs itself under `timeout`, but ensure-infras.bash's +# reconfigure path calls this script directly, where an unbounded loop would hang +# the run instead of failing it. +PROXY_WAIT_SECONDS="${PROXY_WAIT_SECONDS:-120}" +waited=0 +while ! docker exec "${PROXY_CONTAINER}" mysql -uadmin -padmin -h127.0.0.1 -P6032 -e 'SELECT 1' >/dev/null 2>&1; do + if [ "${waited}" -ge "${PROXY_WAIT_SECONDS}" ]; then + echo " TIMEOUT" + echo "ERROR: ProxySQL admin on ${PROXY_CONTAINER} not reachable after ${PROXY_WAIT_SECONDS}s." + echo ">>> Container Logs:" + docker logs "${PROXY_CONTAINER}" 2>&1 | tail -n 50 + exit 1 + fi + echo -n '.' + sleep 1 + waited=$((waited + 1)) +done + +# Pre-process the SQL template. envsubst substitutes exactly the five variables +# named below and leaves every other '$' alone; the previous eval-echo form ran +# the template through the shell, so any quote, backtick or $(...) that a future +# edit introduced into the SQL would be mangled or executed. envsubst reads only +# EXPORTED variables, hence the explicit export of the .env-sourced WHG/RHG. +export INFRA_ID INFRA WHG RHG ROOT_PASSWORD +SQL_CONTENT=$(envsubst '${INFRA_ID} ${INFRA} ${WHG} ${RHG} ${ROOT_PASSWORD}' \ + < "${SCRIPT_DIR}/../conf/proxysql/infra-config.sql") + +# Apply configuration via docker exec using psql (ProxySQL Admin supports PG +# protocol on port 6132). ON_ERROR_STOP=1 makes psql abort with a non-zero +# exit on the FIRST SQL-level error (bad token, constraint violation, ...); +# without it psql prints the error, keeps going, and exits 0 -- silently +# defeating set -e and this script's fail-non-zero contract. +echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -v ON_ERROR_STOP=1 -h127.0.0.1 -p6132 -Uadmin -dadmin diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh b/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh new file mode 100755 index 0000000000..9c6a9df34a --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Create one passthrough Toxiproxy proxy per PG backend (primary/replica1/ +# replica2). No toxics are added here -- SP-4's chaos suite adds those later. +# +# Idempotent: safe to re-run against an already-bootstrapped toxiproxy (each +# proxy is deleted first, 404-on-delete is tolerated, and a 409 on create is +# treated as "already exists" -> success). +# +# The toxiproxy:2.9.0 image ships no shell/curl, so the admin HTTP API (port +# 8474) is driven from a throwaway curl container attached to the same +# Docker network as toxiproxy and the backend. +set -euo pipefail + +: "${INFRA_ID:?INFRA_ID must be set}" + +NETWORK="${INFRA_ID}_backend" +TOXI_HOST="toxiproxy.${INFRA_ID}" +TOXI_ADMIN="${TOXI_HOST}:8474" +UPSTREAM_HOST="dbdeployer1.${INFRA_ID}" +CURL_IMAGE="curlimages/curl:8.10.1" + +curl_in_net() { + docker run --rm --network "${NETWORK}" "${CURL_IMAGE}" "$@" +} + +echo ">>> toxiproxy-bootstrap: waiting for Toxiproxy admin API at ${TOXI_ADMIN}..." +MAX_WAIT=60 +COUNT=0 +until curl_in_net -fsS -o /dev/null "http://${TOXI_ADMIN}/version"; do + if [ "${COUNT}" -ge "${MAX_WAIT}" ]; then + echo "ERROR: Toxiproxy admin API at ${TOXI_ADMIN} did not become reachable within ${MAX_WAIT}s." + exit 1 + fi + echo -n "." + sleep 2 + COUNT=$((COUNT + 2)) +done +echo " OK" + +mk() { # name listen_port upstream_port + local name="$1" port="$2" upstream_port="$3" + local body="{\"name\":\"${name}\",\"listen\":\"0.0.0.0:${port}\",\"upstream\":\"${UPSTREAM_HOST}:${upstream_port}\",\"enabled\":true}" + + echo -n " - proxy ${name} (0.0.0.0:${port} -> ${UPSTREAM_HOST}:${upstream_port})..." + + # Delete any pre-existing proxy of the same name; tolerate 404 (doesn't exist yet). + curl_in_net -sS -o /dev/null -XDELETE "http://${TOXI_ADMIN}/proxies/${name}" || true + + local http_code + http_code=$(curl_in_net -sS -o /tmp/toxi_create_resp -w '%{http_code}' \ + -XPOST "http://${TOXI_ADMIN}/proxies" -d "${body}" 2>/dev/null || echo "000") + + if [ "${http_code}" = "200" ] || [ "${http_code}" = "201" ] || [ "${http_code}" = "409" ]; then + echo " OK (${http_code})" + else + echo " FAIL (HTTP ${http_code})" + exit 1 + fi +} + +mk pg_primary 6001 16710 +mk pg_replica1 6002 16711 +mk pg_replica2 6003 16712 + +echo ">>> toxiproxy-bootstrap: verifying proxy list..." +curl_in_net -fsS "http://${TOXI_ADMIN}/proxies" +echo +echo ">>> toxiproxy-bootstrap: done." diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql b/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql new file mode 100644 index 0000000000..215290c6bd --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql @@ -0,0 +1,73 @@ +-- ProxySQL PostgreSQL Server Configuration for infra-dbdeployer-pgsql17-repl. +-- +-- Unlike the static-split reference (infra-pgsql17-repl/conf/proxysql/infra-config.sql), +-- this is the AUTOMATIC rw-split path: all three backends start in the WRITER +-- hostgroup and the monitor demotes read-only replicas to the reader hostgroup +-- via pg_is_in_recovery() (pgsql_replication_hostgroups, check_type='read_only'). +-- +-- Backends are reached exclusively through Toxiproxy (bootstrapped by +-- ./bin/toxiproxy-bootstrap.sh): toxiproxy.${INFRA_ID} ports 6001/6002/6003 -> +-- dbdeployer1.${INFRA_ID}:16710/16711/16712 (primary/replica1/replica2). +-- +-- NOTE: this template is eval-expanded by ./bin/docker-proxy-post.bash, which +-- can run either from docker-compose-init.bash (INFRA exported = this infra +-- directory's basename) or from control/ensure-infras.bash's already-running +-- reconfigure path (which does NOT reliably export INFRA -- it is only set +-- from INFRA_TYPE, itself populated later by the group's env.sh). +-- INFRA_ID (the per-test-run instance id, e.g. sdd-sp2) IS reliably present +-- in every invocation path, and docker-compose.yml registers the +-- toxiproxy.${INFRA_ID} / dbdeployer1.${INFRA_ID} network aliases specifically +-- so downstream SP-2 tasks (harness/ProxySQL) resolve the backend -- so this +-- config addresses backends and tags comments via ${INFRA_ID}, not ${INFRA}. +-- +-- CAUTION for future edits: this whole file is run through a shell eval to +-- expand the template placeholders above, so any double quote character or +-- any dollar-sign token in a comment (not just the intended placeholders) +-- gets interpreted by that eval too. Keep comments free of both. +DELETE FROM pgsql_servers WHERE comment LIKE '%${INFRA_ID}%'; + +-- All three backends in the WRITER hostgroup, addressed via Toxiproxy. +INSERT INTO pgsql_servers (hostgroup_id, hostname, port, max_connections, comment) VALUES + (${WHG}, 'toxiproxy.${INFRA_ID}', 6001, 200, 'pg_primary ${INFRA_ID}'), + (${WHG}, 'toxiproxy.${INFRA_ID}', 6002, 200, 'pg_replica1 ${INFRA_ID}'), + (${WHG}, 'toxiproxy.${INFRA_ID}', 6003, 200, 'pg_replica2 ${INFRA_ID}'); + +-- Automatic writer/reader assignment via pg_is_in_recovery(). +DELETE FROM pgsql_replication_hostgroups WHERE writer_hostgroup=${WHG}; +INSERT INTO pgsql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type, comment) + VALUES (${WHG}, ${RHG}, 'read_only', 'pg auto rw-split ${INFRA_ID}'); + +LOAD PGSQL SERVERS TO RUNTIME; -- loads replication_hostgroups too +SAVE PGSQL SERVERS TO DISK; + +DELETE FROM pgsql_users WHERE comment LIKE '%${INFRA_ID}%'; + +-- Superuser row, mirroring the reference infra (postgres/${ROOT_PASSWORD}, +-- the role the entrypoint gives a known password so ProxySQL can log in). +REPLACE INTO pgsql_users (username, password, active, default_hostgroup, comment) VALUES + ('postgres', '${ROOT_PASSWORD}', 1, ${WHG}, '${INFRA_ID}'); +-- Application user. +REPLACE INTO pgsql_users (username, password, active, default_hostgroup, comment) VALUES + ('testuser', 'testuser', 1, ${WHG}, '${INFRA_ID}'); + +LOAD PGSQL USERS TO RUNTIME; +SAVE PGSQL USERS TO DISK; + +-- Read/write split query rules (route SELECTs to the reader HG, SELECT ... FOR +-- UPDATE and everything else stays on the writer HG). +DELETE FROM pgsql_query_rules WHERE destination_hostgroup IN (${WHG}, ${RHG}); +INSERT INTO pgsql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply) VALUES + (${WHG}01, 1, '^SELECT.*FOR UPDATE', ${WHG}, 1), + (${RHG}01, 1, '^SELECT', ${RHG}, 1); +LOAD PGSQL QUERY RULES TO RUNTIME; +SAVE PGSQL QUERY RULES TO DISK; + +-- Enable the monitor (drives the automatic split) against the 'monitor' role +-- provisioned by the entrypoint (monitor/monitor, LOGIN). Shorten the +-- read_only check interval to 1000ms (still within [100, 7*24*3600*1000], +-- see PgSQL_Thread.cpp VariablesPointers_int) so demotion happens fast in tests. +UPDATE global_variables SET variable_value='true' WHERE variable_name='pgsql-monitor_enabled'; +UPDATE global_variables SET variable_value='monitor' WHERE variable_name IN ('pgsql-monitor_username','pgsql-monitor_password'); +UPDATE global_variables SET variable_value='1000' WHERE variable_name='pgsql-monitor_read_only_interval'; +LOAD PGSQL VARIABLES TO RUNTIME; +SAVE PGSQL VARIABLES TO DISK; diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash new file mode 100755 index 0000000000..cc903d8d53 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +set -o pipefail +pushd $(dirname $0) &>/dev/null +trap 'popd &>/dev/null' EXIT +set -a; . .env; set +a +export INFRA=${PWD##*/} +export COMPOSE_PROJECT="${INFRA}-${INFRA_ID}" + +echo "Destroying CI Infra Cluster '${INFRA}' (Project: ${COMPOSE_PROJECT})..." +docker compose -p "${COMPOSE_PROJECT}" down -v diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash new file mode 100755 index 0000000000..b46888cc50 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -0,0 +1,165 @@ +#!/bin/bash +# RELIABLY CAPTURE INFRA_ID FROM ENVIRONMENT OR DIRECTORY NAME +if [ -z "${INFRA_ID}" ]; then + export INFRA_ID=$(basename $(dirname $(pwd)) | sed 's/infra-//; s/docker-//') +fi +# Final safety: if INFRA_ID is still empty or ".", use a default +if [ -z "${INFRA_ID}" ] || [ "${INFRA_ID}" = "." ]; then + export INFRA_ID="dev-$USER" +fi + +# Derive Workspace relative to script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +export WORKSPACE="${REPO_ROOT}" + +set -e +set -o pipefail + +# SUDO helper: empty if root +SUDO="" +if [ "$(id -u)" != "0" ]; then SUDO="sudo"; fi + +# relaunch self with timeout +[[ $(ps -o command= $(ps -o ppid= $$)) =~ timeout ]] || exec timeout -v -s 9 ${TIMEOUT:-600} "${BASH_SOURCE}" "$@" + +# make sure we have correct cwd +pushd $(dirname $0) &>/dev/null +trap 'popd &>/dev/null' EXIT + +# Load .env but ensure INFRA_ID is preserved +if [ ! -f .env ]; then echo "Error: .env not found"; exit 1; fi +SAVED_INFRA_ID="${INFRA_ID}" +set -a; . .env; set +a +export INFRA_ID="${SAVED_INFRA_ID}" + +# Docker Compose version helper - prefer plugin (v2) +COMPOSE_CMD="docker compose" +if ! $COMPOSE_CMD version &>/dev/null; then + COMPOSE_CMD="docker-compose" + if ! $COMPOSE_CMD version &>/dev/null; then + echo "ERROR: Neither 'docker compose' nor 'docker-compose' found!" + exit 1 + fi +fi + +if [ -z "${INFRA_ID}" ]; then echo "Error: INFRA_ID must be set"; exit 1; fi + +export ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10) +export INFRA=${PWD##*/} +export COMPOSE_PROJECT="${INFRA}-${INFRA_ID}" +export INFRA_LOGS_PATH=${INFRA_LOGS_PATH:-${WORKSPACE}/ci_infra_logs} + +echo "================================================================================" +echo "Initializing CI Infra '${INFRA}' (Project: ${COMPOSE_PROJECT}) ..." +echo "================================================================================" + +# 1. VERIFY NO EXISTING CONTAINERS ARE RUNNING FOR THIS PROJECT +if [ -n "$($COMPOSE_CMD -p "${COMPOSE_PROJECT}" ps -q 2>/dev/null)" ]; then + echo "ERROR: Containers for project ${COMPOSE_PROJECT} are already running." + echo "Please run teardown first." + exit 1 +fi + +# 2. Infrastructure-specific preparation (logs/data) +# We extract host paths that appear to be for logs or data. +echo "Scanning for volumes in docker-compose.yml..." +# CRITICAL: Exclude .crt and .key files from auto-mkdir logic to prevent "directory vs file" conflicts +MOUNTED_PATHS=$(grep -E '\$\{INFRA_LOGS_PATH\}|\./log/' docker-compose.yml | grep -vE "\.crt|\.key" | awk -F: '{print $1}' | sed 's/^[[:space:]-]*//' | sort -u || true) + +for RAW_PATH in ${MOUNTED_PATHS}; do + # Skip relative paths that point to config files (e.g. ./conf/...) + if [[ "${RAW_PATH}" == "./conf/"* ]]; then continue; fi + + # Expand variables like ${INFRA_LOGS_PATH} and ${COMPOSE_PROJECT} + eval "ACTUAL_PATH=${RAW_PATH}" + + # Safety: Refuse to proceed if ACTUAL_PATH is a directory and is not empty + if [ -d "${ACTUAL_PATH}" ] && [ "$(ls -A "${ACTUAL_PATH}" 2>/dev/null)" ]; then + echo "ERROR: Directory '${ACTUAL_PATH}' is not empty." + echo "Please run teardown/cleanup first." + exit 1 + fi + + echo "Preparing directory: ${ACTUAL_PATH}" + $SUDO mkdir -p "${ACTUAL_PATH}" + $SUDO chmod -R 777 "${ACTUAL_PATH}" + + # Aggressive postgres fix: UID 999 + if [[ "${ACTUAL_PATH}" == *pgsql* ]] || [[ "${ACTUAL_PATH}" == *pgdb* ]]; then + echo "Applying postgres ownership (999:999) to ${ACTUAL_PATH}" + $SUDO chown -R 999:999 "${ACTUAL_PATH}" + fi +done + +# 3. Create a temporary env file for docker-compose to ensure it sees our variables +ENV_FILE=".env.isolated.${INFRA_ID}" +cat < "${ENV_FILE}" +INFRA_ID=${INFRA_ID} +ROOT_PASSWORD=${ROOT_PASSWORD} +INFRA=${INFRA} +COMPOSE_PROJECT=${COMPOSE_PROJECT} +INFRA_LOGS_PATH=${INFRA_LOGS_PATH} +ENVEOF + +# 4. START CONTAINERS +if ! $COMPOSE_CMD --env-file .env --env-file "${ENV_FILE}" -p "${COMPOSE_PROJECT}" up -d; then + echo "ERROR: Docker Compose failed"; rm -f "${ENV_FILE}"; exit 1 +fi +rm -f "${ENV_FILE}" + +# 5. VERIFY ALL CONTAINERS STARTED SUCCESSFULLY +echo "Verifying container health..." +PROJECT_CONTAINERS=$($COMPOSE_CMD -p "${COMPOSE_PROJECT}" ps --format '{{.Name}}') +for C in ${PROJECT_CONTAINERS}; do + STATE=$(docker inspect -f '{{.State.Running}}' "${C}" 2>/dev/null || echo "false") + if [ "${STATE}" != "true" ]; then + echo -e "\nERROR: Container ${C} failed to start!" + echo ">>> Container Logs:" + docker logs "${C}" | tail -n 50 + exit 1 + fi +done + +if [ -f /.dockerenv ]; then + RUNNER_ID=$(hostname) + docker network connect "${INFRA_ID}_backend" "${RUNNER_ID}" || true +fi + +# 6. Wait for dbdeployer entrypoint to finish PostgreSQL deployment +CONTAINER="${COMPOSE_PROJECT}-dbdeployer1-1" +echo -n "Waiting for dbdeployer to finish deployment..." +MAX_WAIT=120 +COUNT=0 +while ! docker exec "${CONTAINER}" test -f /tmp/dbdeployer_ready 2>/dev/null; do + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT" + echo ">>> Container Logs:" + docker logs "${CONTAINER}" | tail -n 50 + exit 1 + fi + echo -n "." + sleep 2 + COUNT=$((COUNT + 2)) +done +echo " OK" + +# 7. Run post-scripts if they exist +[ -f ./bin/docker-pgsql-post.bash ] && ./bin/docker-pgsql-post.bash + +# 7b. Bootstrap the Toxiproxy passthrough proxies (one per backend node), now +# that the backend is up and verified. Must succeed -- a bootstrap failure +# fails init loudly, since later SP-2 tasks (ProxySQL config, chaos suite) +# depend on these proxies existing. +if [ -f ./bin/toxiproxy-bootstrap.sh ]; then + if ! INFRA_ID="${INFRA_ID}" ./bin/toxiproxy-bootstrap.sh; then + echo "ERROR: toxiproxy-bootstrap.sh failed." + exit 1 + fi +fi + +[ -f ./bin/docker-proxy-post.bash ] && ./bin/docker-proxy-post.bash "$1" + +echo "================================================================================" +echo "Done." +echo "================================================================================" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml new file mode 100644 index 0000000000..d010f3e6fa --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml @@ -0,0 +1,35 @@ +services: + + dbdeployer1: + hostname: dbdeployer1.${INFRA} + image: proxysql/ci-infra:dbdeployer-pgsql17-repl + container_name: ${COMPOSE_PROJECT}-dbdeployer1-1 + environment: + - ROOT_PASSWORD=${ROOT_PASSWORD} + - INFRA=${INFRA} + networks: + backend: + aliases: + - dbdeployer1.${INFRA} + - dbdeployer1.infra-dbdeployer-pgsql17-repl + # env.sh publishes PGCOMPAT_*_HOST=dbdeployer1.${INFRA_ID}; expose that + # name so downstream SP-2 tasks (harness/ProxySQL) resolve the backend. + - dbdeployer1.${INFRA_ID} + + toxiproxy: + hostname: toxiproxy.${INFRA} + image: ghcr.io/shopify/toxiproxy:2.9.0 + container_name: ${COMPOSE_PROJECT}-toxiproxy-1 + command: ["-host", "0.0.0.0"] + networks: + backend: + aliases: + - toxiproxy.${INFRA} + # env.sh publishes PGCOMPAT_TOXI_*_HOST=toxiproxy.${INFRA_ID}; expose + # that name so downstream SP-2 tasks (harness/ProxySQL) resolve it. + - toxiproxy.${INFRA_ID} + +networks: + backend: + name: "${INFRA_ID}_backend" + external: true diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile new file mode 100644 index 0000000000..7438b52efa --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile @@ -0,0 +1,88 @@ +FROM ubuntu:22.04 + +ARG DBDEPLOYER_VERSION=2.2.1 + +# PostgreSQL point release to bake in. Pinned so the dbdeployer-derived ports +# stay fixed at 16710/16711/16712 (ports = 15000 + major*100 + minor, per the +# Task 1 spike). If PGDG retires this exact point release, bump all three of +# PG_VERSION/PG_DEB_VERSION and the ports in .env / env.sh together (the ENV +# PATH below auto-follows PG_VERSION). +ARG PG_MAJOR=17 +ARG PG_VERSION=17.10 +ARG PG_DEB_VERSION=17.10-1.pgdg22.04+1 + +ENV DEBIAN_FRONTEND=noninteractive + +# Base tooling: dbdeployer install (curl|tar), PGDG repo setup (gnupg/wget), +# and su/psql helpers. PostgreSQL runtime shared libraries are installed later +# from the downloaded PGDG .debs (the Debian PG binaries are NOT self-contained). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + gnupg \ + lsb-release \ + && rm -rf /var/lib/apt/lists/* + +# Install dbdeployer (ProxySQL fork release; tarball member is already named +# 'dbdeployer', no rename needed). +RUN curl -fsSL "https://github.com/ProxySQL/dbdeployer/releases/download/v${DBDEPLOYER_VERSION}/dbdeployer-${DBDEPLOYER_VERSION}.linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin/ \ + && chmod +x /usr/local/bin/dbdeployer + +# Add the PGDG apt repo (ubuntu:22.04 = jammy). +RUN install -d /usr/share/postgresql-common/pgdg \ + && wget -q -O /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list + +# Non-root user: initdb / postgres refuse to run as root, so every dbdeployer +# PG command (unpack at build time, deploy at runtime) runs as pguser. +RUN useradd -m -s /bin/bash pguser + +# Download the PGDG .debs (raw packages, NOT a system install of PG) plus all +# their dependency .debs into /root/pgdebs. dbdeployer unpack consumes the two +# server/client .debs directly; the dependency .debs supply the runtime shared +# libraries the Debian PG binaries need (libpq5, libicu70, libllvm15, tzdata, ...). +RUN mkdir -p /root/pgdebs \ + && apt-get update \ + && apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root/pgdebs \ + postgresql-${PG_MAJOR}=${PG_DEB_VERSION} \ + postgresql-client-${PG_MAJOR}=${PG_DEB_VERSION} \ + postgresql-common postgresql-client-common \ + # Install every downloaded dependency .deb (the shared libs + apt housekeeping + # packages) system-wide, but NOT the two main postgresql server/client packages + # -- those must be unpacked by dbdeployer into pguser's home instead. + && DEP_DEBS=$(ls /root/pgdebs/*.deb | grep -vE "/(postgresql-${PG_MAJOR}|postgresql-client-${PG_MAJOR})_[0-9]") \ + && apt-get install -y -qq ${DEP_DEBS} \ + && ldconfig \ + && rm -rf /var/lib/apt/lists/* + +# Copy the two main .debs to a pguser-readable path (/root is mode 700) and +# unpack them via dbdeployer as pguser -> /home/pguser/opt/postgresql//. +RUN mkdir -p /home/pguser/pgdebs \ + && cp /root/pgdebs/postgresql-${PG_MAJOR}_*.deb /home/pguser/pgdebs/ \ + && cp /root/pgdebs/postgresql-client-${PG_MAJOR}_*.deb /home/pguser/pgdebs/ \ + && chown -R pguser:pguser /home/pguser/pgdebs \ + && su - pguser -c "cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql postgresql-${PG_MAJOR}_*.deb postgresql-client-${PG_MAJOR}_*.deb" + +# The Debian PG build's compiled-in sharedir (/usr/share/postgresql/) is +# NOT relocatable; symlink it to the unpacked share tree so initdb finds its +# timezonesets / templates. +RUN mkdir -p /usr/share/postgresql \ + && ln -sf "/home/pguser/opt/postgresql/${PG_VERSION}/share/postgresql/${PG_MAJOR}" \ + "/usr/share/postgresql/${PG_MAJOR}" + +# Make the unpacked psql/pg_isready available on PATH for docker-exec health +# checks. ${PG_VERSION} (build ARG, in scope in this stage) is expanded at build +# time, so the baked ENV holds the concrete versioned path. +ENV PATH="/home/pguser/opt/postgresql/${PG_VERSION}/bin:${PATH}" + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# dbdeployer auto-derives PG ports for 17.10 as 16710 (primary), 16711/16712 (replicas). +EXPOSE 16710 16711 16712 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh new file mode 100755 index 0000000000..3986f2cf13 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE_TAG="${1:-proxysql/ci-infra:dbdeployer-pgsql17-repl}" + +echo "Building Docker image: ${IMAGE_TAG}" +docker build --network=host -t "${IMAGE_TAG}" -f "${SCRIPT_DIR}/Dockerfile" "${SCRIPT_DIR}" +echo "Done: ${IMAGE_TAG}" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh new file mode 100755 index 0000000000..ffeb6c1d90 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh @@ -0,0 +1,200 @@ +#!/bin/bash +set -e +set -o pipefail + +echo "========================================================================" +echo "dbdeployer entrypoint: deploying PostgreSQL 17 streaming replication" +echo " (1 primary + 2 replicas, master-slave topology)" +echo "========================================================================" + +# Passed in via docker-compose environment. (Unlike the MySQL GR reference, +# INFRA is not needed here: PG has no report_host equivalent and no per-infra +# role is provisioned.) +ROOT_PASSWORD="${ROOT_PASSWORD:-default_password}" + +# --------------------------------------------------------------------------- +# 1. Detect the pre-baked PostgreSQL version (unpacked at image-build time). +# --------------------------------------------------------------------------- +PG_VERSION=$(ls /home/pguser/opt/postgresql/ 2>/dev/null | head -1) +if [ -z "${PG_VERSION}" ]; then + echo "ERROR: No unpacked PostgreSQL found in /home/pguser/opt/postgresql/" + exit 1 +fi +echo "Using PostgreSQL version: ${PG_VERSION}" + +# --------------------------------------------------------------------------- +# 2. Deploy the replication sandbox as pguser (initdb refuses to run as root). +# --base-port / --bind-address / -c are silently ignored by the postgresql +# provider (spike §5); ports are auto-derived to 16710/16711/16712 for 17.10. +# --------------------------------------------------------------------------- +su - pguser -c "dbdeployer deploy replication ${PG_VERSION} --provider=postgresql --topology=master-slave --nodes=3" + +# Locate the sandbox tree (dbdeployer sandboxes command is unreliable for PG; +# enumerate the directory instead -- spike §6). +SBASE=$(ls -d /home/pguser/sandboxes/postgresql_repl_* 2>/dev/null | head -1) +if [ -z "${SBASE}" ]; then + echo "ERROR: PostgreSQL replication sandbox directory not found" + exit 1 +fi +echo "Sandbox directory: ${SBASE}" + +# Derive the port map from the sandbox dir name (postgresql_repl_). +BASE_PORT="$(basename "${SBASE}")" +BASE_PORT="${BASE_PORT##*_}" +PRIMARY_PORT="${BASE_PORT}" +REPLICA1_PORT="$((BASE_PORT + 1))" +REPLICA2_PORT="$((BASE_PORT + 2))" +echo "Ports -> primary=${PRIMARY_PORT} replica1=${REPLICA1_PORT} replica2=${REPLICA2_PORT}" + +# --------------------------------------------------------------------------- +# 3. Post-deploy config injection (spike §5): the provider ignores -c flags, so +# append overrides directly to each node's postgresql.conf, widen pg_hba.conf +# for cross-container access, then restart each node. +# --------------------------------------------------------------------------- +echo "Injecting postgresql.conf / pg_hba.conf overrides on all 3 nodes..." +for n in primary replica1 replica2; do + echo " - stopping ${n}" + su - pguser -c "'${SBASE}/${n}/stop'" + + cat >> "${SBASE}/${n}/data/postgresql.conf" <= 10), so real password auth is exercised for app roles. + # Replication connections keep 'trust' (replicas stream as postgres with no + # password via primary_conninfo) so setting the postgres password below does + # not break streaming. + cat >> "${SBASE}/${n}/data/pg_hba.conf" </dev/null 2>&1; do + if [ $COUNT -ge $MAX_WAIT ]; then echo " TIMEOUT"; exit 1; fi + echo -n "."; sleep 1; COUNT=$((COUNT + 1)) + done + echo " OK" +done + +# --------------------------------------------------------------------------- +# 5. Wait for streaming replication to be established. +# primary: pg_is_in_recovery()=f AND 2 streaming walsenders +# replicas: pg_is_in_recovery()=t +# --------------------------------------------------------------------------- +echo -n "Waiting for replication to stream..." +MAX_WAIT=60; COUNT=0 +while true; do + PRIM_REC=$(psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + R1_REC=$(psql -h 127.0.0.1 -p "${REPLICA1_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + R2_REC=$(psql -h 127.0.0.1 -p "${REPLICA2_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + STREAMING=$(psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d postgres -tAc \ + "SELECT count(*) FROM pg_stat_replication WHERE state='streaming';" 2>/dev/null || echo "0") + if [ "${PRIM_REC}" = "f" ] && [ "${R1_REC}" = "t" ] && [ "${R2_REC}" = "t" ] && [ "${STREAMING}" = "2" ]; then + echo " OK (primary=f, replicas=t, ${STREAMING} streaming)" + break + fi + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT (primary=${PRIM_REC} r1=${R1_REC} r2=${R2_REC} streaming=${STREAMING})" + exit 1 + fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) +done + +# --------------------------------------------------------------------------- +# 6. Provision roles / databases / extension on the PRIMARY only. +# Physical streaming replication propagates all of this byte-for-byte to the +# replicas (spike §8; brief: "creating on primary is sufficient"). +# --------------------------------------------------------------------------- +echo "Provisioning roles / databases / pg_stat_statements on the primary..." +${PSQL_PRIMARY} -v ON_ERROR_STOP=1 <= 0 FROM pg_stat_statements;" 2>/dev/null || echo "f") + R2_OK=$(psql -h 127.0.0.1 -p "${REPLICA2_PORT}" -U postgres -d postgres -tAc \ + "SELECT count(*) >= 0 FROM pg_stat_statements;" 2>/dev/null || echo "f") + if [ "${R1_OK}" = "t" ] && [ "${R2_OK}" = "t" ]; then echo " OK"; break; fi + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT (replica1=${R1_OK} replica2=${R2_OK})"; exit 1 + fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) +done + +# --------------------------------------------------------------------------- +# 8. Signal readiness (consumed by docker-compose-init.bash). +# --------------------------------------------------------------------------- +touch /tmp/dbdeployer_ready + +echo "========================================================================" +echo "dbdeployer PostgreSQL 17 replication is ready." +echo " primary : port ${PRIMARY_PORT} (pg_is_in_recovery = f)" +echo " replica1 : port ${REPLICA1_PORT} (pg_is_in_recovery = t)" +echo " replica2 : port ${REPLICA2_PORT} (pg_is_in_recovery = t)" +echo " roles : testuser/testuser (LOGIN CREATEDB), monitor/monitor," +echo " postgres/" +echo " pg_stat_statements: preloaded + created in postgres + testuser DBs" +echo "========================================================================" + +# Keep the container alive. +exec sleep infinity diff --git a/test/pg-compat/.dockerignore b/test/pg-compat/.dockerignore new file mode 100644 index 0000000000..81d48988e7 --- /dev/null +++ b/test/pg-compat/.dockerignore @@ -0,0 +1,7 @@ +# Keep dependency trees and generated Prisma artifacts out of the build +# context: every driver's node_modules (and Prisma's generated client) are +# produced INSIDE their multi-stage build steps, so shipping a host-built +# copy in via `COPY . .` would bloat the image and risk a wrong-platform +# query-engine binary. Report output is host-only too. +**/node_modules +pg-compat-reports diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile new file mode 100644 index 0000000000..233ef704ca --- /dev/null +++ b/test/pg-compat/Dockerfile @@ -0,0 +1,122 @@ +# ---- Go builder: static behavior binary (no runtime needed in final) ---- +FROM golang:1.23-bookworm AS gobuild +WORKDIR /src +COPY drivers/go/ . +RUN CGO_ENABLED=0 go build -o /out/behaviors-go . + +# ---- Java builder: compile against a pinned pgjdbc jar ---- +FROM eclipse-temurin:21-jdk AS javabuild +WORKDIR /src +# Pin the driver version explicitly; record bumps in README's driver table. +# PGJDBC_SHA256 must be updated together with PGJDBC_VERSION: verifying the +# digest turns a silent substitution of the downloaded jar into a build failure. +# (Maven Central publishes .sha1 but not .sha256 for this artifact, so the value +# below is the SHA-256 of the jar whose SHA-1 matches the published digest.) +ARG PGJDBC_VERSION=42.7.4 +ARG PGJDBC_SHA256=188976721ead8e8627eb6d8389d500dccc0c9bebd885268a3047180274a6031e +# --proto/--proto-redir '=https': -L follows redirects, and without these a +# redirect could downgrade the transfer to plaintext HTTP. +RUN curl -fsSL --proto '=https' --proto-redir '=https' \ + -o /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" \ + && echo "${PGJDBC_SHA256} /pgjdbc.jar" | sha256sum -c - +COPY drivers/java/Behaviors.java . +RUN javac --release 17 -cp /pgjdbc.jar Behaviors.java -d /out + +# ---- Node deps: install node-postgres against the lockfile ---- +FROM node:22-bookworm-slim AS nodebuild +WORKDIR /app +COPY drivers/node/package.json drivers/node/package-lock.json* ./ +# No `|| npm install` fallback: package-lock.json is committed, so `npm ci` +# always succeeds, and the fallback only ever fired when the lockfile was +# missing -- resolving fresh, unlocked versions precisely when reproducibility +# mattered most. Failing loudly is the correct behaviour there. +# --ignore-scripts: `pg` needs no lifecycle scripts, so nothing from the +# dependency tree gets to execute at install time. +RUN npm ci --omit=dev --ignore-scripts +COPY drivers/node/behaviors.js . + +# ---- Prisma deps + client generation (ORM tier, SP3-Task 5) ---- +# npm ci pulls prisma (CLI, a devDependency) AND @prisma/client, then +# `prisma generate` produces the client + downloads the query-engine binary +# for binaryTargets=["debian-openssl-3.0.x"] (matching the bookworm/OpenSSL-3 +# final image). This stage is bookworm/OpenSSL-3 too, so the engine it +# fetches is the exact one the final stage runs. The engine download needs +# network egress -> the build runs with --network=host (see +# run-pg-compat.bash). PGCOMPAT_PRISMA_URL only has to EXIST for `generate` +# (it does not connect); behaviors.mjs overwrites it at runtime. +FROM node:22-bookworm-slim AS prismabuild +WORKDIR /app +COPY drivers/prisma/package.json drivers/prisma/package-lock.json* ./ +# devDependencies (the prisma CLI) are REQUIRED here for `prisma generate`, +# so this is a full install, not --omit=dev. No `|| npm install` fallback -- +# package-lock.json is committed (see the node stage for the rationale). +# --ignore-scripts is safe here even though @prisma/client's postinstall +# normally runs `prisma generate`: the explicit `prisma generate` below does +# that job, so the only effect is that no dependency executes code at install +# time. +RUN npm ci --ignore-scripts +COPY drivers/prisma/schema.prisma drivers/prisma/behaviors.mjs ./ +ENV PGCOMPAT_PRISMA_URL="postgresql://build:build@localhost:5432/build?sslmode=disable" +# --no-install: run ONLY the prisma CLI already pinned by package-lock.json. +# Bare `npx` silently fetches an unpinned package from the registry when the +# binary is missing locally, which would defeat the lockfile. +RUN npx --no-install prisma generate + +# ---- Final: python base + JRE + node runtime + artifacts ---- +FROM python:3.11-slim +# openssl/libssl3: the Prisma query engine (debian-openssl-3.0.x) links +# against libssl.so.3 / libcrypto.so.3 at load time. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 curl default-jre-headless openssl \ + && rm -rf /var/lib/apt/lists/* +# Node runtime copied from the official image (bookworm-glibc compatible). +COPY --from=nodebuild /usr/local/bin/node /usr/local/bin/node +WORKDIR /pg-compat +COPY requirements.txt . +# --only-binary :all: installs from wheels exclusively, so no package gets to +# run a setup.py at install time. --require-hashes then makes the install abort +# unless every downloaded artifact -- direct and transitive -- matches a SHA-256 +# recorded in requirements.txt, which is fully locked for that purpose. +RUN pip install --no-cache-dir --only-binary :all: --require-hashes -r requirements.txt +# Copy exactly what the suite needs rather than `COPY . .`, which would sweep +# in the build context wholesale -- README/SPIKE notes, the driver sources +# already compiled into the earlier stages, run-pg-compat.bash (a host-side +# script), local __pycache__, and anything untracked that happens to be +# sitting in the directory. +COPY conftest.py pytest.ini xfail.toml ./ +COPY harness/ ./harness/ +COPY behaviors/ ./behaviors/ +COPY cases/ ./cases/ +COPY tests/ ./tests/ +# drivers/: only the Python tier is needed at runtime -- tests/test_behaviors.py +# imports drivers.python.adapter in-process, whereas the go/java/node/prisma +# tiers are compiled or installed in the earlier stages and arrive via their own +# COPY --from lines below. drivers/__init__.py is required for the package import. +COPY drivers/__init__.py ./drivers/ +COPY drivers/python/ ./drivers/python/ +# Language artifacts under /pg-compat/bin with uniform CLI wrappers. +COPY --from=gobuild /out/behaviors-go /pg-compat/bin/behaviors-go +COPY --from=javabuild /out/ /pg-compat/bin/java-classes/ +COPY --from=javabuild /pgjdbc.jar /pg-compat/bin/pgjdbc.jar +COPY --from=nodebuild /app /pg-compat/node-app +# Prisma app: node_modules (with the generated @prisma/client + .prisma +# client + query-engine binary), schema.prisma, behaviors.mjs. +# Known size trade-off: this copies the whole prismabuild dev tree, +# including the prisma CLI devDependency and its non-query engines +# (schema/format engines), not just what behaviors.mjs needs at runtime -- +# simple and correct over minimal. Task 6's image-size measurement accounts +# for it; prune here if the numbers demand it. +COPY --from=prismabuild /app /pg-compat/prisma-app +RUN printf '#!/bin/sh\nexec java -cp /pg-compat/bin/java-classes:/pg-compat/bin/pgjdbc.jar Behaviors "$@"\n' > /pg-compat/bin/behaviors-java \ + && printf '#!/bin/sh\nexec node /pg-compat/node-app/behaviors.js "$@"\n' > /pg-compat/bin/behaviors-node \ + && printf '#!/bin/sh\nexec node /pg-compat/prisma-app/behaviors.mjs "$@"\n' > /pg-compat/bin/behaviors-prisma \ + && chmod +x /pg-compat/bin/behaviors-* +# Drop privileges: nothing here needs root at runtime. This suite exists to +# execute third-party driver code (pgx, pgjdbc, node-postgres, Prisma) against +# a live backend, so running it unprivileged bounds what a compromised +# dependency can reach. /pg-compat is chowned because pytest writes +# .pytest_cache and __pycache__ into the rootdir. +RUN useradd --create-home --uid 10001 pgcompat \ + && chown -R pgcompat:pgcompat /pg-compat +USER pgcompat +ENTRYPOINT ["pytest", "-q"] diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md new file mode 100644 index 0000000000..861b243436 --- /dev/null +++ b/test/pg-compat/README.md @@ -0,0 +1,251 @@ +# pg-compat + +Polyglot PostgreSQL-protocol compatibility suite for ProxySQL (SP-2). It +drives real PG client drivers (psycopg, asyncpg) against the ProxySQL PG +frontend and cross-checks/configures behavior via the ProxySQL admin +interface, running against the `infra-dbdeployer-pgsql17-repl` backend +(one primary + two replicas, automatic RW-split, fronted by Toxiproxy). + +This is a **discovery-phase, non-gating** suite (see the "Global +Constraints" / §2.1 framing in the plan below): its first job is to build a +failure inventory, not to be all-green. Known divergences are recorded as +`xfail` entries in `xfail.toml` (see "xfail / finding catalogue" below) +rather than by loosening assertions. + +## Running + +The suite runs **inside a container** joined to the infra's Docker network +(no ProxySQL/backend ports are published to the host — see Global +Constraints). Never start containers or networks by hand; always go +through `test/infra/control/*`. + +```bash +# 1. Bring up (or reuse) the infra + ProxySQL for this TAP group. +WORKSPACE=$(pwd) INFRA_ID= TAP_GROUP=pg-compat test/infra/control/ensure-infras.bash + +# 2. Build the pg-compat image and run pytest in a container on the infra network. +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash # full suite +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash tests/test_smoke.py # one file +``` + +Extra arguments after the script name are forwarded verbatim to `pytest` +(e.g. `-k`, a specific test file, `-v`). + +If ProxySQL was rebuilt, re-run +`test/infra/control/start-proxysql-isolated.bash` to pick up the new binary +(it only restarts the ProxySQL container, leaving backends up). + +### Report output (`--junitxml` and friends) + +`run-pg-compat.bash`'s container runs with `--rm`, so anything pytest writes +to its own filesystem is destroyed the moment the container exits. The +script bind-mounts a host directory to `/pg-compat-reports` inside the +container (default `${WORKSPACE}/pg-compat-reports`, override with +`PGCOMPAT_REPORT_DIR`) so report files survive. Write reports there, e.g.: + +```bash +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash \ + --junitxml=/pg-compat-reports/pg-compat.xml -rxX +# report lands at: ${WORKSPACE}/pg-compat-reports/pg-compat.xml +``` + +## Driver matrix (SP-3) + +Beyond the reference Python/psycopg3 harness, the suite runs the same +4-behavior contract (`connect`, `transactions`, `prepared`, +`session_isolation` — see `behaviors/*.py`, the FROZEN cross-driver +contract) through four more real-world driver stacks, each its own +self-contained CLI program compiled/installed into the pg-compat image by +the multi-stage `Dockerfile` (`drivers//`). All five stacks pass the +full behavior contract through ProxySQL with **zero `xfail.toml` entries +added** — every pass below is a genuine pass, not a catalogued divergence. + +| Language | Driver | Version | Placeholders | Prepared-statement strategy | Encoding pin | +|---|---|---|---|---|---| +| Python | psycopg3 | 3.2.* | `%s` (client-side) | auto-prepare after `prepare_threshold=5` (driver default) | DSN `client_encoding=UTF8` | +| Go | pgx | v5.7.5 | `$1, $2` | default `QueryExecMode=cache_statement` — Parse once per distinct SQL text (extended protocol), then Bind/Execute-only on every subsequent call via the server-side statement cache | DSN param `client_encoding=UTF8` | +| Java | pgjdbc | 42.7.4 | `?` | server-side NAMED statement after `prepareThreshold=5` (driver default); one `PreparedStatement` object reused for all 50 iterations | `options=-c client_encoding=UTF8` connection property | +| Node | pg (node-postgres) | 8.13.1 | `$1, $2` | UNCONDITIONAL named statements — `Parse` sent once at iteration 0 via `{name, text, values}`, every later call is `Bind`/`Execute` only | `client_encoding` config key | +| Node | Prisma | 5.22.0 | tagged-template (`$queryRaw`) | always-prepared — the Rust query engine has no simple-query mode; every `$queryRaw`/`$executeRawUnsafe` call is a real Parse/Bind/Execute; `connection_limit=1` pins the client to one backend connection | URL param `client_encoding` is accepted but IGNORED by the Rust engine (verified: `LATIN1` in the URL still yields UTF8) — the factory issues an explicit `SET client_encoding TO 'UTF8'` instead | + +**Headline finding:** four distinct prepared-statement strategies — including +pgjdbc's server-side NAMED statements (the classic connection-pooler +breaker: `prepared statement "S_1" does not exist`) and Prisma's +always-prepared Rust engine — all stay transparent through ProxySQL's +connection multiplexing. + +**Prisma caveat:** the Prisma behavior program (`drivers/prisma/behaviors.mjs`) +exercises only the **raw-query API** (`$queryRaw`/`$executeRawUnsafe`/ +`$transaction`), not Prisma's model/ORM query path (`prisma.model.findMany()` +etc.) — there are no real models in `schema.prisma` (a single unused dummy +model exists only to satisfy `prisma generate`). The ORM query path is a +possible future extension, not covered here. + +### Running one language + +Extra arguments to `run-pg-compat.bash` are forwarded to `pytest`, so a +single language's wrapper file (or `-k`) selects just that driver, e.g.: + +```bash +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash tests/test_behaviors_go.py -v +``` + +Per-language wrapper files: `tests/test_behaviors_go.py`, +`tests/test_behaviors_java.py`, `tests/test_behaviors_node.py`, +`tests/test_behaviors_prisma.py` (Python's own behaviors run via +`tests/test_behaviors.py`, in-process rather than as a subprocess). + +### Behavior-CLI contract + +Every language ships ONE compiled/installed binary at +`/pg-compat/bin/behaviors-` implementing the same CLI: + +``` +behaviors- # ∈ {connect, transactions, prepared, session_isolation} +``` + +- **exit 0** — behavior passed. +- **exit 1** — behavior assertion failed; a human-readable reason on stderr. +- **exit 2** — usage or infra error (unknown behavior name, not-yet-implemented + behavior, missing/invalid env); never a behavior-contract failure. +- No stdout output is required on pass. + +`tests/_subproc.py::run_behavior(program, behavior)` runs +`[program, behavior]`, translates exit 0/1/2 into pytest pass/fail, and +`pytest.skip`s if the binary is absent from the image (so a partial image +still runs the languages it does have). + +### Adding a language + +1. Implement the 4 behaviors (`connect`, `transactions`, `prepared`, + `session_isolation`) against `behaviors/*.py` as the frozen reference — + same assertions, same trap adaptations (session-isolation probe is + `SET TimeZone = 'Antarctica/Troll'` / `SHOW TimeZone`, **never** + `application_name` — it's in ProxySQL's `ignore_vars`; every transaction + verification read runs inside its own `BEGIN`/`COMMIT` so it pins to the + writer instead of racing replica lag; every connection pins + `client_encoding=UTF8`; placeholders are driver-native, not psycopg's `%s`). +2. Expose them behind the uniform CLI contract above, in its own + `drivers//` directory. Use a per-language table name for the + transactions behavior (`behavior_tx_t_`) so runs never collide + with another language's. +3. Add a build stage to `Dockerfile` that produces + `/pg-compat/bin/behaviors-` (a compiled binary, or a thin shell + wrapper invoking an interpreter — see the Java/Node stages for both + patterns) and pin the exact driver version in both the Dockerfile + (`ARG`/lockfile) and this table. +4. Add `tests/test_behaviors_.py` — a thin subprocess wrapper using + `tests/_subproc.py::run_behavior` and + `@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS)` so + nodeids stay stable (`tests/test_behaviors_.py::test_behavior_[]`) + for the `xfail.toml` exact-nodeid catalogue. +5. A behavior that genuinely fails through ProxySQL is a FINDING, not a bug + to hide — add an `[[xfail]]` entry (or a `[[finding]]` if nothing fails + but a divergence was neutralized), never weaken the assertion. + +## CI + +The suite is wired into CI as `CI-pg-compat` (`.github/workflows/CI-pg-compat.yml` +caller on `v3.0` + `ci-pg-compat.yml` reusable on `GH-Actions`, per the +two-branch split in `doc/GH-Actions/README.md`). Unlike the TAP families it +does not chain off `CI-trigger`/`CI-builds`; it builds ProxySQL inline +(`PROXYSQL31=1 make debug`), like the `CI-3p-*` family, since its triggers +have no guaranteed prior `CI-builds` cache to restore from. + +- **Triggers:** nightly at 03:00 UTC (`schedule`), any `pull_request` that + carries the `pg-compat` label, and manual `workflow_dispatch`. +- **Status: non-gating.** Per the discovery-phase framing above, the run + step uses `|| true` so a real/uncatalogued divergence does not fail the + workflow. Promote to gating (drop `|| true`, tighten `xfail.toml`) once + the suite is green and stable. +- **Artifact:** the junitxml report is uploaded as `pg-compat-report` on + every run (`if: always()`), whether the underlying pytest run passed, + xfailed, or hit real failures. + +## Env contract + +Populated by `test/tap/groups/pg-compat/env.sh` (sourced by +`run-pg-compat.bash`) plus two pairs set by `run-pg-compat.bash` itself: + +| Variable | Meaning | +|---|---| +| `PGCOMPAT_PRIMARY_HOST` / `_PORT` | dbdeployer PG primary (single container, per-node port) | +| `PGCOMPAT_REPLICA1_HOST` / `_PORT` | dbdeployer replica 1 | +| `PGCOMPAT_REPLICA2_HOST` / `_PORT` | dbdeployer replica 2 | +| `PGCOMPAT_TOXI_ADMIN` | Toxiproxy admin API (`host:port`) | +| `PGCOMPAT_TOXI_PRIMARY_HOST` / `_PORT` | Toxiproxy listener in front of the primary | +| `PGCOMPAT_TOXI_REPLICA1_HOST` / `_PORT` | Toxiproxy listener in front of replica 1 | +| `PGCOMPAT_TOXI_REPLICA2_HOST` / `_PORT` | Toxiproxy listener in front of replica 2 | +| `PGCOMPAT_PROXY_HOST` / `_PORT` | ProxySQL PG frontend (default `proxysql:6133`); user `testuser`/`testuser`, db `testuser` | +| `PGCOMPAT_ADMIN_HOST` / `_PORT` | ProxySQL admin over the PG protocol (default `proxysql:6132`) | + +There is intentionally **no** single `PGCOMPAT_BACKEND_PORT` — each node has +its own host/port pair because all three nodes are one dbdeployer +container. `run-pg-compat.bash` forwards every `PGCOMPAT_*` variable +currently in the environment into the container, so new variables added to +`env.sh` are picked up automatically. + +### Admin credentials + +The admin config primitive (`harness/proxysql.py::Admin`) connects as +`radmin`/`radmin`, not `admin`/`admin`. ProxySQL's PG-protocol admin +interface rejects the literal username `admin` from any non-loopback peer +("User 'admin' can only connect locally"); `radmin` carries the same admin +privileges without that restriction and is intended for exactly this kind +of remote/containerized use. See the docstring in `harness/proxysql.py` for +the empirical verification. + +## xfail / finding catalogue + +`xfail.toml` is the suite's living failure inventory (spec §2.1). It has two +independent sections — see the loader in `harness/xfail.py` and the header +comment in `xfail.toml` itself for the authoritative format: + +- **`[[xfail]]`** — a known divergence that currently makes one specific + test fail. Each entry maps a pytest `test_id` (`item.nodeid`, e.g. + `tests/test_differential.py::test_case_is_transparent[002_bytea_json_array.sql]`) + to `mode` (`libpq` | `native` | `both`), a human `reason`, and a tracking + `ref` (issue/PR). `conftest.py`'s `pytest_collection_modifyitems` hook + applies `pytest.mark.xfail(reason=..., strict=False)` to every listed + `test_id` at collection time. Because `strict=False`: + - the test still runs every time, so nothing is silently skipped; + - while the divergence persists, it reports `xfailed` — an *expected* + failure — instead of `failed`, so the suite stays green without + loosening any assertion; + - once the underlying bug is fixed, the *same* test starts passing and is + reported `xpassed` (visible with `-rxX`, on by default via `pytest.ini`'s + `addopts = -rxX`). An `xpassed` report is the signal to delete that + entry from `xfail.toml` — the fix landed. Do not "fix" an xpass by + flipping to `strict=True`; the point is that closing a divergence is + detected by the run itself, not by someone remembering to edit the toml. + - **To add an entry:** find (or intentionally reproduce) the failing + `test_id`, add a `[[xfail]]` block with `test_id`/`mode`/`reason`/`ref`, + re-run — it should now report `xfailed` rather than `failed`. +- **`[[finding]]`** — a real, verified ProxySQL behavioral difference + discovered while building this suite that does **not** currently fail any + test (typically because the harness neutralizes it, e.g. by pinning a + session parameter so a differential comparison stays apples-to-apples). + It has no `test_id` to attach an xfail marker to, so it's recorded here + instead purely so the finding isn't lost. Fields: `summary`, `mode`, + `detail`, `discovered_by`, `ref`. `harness/xfail.py::findings()` exposes + these for tooling; nothing in `conftest.py` acts on them automatically. + Today's one entry: ProxySQL imposes `client_encoding=UTF8` on backend + connections instead of inheriting the server default (discovered against + the SQL_ASCII dbdeployer backend in Task 6; see `xfail.toml` for detail). + +## Layout + +- `harness/proxysql.py` — `Admin` class: `query`, `set_var`, `load_vars`, + `snapshot`, `restore` — the read/modify/LOAD/verify/restore cycle used by + every test that flips a ProxySQL runtime variable. +- `harness/xfail.py` — `load()` / `findings()`: loaders for the two sections + of `xfail.toml` (see "xfail / finding catalogue" above). +- `conftest.py` — `admin` (session-scoped `Admin`) and `proxy_conn` + (function-scoped psycopg connection to the ProxySQL PG frontend) + fixtures shared by all tests, plus the `pytest_collection_modifyitems` + hook that applies xfail markers from `xfail.toml`. +- `tests/` — the test suite (`test_smoke.py`, `test_behaviors.py`, + `test_differential.py`, `test_differential_selfcheck.py`, + `test_routing_oracle.py` today). +- `SPIKE-dbdeployer-pg.md` — prior spike notes on the dbdeployer PG infra; + left as-is. diff --git a/test/pg-compat/SPIKE-dbdeployer-pg.md b/test/pg-compat/SPIKE-dbdeployer-pg.md new file mode 100644 index 0000000000..37b19d5879 --- /dev/null +++ b/test/pg-compat/SPIKE-dbdeployer-pg.md @@ -0,0 +1,398 @@ +# SPIKE: dbdeployer PostgreSQL deploy command (Task 1) + +Status: **CONFIRMED WORKING** — the pinned ProxySQL fork of dbdeployer (v2.2.1) can deploy a +1-primary + 2-replica PostgreSQL 17 streaming-replication sandbox. Task 2a (dbdeployer path) is +viable. This doc records the exact commands, ports, and — critically — several undocumented +gaps/limitations in this fork's PostgreSQL support that Task 2 must work around. + +All experiments were run inside a single throwaway `ubuntu:22.04` container +(`docker run -d --name sdd-sp2-spike --network=host ubuntu:22.04 sleep infinity`, removed at the +end via `docker rm -f sdd-sp2-spike`). Nothing was installed on the host. + +## 1. dbdeployer version + install method (verbatim, matches the MySQL GR reference Dockerfile) + +```bash +curl -fsSL "https://github.com/ProxySQL/dbdeployer/releases/download/v2.2.1/dbdeployer-2.2.1.linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin/ +chmod +x /usr/local/bin/dbdeployer +``` + +`dbdeployer --version` → `dbdeployer version 2.2.1`. (No rename/symlink needed — the tarball's +member is already named `dbdeployer`, unlike the brief's assumption of a +`dbdeployer-2.2.1.linux_amd64` binary name.) + +`dbdeployer deploy --help` lists a **`postgresql` subcommand** ("deploys a PostgreSQL sandbox") +alongside `single`/`multiple`/`replication`/`proxysql`, and `deploy replication`/`deploy single` +both expose a `--provider string` flag ("Database provider (mysql, postgresql)", default +`mysql`). This is the confirmed PG support surface. + +## 2. PostgreSQL is NOT in `dbdeployer downloads list` — use PGDG `.deb`s instead + +This is the biggest divergence from the brief's assumed flow. **`dbdeployer downloads list` +(with `--OS=all`, or `--flavor=postgresql|pgsql|postgres`) returns zero PostgreSQL entries** — +the fork's built-in remote tarball index only carries MySQL/Percona/MariaDB tarballs. There is +no `dbdeployer downloads get-unpack ` to run. + +Instead, `dbdeployer deploy postgresql --help` states the actual prerequisite: + +``` +Requires PostgreSQL binaries to be extracted first: + dbdeployer unpack --provider=postgresql postgresql-16_*.deb postgresql-client-16_*.deb +``` + +i.e. dbdeployer expects **official PGDG `.deb` packages** (not `.tar.gz`), fed to +`dbdeployer unpack --provider=postgresql`, which extracts their file trees into +`$HOME/opt/postgresql//` (note: separate from `$HOME/opt/mysql`, and NOT affected by +`--sandbox-binary`). + +### Exact commands used (PG 17.10, the newest available for jammy/22.04 in PGDG at spike time) + +```bash +# Add the PGDG apt repo (ubuntu:22.04 = jammy) +apt-get install -y -qq gnupg lsb-release wget +install -d /usr/share/postgresql-common/pgdg +wget -q -O /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc +echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \ + https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list +apt-get update -qq + +# Fetch the .deb files themselves (do NOT `apt-get install` the PG packages system-wide; +# dbdeployer wants the raw .deb to extract itself) +mkdir -p /root/pgdebs && cd /root/pgdebs +apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root/pgdebs \ + postgresql-17 postgresql-client-17 postgresql-common postgresql-client-common + +# Exact tarball(deb) names used: +# postgresql-17_17.10-1.pgdg22.04+1_amd64.deb +# postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb + +# IMPORTANT: the unpack must run as a NON-ROOT user (see §4), and /root is mode 700 — +# pguser cannot traverse into /root/pgdebs at all ("Permission denied"). Copy the debs +# to a pguser-owned location first. Exact commands used in this spike (run as root): +useradd -m -s /bin/bash pguser # the non-root user from §4 (create it first) +mkdir -p /home/pguser/pgdebs +cp /root/pgdebs/*.deb /home/pguser/pgdebs/ +chown -R pguser:pguser /home/pguser/pgdebs +# (Alternative: download straight to a world-readable dir, e.g. +# -o Dir::Cache::archives=/tmp/pgdebs above, and chown that — either way the .debs +# must end up readable at a path pguser can reach.) + +# Unpack via dbdeployer, as pguser (see §4) +su - pguser -c 'cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql \ + postgresql-17_17.10-1.pgdg22.04+1_amd64.deb \ + postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' +# => "PostgreSQL 17.10 unpacked to /home/pguser/opt/postgresql/17.10" +``` + +PG version deployed: **17.10** (PGDG's latest 17.x for jammy at spike time 2026-07-08; 17.8/17.9 +were also available — pin whichever exact point release Task 2 wants, all worked identically). + +## 3. Runtime shared libraries required (Debian PG binaries are not self-contained) + +Unlike the MySQL/Percona/MariaDB tarballs dbdeployer normally consumes (statically-ish bundled, +relocatable), **the PGDG `.deb` payload only contains PostgreSQL's own files** — it does NOT +bundle its shared-library dependencies. Running the unpacked binaries requires these installed +system-wide first (else `initdb`/`postgres` fail with `error while loading shared libraries`): + +```bash +apt-get install -y -qq /root/pgdebs/libpq5_*.deb /root/pgdebs/libicu70_*.deb \ + /root/pgdebs/libxml2_*.deb /root/pgdebs/libxslt1.1_*.deb /root/pgdebs/libllvm15_*.deb \ + /root/pgdebs/libedit2_*.deb /root/pgdebs/libbsd0_*.deb /root/pgdebs/libmd0_*.deb \ + /root/pgdebs/tzdata_*.deb +ldconfig +``` + +**Untested alternative worth trying in Task 2** (simpler, not verified in this spike): instead of +hand-picking these libs, just `apt-get install -y postgresql-17 postgresql-client-17` normally +(full system install, which pulls in all deps AND places files at the standard `/usr/share/postgresql/17` +path — see next point), then *separately* `apt-get download` a second copy of the same `.deb`s to +feed to `dbdeployer unpack`. This would likely avoid the manual lib list above and the symlink +workaround below. Flagging as a recommended experiment for Task 2, not a confirmed fact. + +### `pg_config --sharedir` is a hardcoded absolute path, not relocatable + +The Debian PG build reports (`pg_config --sharedir --bindir --pkglibdir`): +``` +/usr/share/postgresql/17 <- WRONG once relocated by dbdeployer unpack; hardcoded, not relative +$HOME/opt/postgresql/17.10/bin <- correct, tracks the actual unpack location +$HOME/opt/postgresql/17.10/lib <- correct +``` +`initdb` fails (`could not open directory "/usr/share/postgresql/17/timezonesets"`) because the +binary looks for its share files at the compiled-in absolute path, which doesn't exist once the +tree is copied to `$HOME/opt/postgresql/17.10`. **Workaround used and confirmed working:** + +```bash +mkdir -p /usr/share/postgresql +ln -sf "$HOME/opt/postgresql/17.10/share/postgresql/17" /usr/share/postgresql/17 +``` + +(System `/usr/share/zoneinfo` must also exist — i.e. the `tzdata` package must be installed; +it was in the runtime-lib list above.) + +## 4. initdb refuses to run as root — must run dbdeployer as a non-root user + +`dbdeployer deploy postgresql`/`deploy replication --provider=postgresql` shell out to `initdb`, +which **hard-refuses to run as root** (`initdb: error: cannot be run as root` — no override flag +exists; this is a PostgreSQL security feature, not a dbdeployer bug). This is a real architectural +difference from the MySQL GR reference image, whose entrypoint runs `dbdeployer` as root with no +issue. + +**Confirmed working setup:** create a dedicated non-root user, and run every `dbdeployer` command +for PG (`unpack`, `deploy postgresql`, `deploy replication --provider=postgresql`, and any +`sandboxes`/`delete`) via that user (its `$HOME` becomes dbdeployer's default `sandbox-home` / +`sandbox-binary` root for the postgresql provider): + +```bash +useradd -m -s /bin/bash pguser +# Copy + chown the downloaded .deb files into pguser's home first — /root is mode 700, +# so pguser cannot read /root/pgdebs (exact commands in §2): +mkdir -p /home/pguser/pgdebs +cp /root/pgdebs/*.deb /home/pguser/pgdebs/ +chown -R pguser:pguser /home/pguser/pgdebs +# then, as pguser: +su - pguser -c 'cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql postgresql-17_17.10-1.pgdg22.04+1_amd64.deb postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' +``` + +Task 2's entrypoint/Dockerfile should either run the whole container as this user (`USER pguser` ++ appropriate ownership of the build layers), or `su`/`gosu`/`setpriv` into it for the PG-specific +commands only, similar to how official `postgres` images drop privileges. + +## 5. The exact WORKING `dbdeployer deploy replication` command + +### FINAL RECOMMENDED COMMAND (Task 2: copy this) + +Run as `pguser` (see §4). This is the minimal form with the no-op flags removed (see +"silently-ignored flags" below for why `--bind-address`/`--base-port`/`-c` are omitted — +they do nothing for the postgresql provider): + +```bash +dbdeployer deploy replication 17.10 \ + --provider=postgresql \ + --topology=master-slave \ + --nodes=3 +``` + +Config injection (`listen_addresses`, `shared_preload_libraries`, `max_connections`, +`pg_hba.conf`) happens **post-deploy** — see the two confirmed workaround blocks below; Task 2's +entrypoint must include both. + +On reserved-ports: the spike ran `dbdeployer defaults update reserved-ports '0'` before +deploying, carried over defensively from the MySQL GR recipe (dbdeployer's default reserved list +includes 5432). It was **NOT confirmed necessary for the postgresql provider** — PG ports are +auto-derived to 16710+ regardless of any port flags (see below), nowhere near the reserved list, +and a run without the step was not tested. Keep it or drop it in Task 2; do not treat it as a +proven requirement. + +### The command as actually run in the spike (annotated history) + +The spike's original invocation included the flags below; it succeeded, but the highlighted flags +were proven no-ops afterwards: + +```bash +dbdeployer deploy replication 17.10 \ + --provider=postgresql \ + --topology=master-slave \ + --nodes=3 \ + --bind-address=0.0.0.0 \ # IGNORED for postgresql provider + --base-port=5432 \ # IGNORED — ports land at 16710/16711/16712 + -c listen_addresses=0.0.0.0 \ # IGNORED + -c max_connections=500 \ # IGNORED + -c shared_preload_libraries=pg_stat_statements # IGNORED +``` + +Output: +``` + Primary deployed in $HOME/sandboxes/postgresql_repl_16710/primary (port: 16710) + Replica 1 deployed in $HOME/sandboxes/postgresql_repl_16710/replica1 (port: 16711) + Replica 2 deployed in $HOME/sandboxes/postgresql_repl_16710/replica2 (port: 16712) +postgresql replication sandbox (1 primary + 2 replicas) deployed in $HOME/sandboxes/postgresql_repl_16710 +``` + +### IMPORTANT — silently-ignored flags (surprise #1) + +**`--base-port`, `--bind-address`, and every `-c` config override above are silently ignored by +the `--provider=postgresql` replication code path in this fork.** The command above "succeeds" +and reports the settings were requested, but: +- Ports are **not** 5432-based; they are auto-derived from the version number + (`17.10` → primary port **16710**, replicas **16711**/**16712** — pattern is + `15000 + major*100 + minor`, confirmed by observing the same 16710 base for a plain + `deploy postgresql 17.10` single-node deploy with no port flags at all). +- `listen_addresses` in the generated `postgresql.conf` stays `127.0.0.1` regardless of + `--bind-address=0.0.0.0`. +- None of the `-c` key=value pairs appear in `postgresql.conf` at all (verified by `tail`-ing the + file post-deploy — only dbdeployer's own fixed template lines are present: `port`, + `listen_addresses`, `unix_socket_directories`, `logging_collector`, `log_directory`, + `wal_level`, `max_wal_senders`, `hot_standby`). + +This is a real fork limitation (verified by testing flag placement both after and before the +`replication` subcommand — no difference), not a usage mistake. **Task 2 cannot rely on `-c` / +`--bind-address` / `--base-port` for the postgresql provider.** + +### Confirmed working workaround (surprise #1, mitigation) + +Stop all 3 nodes, append overrides directly to each `data/postgresql.conf`, and restart — this +DOES work and was verified end-to-end (config values took effect, replication stayed intact, +extension load succeeded): + +```bash +for n in primary replica1 replica2; do + "$SBASE/$n/stop" + cat >> "$SBASE/$n/data/postgresql.conf" <", user "postgres", database "postgres", no +encryption` — i.e. **listen_addresses alone is not sufficient for other containers on the Docker +network to reach these nodes; `pg_hba.conf` must also be widened.** Confirmed fix (append + reload, +no restart needed): + +```bash +for n in primary replica1 replica2; do + echo "host all all 0.0.0.0/0 trust" >> "$SBASE/$n/data/pg_hba.conf" + echo "host replication all 0.0.0.0/0 trust" >> "$SBASE/$n/data/pg_hba.conf" +done +"$SBASE/primary/use" -c "SELECT pg_reload_conf();" # or any node — reload is per-node +"$SBASE/replica1/use" -c "SELECT pg_reload_conf();" +"$SBASE/replica2/use" -c "SELECT pg_reload_conf();" +``` +Re-verified with a real (non-loopback) client connection after the reload: succeeded +(`SELECT 1` returned). `trust` auth is obviously permissive — Task 2 may want to scope the CIDR +to the actual Docker bridge subnet and/or switch to `scram-sha-256` with a real password for +anything beyond a fully-isolated test network (see §7 — there's currently no password set at +all). + +## 6. Sandbox directory layout & port map (PG 17.10, 1 primary + 2 replicas) + +``` +$HOME/sandboxes/postgresql_repl_16710/ +├── check_recovery # runs `SELECT pg_is_in_recovery();` against both replica ports +├── check_replication # runs `SELECT ... FROM pg_stat_replication;` against the primary +├── primary/ +│ ├── data/ # PGDATA (postgresql.conf, pg_hba.conf, base/, etc.) +│ ├── postgresql.log +│ ├── start / stop / restart / status / clear / use # `use` = psql wrapper script +├── replica1/ (same layout) +└── replica2/ (same layout) +``` + +| Node | Role | Port | `pg_is_in_recovery()` | +|----------|---------|-------|------------------------| +| primary | primary | 16710 | `f` | +| replica1 | replica | 16711 | `t` | +| replica2 | replica | 16712 | `t` | + +**Note:** `dbdeployer sandboxes` (the catalog command) did **not** reliably list this sandbox in +testing — it showed a stale/unrelated entry from an earlier failed attempt while the real, +running `postgresql_repl_16710` tree was absent from its output. **Task 2 should enumerate PG +sandboxes via `ls $HOME/sandboxes/` / the directory tree, not trust `dbdeployer sandboxes` for +the postgresql provider.** + +## 7. Superuser, replication user, and adding more users + +- Superuser: **`postgres`**, auth method **`trust`** (no password set) for local/loopback + connections by default. `--db-user`/`--db-password` (global deploy flags, default + `msandbox`/`msandbox`) are **also ignored** for `--provider=postgresql` — the role is always + `postgres` with no password, confirmed via `\du` (single role: `postgres`, Superuser/Create + role/Create DB/Replication/Bypass RLS). +- Replication: dbdeployer does **not** create a dedicated replication role. Each replica's + `postgresql.auto.conf` sets `primary_conninfo = 'user=postgres passfile=... host=127.0.0.1 + port=16710 ...'` — i.e. replication streams **as the `postgres` superuser itself** via + streaming replication (`standby.signal` present, `wal_level=replica`, `max_wal_senders=10`, + `hot_standby=on` baked into the primary's `postgresql.conf` by dbdeployer). No replication + slots are configured (physical replication via `primary_conninfo`, not slot-based). +- Additional users/passwords: not a dbdeployer feature for PG — just connect as `postgres` via + the `use` script and run plain SQL, e.g.: + ```bash + "$SBASE/primary/use" -c "CREATE ROLE app_user LOGIN PASSWORD 'app_pw';" + ``` + +## 8. `pg_stat_statements` availability (confirmed present, verified loadable) + +`postgresql-17_17.10-1.pgdg22.04+1_amd64.deb` ships `pg_stat_statements.so` at +`$HOME/opt/postgresql/17.10/lib/pg_stat_statements.so`, plus its control/SQL files under +`share/postgresql/17/extension/pg_stat_statements*`. Confirmed end-to-end: +- `-c shared_preload_libraries=pg_stat_statements` written directly into `postgresql.conf` (see + §5 workaround) → after restart, `SHOW shared_preload_libraries;` returned `pg_stat_statements`. +- `CREATE EXTENSION pg_stat_statements;` succeeded on the primary; `SELECT 1 FROM + pg_stat_statements LIMIT 1;` returned a row. + +## 9. Full verification transcript (final state) + +``` +-- primary (port 16710) +SHOW listen_addresses; -- 0.0.0.0 +SHOW max_connections; -- 500 +SHOW shared_preload_libraries; -- pg_stat_statements +SELECT pg_is_in_recovery(); -- f +CREATE EXTENSION pg_stat_statements; -- CREATE EXTENSION +SELECT 1 FROM pg_stat_statements LIMIT 1; -- 1 row + +-- check_replication (run on primary, port 16710) + client_addr | state | sent_lsn | write_lsn | flush_lsn | replay_lsn +-------------+-----------+-----------+-----------+-----------+------------ + 127.0.0.1 | streaming | 0/40513C8 | 0/40513C8 | 0/40513C8 | 0/40513C8 + 127.0.0.1 | streaming | 0/40513C8 | 0/40513C8 | 0/40513C8 | 0/40513C8 + +-- check_recovery +=== Replica port 16711 === pg_is_in_recovery = t +=== Replica port 16712 === pg_is_in_recovery = t + +-- remote (non-loopback) connectivity, post pg_hba.conf fix +psql -h -p 16710 -U postgres -c "SELECT 1 AS remote_ok;" -- 1 +``` + +## 10. Summary of surprises/limitations for Task 2 to design around + +1. No PG tarballs in `dbdeployer downloads list` — must pull PGDG `.deb`s via `apt` and feed them + to `dbdeployer unpack --provider=postgresql`. +2. Debian PG binaries need their shared-lib dependencies installed system-wide (`libpq5`, + `libicu70`, `libxml2`, `libxslt1.1`, `libllvm15`, `libedit2`, `libbsd0`, `libmd0`, `tzdata`), + and a `/usr/share/postgresql/` symlink to the unpacked share dir (hardcoded, non- + relocatable `sharedir`). +3. `initdb`/`postgres` refuse to run as root — the container must run PG-related dbdeployer + commands as a non-root user (unlike the MySQL GR image, which runs entirely as root). +4. Because of (3), the downloaded `.deb`s must be readable by that non-root user before + `dbdeployer unpack` — `/root` is mode 700, so debs fetched under `/root/pgdebs` hit + "Permission denied". Copy + `chown` them into the user's home (exact commands in §2/§4), or + download them to a world-readable path in the first place. +5. `--base-port`, `--bind-address`, `-c my-cnf-options`, and `--db-user`/`--db-password` are all + silently ignored for `--provider=postgresql`; config must be injected by editing + `postgresql.conf`/`pg_hba.conf` directly and restarting/reloading. Ports are fixed at + `15000 + major*100 + minor` (16710/16711/16712 for 17.10) — do not fight this, standardize on + it. +6. `pg_hba.conf` defaults to loopback-only `trust` rules — must append a wider `host ... 0.0.0.0/0` + (or the actual Docker subnet) entry for cross-container reachability; `pg_reload_conf()` (no + restart) is sufficient. +7. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory + tree directly. +8. No dedicated replication role/slots — replicas stream as `postgres` itself with no password + (`trust`); no password is set for `postgres` by dbdeployer at all. + +None of the above break the dbdeployer path — every one has a confirmed, tested workaround above. +**Recommendation: proceed with Task 2a (dbdeployer), incorporating the post-deploy config-patch +step (§5) and the pg_hba widening step (§5) into the Dockerfile/entrypoint.** The native +`postgres:17` fallback (Task 2b) is not needed. diff --git a/test/pg-compat/behaviors/__init__.py b/test/pg-compat/behaviors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py new file mode 100644 index 0000000000..4109205af4 --- /dev/null +++ b/test/pg-compat/behaviors/connect.py @@ -0,0 +1,18 @@ +"""Driver-agnostic behavior: a fresh connection can run a trivial query. + +The simplest possible contract -- if this fails, nothing else in the +behavior set is meaningful for that driver/target. +""" + + +def run(Adapter): + a = Adapter() + try: + assert a.exec_simple("SELECT 1")[0][0] == 1 + # Uniform with the four SP-3 driver ports (go/java/node/prisma): + # assert the client_encoding=UTF8 pin took effect -- see + # harness/targets.py's encoding rationale (backend DBs default to + # SQL_ASCII; ProxySQL imposes UTF8). + assert a.exec_simple("SHOW client_encoding")[0][0] == "UTF8" + finally: + a.close() diff --git a/test/pg-compat/behaviors/prepared.py b/test/pg-compat/behaviors/prepared.py new file mode 100644 index 0000000000..43f7e5a0fe --- /dev/null +++ b/test/pg-compat/behaviors/prepared.py @@ -0,0 +1,35 @@ +"""Driver-agnostic behavior: a parameterized statement, reused many times, +keeps working across ProxySQL's connection multiplexing. + +psycopg3 auto-prepares a statement (turning it into a real extended-protocol +Parse/Bind/Execute sequence, not just a client-side text substitution) after +it has been executed more than ``prepare_threshold`` times (default 5) on +the same connection -- see psycopg's ``prepared.py``. Looping 50 times here +guarantees the driver crosses that threshold, so the back half of this loop +genuinely exercises real server-side prepared statements multiplexed by +ProxySQL, not merely simple-protocol round trips. + +Placeholder syntax note (a real adaptation, not a style choice): the brief +wrote the query with raw PostgreSQL positional placeholders (``$1``, ``$2``). +That is NOT what psycopg3's ``cursor.execute(sql, params)`` expects on the +client side -- psycopg (2 and 3 alike) uses ``%s`` placeholders in the SQL +text it is given and translates them to ``$1``/``$2``/... itself when it +builds the wire-protocol Bind message. Passing ``$1``/``$2`` literally +through ``execute()`` makes psycopg count zero ``%s`` placeholders in the +query while two params were supplied, raising +``psycopg.ProgrammingError: the query has 0 placeholders but 2 parameters +were passed`` (reproduced against this exact behavior before this fix). The +query text below therefore uses ``%s``; the actual wire protocol PostgreSQL +sees (and what ProxySQL multiplexes) still uses ``$1``/``$2`` -- that +translation is exactly what the driver is for. +""" + + +def run(Adapter): + a = Adapter() + try: + for i in range(50): + r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) + assert r[0][0] == i + 1 + finally: + a.close() diff --git a/test/pg-compat/behaviors/session_isolation.py b/test/pg-compat/behaviors/session_isolation.py new file mode 100644 index 0000000000..895cf459e8 --- /dev/null +++ b/test/pg-compat/behaviors/session_isolation.py @@ -0,0 +1,56 @@ +"""Driver-agnostic behavior: session state set on one connection must not +leak to a different connection. + +Trap avoided here (folded in from SP-1's lessons, ahead of the original +brief which probed ``application_name``): ProxySQL explicitly lists +``application_name`` in ``ignore_vars`` (see ``lib/PgSQL_Variables.cpp``) -- +it is never forwarded to or tracked against the backend, which hardcodes +``application_name = 'proxysql'`` on its own server connections. That means +``SHOW application_name`` can NEVER reflect a client ``SET``, through +ProxySQL, and a probe built on it would fail for a reason that has nothing +to do with session isolation. This behavior instead probes ``TimeZone``, +which IS a tracked/forwarded/reset variable (``pgsql_tracked_variables[]`` +in ``include/proxysql_structs.h``) -- the same swap validated by SP-1's +pool-churn TAP test. + +Structure: connection A sets the distinctive value and asserts it, then +CLOSES before connection B ever opens. This makes backend-connection reuse +between A and B at least *possible* (A's backend connection is free by the +time B asks for one), so this behavior is testing the real cross-driver +contract: IF B happens to land on the same physical backend connection A +just released, ProxySQL must have reset/not-inherited A's session state. +Note for anyone tightening this later: a trivial pass is possible here if B +lands on a *different* backend than A (then there was never any shared +state to leak in the first place) -- this is an inherent limitation of a +black-box, connection-pool-driven probe run against a live multi-backend +pool where which physical backend serves which client session is not +observable/controllable from here. The deterministic, single-backend +variant (that forces A and B onto the very same backend and proves the +reset explicitly) lives in SP-1's TAP test; this behavior's job is only to +exercise the same contract identically across every driver adapter (Python +here, Java/Go/Node in SP-3). +""" + +DISTINCTIVE_TZ = "Antarctica/Troll" + + +def run(Adapter): + a = Adapter() + b = None + try: + a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") + assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ + # Close A before B opens (deliberate -- see module docstring): this + # keeps A's backend connection possibly free by the time B asks for + # one. The `finally` below closes A again as a resource-hygiene + # backstop on an assert failure above; the adapter's close() is + # idempotent so that repeat call is a safe no-op. + a.close() + + b = Adapter() + val = b.exec_simple("SHOW TimeZone")[0][0] + assert val != DISTINCTIVE_TZ, "session state leaked across connections" + finally: + a.close() + if b is not None: + b.close() diff --git a/test/pg-compat/behaviors/transactions.py b/test/pg-compat/behaviors/transactions.py new file mode 100644 index 0000000000..17608122db --- /dev/null +++ b/test/pg-compat/behaviors/transactions.py @@ -0,0 +1,68 @@ +"""Driver-agnostic behavior: BEGIN/COMMIT/ROLLBACK are honored end-to-end +through ProxySQL. + +Read-after-write through the RW-split (trap, see SP-1 lessons folded into +this SP-2 task): the infra routes a bare ``^SELECT`` to a READER hostgroup. +``INSERT ... COMMIT`` followed by a bare ``SELECT count(*)`` would send the +verification read to a replica, where the freshly committed row may not be +visible yet (streaming replication lag) -- a flake that has nothing to do +with transaction semantics. + +Fix applied here: every verification read runs inside its own explicit +``begin()``/``commit()`` pair. ``BEGIN`` does not match ``^SELECT``, so it +takes the default hostgroup (the writer); ProxySQL then keeps that +transaction pinned to the same backend connection until it commits, so the +SELECT inside it is also pinned to the writer -- the same connection that +just did the INSERT/COMMIT, so there is no cross-node replication lag to +race. This was verified empirically against the oracle (see the task +report): the verify-SELECT below carries a distinctive column alias +(``AS verify_read``) precisely so it can be isolated in +``pg_stat_statements`` and proven to land only on the primary, never a +replica. +""" + +TABLE = "behavior_tx_t" + + +def run(Adapter): + a = Adapter() + try: + a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") + a.exec_simple(f"CREATE TABLE {TABLE} (id int)") + + a.begin() + a.exec_simple(f"INSERT INTO {TABLE} VALUES (1)") + a.rollback() + + a.begin() + count = a.exec_simple(f"SELECT count(*) AS verify_read FROM {TABLE}")[0][0] + a.commit() + assert count == 0, "rollback did not discard the insert" + + a.begin() + a.exec_simple(f"INSERT INTO {TABLE} VALUES (2)") + a.commit() + + a.begin() + count = a.exec_simple(f"SELECT count(*) AS verify_read FROM {TABLE}")[0][0] + a.commit() + assert count == 1, "commit did not persist the insert" + finally: + # Leave no state behind, whether or not the assertions above passed, + # and use a table name distinct from other behaviors/tests + # (tests/test_routing_oracle.py's own probe table is "oracle_w") so + # runs never collide. + # + # Roll back first: if any statement above raised between begin() and + # commit(), the session is left in PostgreSQL's aborted-transaction + # state, where DROP TABLE fails with "current transaction is aborted" + # -- so the table would leak AND the cleanup error would mask the real + # failure. The rollback is best-effort for the same reason. + try: + a.rollback() + except Exception: + pass + try: + a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") + finally: + a.close() diff --git a/test/pg-compat/cases/001_scalars.sql b/test/pg-compat/cases/001_scalars.sql new file mode 100644 index 0000000000..66352dfe9a --- /dev/null +++ b/test/pg-compat/cases/001_scalars.sql @@ -0,0 +1,2 @@ +-- transactional: false +SELECT true, 2147483647::int4, 9223372036854775807::int8, 1.5::float8, 12345.6789::numeric, 'héllo'::text; diff --git a/test/pg-compat/cases/002_bytea_json_array.sql b/test/pg-compat/cases/002_bytea_json_array.sql new file mode 100644 index 0000000000..bf892fc306 --- /dev/null +++ b/test/pg-compat/cases/002_bytea_json_array.sql @@ -0,0 +1,2 @@ +-- transactional: false +SELECT '\xdeadbeef'::bytea, '{"a":1}'::jsonb, ARRAY[1,2,3]::int4[], '192.168.0.1'::inet, '00000000-0000-0000-0000-000000000001'::uuid; diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py new file mode 100644 index 0000000000..4e3701e800 --- /dev/null +++ b/test/pg-compat/conftest.py @@ -0,0 +1,74 @@ +import os +import warnings + +import psycopg +import pytest + +from harness import xfail as _xfail +from harness.proxysql import Admin + + +@pytest.fixture(scope="session") +def admin(): + return Admin() + + +def _proxy_dsn(dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"] + p = os.environ["PGCOMPAT_PROXY_PORT"] + # client_encoding pinned to UTF8 for the same reason harness/targets.py + # and drivers/python/adapter.py pin it: the dbdeployer backend databases + # default to SQL_ASCII, which psycopg maps to Python's restrictive + # 'ascii' codec. + return ( + f"host={h} port={p} user=testuser password=testuser dbname={dbname} " + f"sslmode=disable client_encoding=UTF8" + ) + + +@pytest.fixture +def proxy_conn(): + conn = psycopg.connect(_proxy_dsn(), autocommit=True) + yield conn + conn.close() + + +# --- xfail catalogue (discovery-phase reporting; spec sec 2.1) ------------- +# +# test/pg-compat/xfail.toml's [[xfail]] entries are keyed by pytest test_id +# (item.nodeid, e.g. "tests/test_differential.py::test_case_is_transparent +# [002_bytea_json_array.sql]"). Any test_id listed there gets an +# xfail(strict=False) marker applied at collection time: +# - while the entry's divergence persists, the test reports "xfailed" +# (an expected failure) instead of "failed" -- the suite stays green +# without loosening any assertion; +# - if/when the underlying bug is fixed, the SAME test starts passing and +# is reported "xpassed" (visible with `-rxX`) because strict=False -- +# that's the signal the entry should be removed from xfail.toml. +_XFAILS = {e["test_id"]: e for e in _xfail.load()} + + +def pytest_collection_modifyitems(config, items): + matched_ids = set() + for item in items: + entry = _XFAILS.get(item.nodeid) + if entry: + matched_ids.add(item.nodeid) + item.add_marker( + pytest.mark.xfail( + reason=f'{entry["reason"]} ({entry["ref"]})', strict=False + ) + ) + + # Catalogue hygiene: a [[xfail]] entry whose test_id matched NO collected + # item is currently a silent no-op (e.g. a typo'd nodeid, or a test that + # was renamed/removed without updating xfail.toml). Warn -- don't fail + # collection -- so a stale/typo'd entry is visible in the run instead of + # quietly doing nothing forever. + for test_id in _XFAILS: + if test_id not in matched_ids: + warnings.warn( + f"xfail.toml entry test_id={test_id!r} matched no collected " + f"test item -- stale or typo'd entry?", + stacklevel=1, + ) diff --git a/test/pg-compat/drivers/__init__.py b/test/pg-compat/drivers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go new file mode 100644 index 0000000000..366e63b033 --- /dev/null +++ b/test/pg-compat/drivers/go/behaviors.go @@ -0,0 +1,307 @@ +// behaviors-go: Go/pgx behavior CLI stub. +// +// CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, +// Global Constraints): `behaviors-go ` where is one of +// connect, transactions, prepared, session_isolation. +// +// exit 0 -> behavior passed +// exit 1 -> behavior assertion failed (reason on stderr) +// exit 2 -> usage/infra error (unknown behavior name, not-yet-implemented +// behavior, missing/invalid env, etc.) +// +// No stdout output is required on pass. +// +// All four behaviors (connect, transactions, prepared, session_isolation) +// are implemented end to end; an unknown behavior name is the only exit-2 +// case dispatch() produces. +// +// Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default +// "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is +// "testuser"/"testuser"/"testuser"; sslmode disabled; client_encoding +// pinned to UTF8 (backend DBs default to SQL_ASCII; ProxySQL imposes +// UTF8 -- see xfail.toml finding referenced in the plan). +package main + +import ( + "context" + "fmt" + "os" + + "github.com/jackc/pgx/v5" +) + +func dsn() string { + host := os.Getenv("PGCOMPAT_PROXY_HOST") + if host == "" { + host = "proxysql" + } + port := os.Getenv("PGCOMPAT_PROXY_PORT") + if port == "" { + port = "6133" + } + return fmt.Sprintf( + "postgres://testuser:testuser@%s:%s/testuser?sslmode=disable&client_encoding=UTF8", + host, port, + ) +} + +// connect: a fresh connection can run a trivial query. The simplest +// possible contract -- if this fails, nothing else is meaningful for this +// driver/target. Mirrors behaviors/connect.py, plus an explicit assertion +// that the DSN's client_encoding=UTF8 pin actually took effect (the pin is +// a recorded SP-2 finding; asserting it here keeps any encoding-pin +// regression visible in every run). +func connect() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + var one int + if err := conn.QueryRow(ctx, "SELECT 1").Scan(&one); err != nil { + return fmt.Errorf("SELECT 1: %w", err) + } + if one != 1 { + return fmt.Errorf("SELECT 1 returned %d, want 1", one) + } + + var enc string + if err := conn.QueryRow(ctx, "SHOW client_encoding").Scan(&enc); err != nil { + return fmt.Errorf("SHOW client_encoding: %w", err) + } + if enc != "UTF8" { + return fmt.Errorf("client_encoding is %q, want \"UTF8\" (DSN pin did not take effect)", enc) + } + return nil +} + +// txTable is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table; see the +// plan's Global Constraints). +const txTable = "behavior_tx_t_go" + +// transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through +// ProxySQL. Mirrors behaviors/transactions.py exactly, including its +// RW-split trap fix: every verification read runs inside its own explicit +// BEGIN/COMMIT (via conn.Begin(ctx)/tx.Commit(ctx)) so it is pinned to the +// same (writer) backend connection as the preceding INSERT/COMMIT, instead +// of racing replication lag on a bare SELECT routed to a reader hostgroup. +// The verify-read carries the same "AS verify_read" alias as the Python +// behavior for pg_stat_statements traceability. +func transactions() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + // Cleanup runs on success AND on failure (defer), leaving no state + // behind, same as the Python behavior's try/finally. Parity with + // Java/Node/Prisma: best-effort ROLLBACK first (error ignored) restores + // a usable session state before the DROP -- if a non-assertion error + // above left the connection mid-transaction, an aborted implicit + // transaction would otherwise reject the DROP. + defer func() { + conn.Exec(ctx, "ROLLBACK") + conn.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", txTable)) + }() + + if _, err := conn.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", txTable)); err != nil { + return fmt.Errorf("DROP TABLE IF EXISTS: %w", err) + } + if _, err := conn.Exec(ctx, fmt.Sprintf("CREATE TABLE %s (id int)", txTable)); err != nil { + return fmt.Errorf("CREATE TABLE: %w", err) + } + + tx1, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("BEGIN (insert 1): %w", err) + } + if _, err := tx1.Exec(ctx, fmt.Sprintf("INSERT INTO %s VALUES (1)", txTable)); err != nil { + return fmt.Errorf("INSERT (1): %w", err) + } + if err := tx1.Rollback(ctx); err != nil { + return fmt.Errorf("ROLLBACK: %w", err) + } + + count, err := verifyCount(ctx, conn) + if err != nil { + return err + } + if count != 0 { + return fmt.Errorf("rollback did not discard the insert: count=%d, want 0", count) + } + + tx2, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("BEGIN (insert 2): %w", err) + } + if _, err := tx2.Exec(ctx, fmt.Sprintf("INSERT INTO %s VALUES (2)", txTable)); err != nil { + return fmt.Errorf("INSERT (2): %w", err) + } + if err := tx2.Commit(ctx); err != nil { + return fmt.Errorf("COMMIT (insert 2): %w", err) + } + + count, err = verifyCount(ctx, conn) + if err != nil { + return err + } + if count != 1 { + return fmt.Errorf("commit did not persist the insert: count=%d, want 1", count) + } + + return nil +} + +// verifyCount runs the RW-split-safe verification read described in the +// transactions() comment above: its own explicit BEGIN...COMMIT wrapping a +// single "SELECT count(*) AS verify_read" against txTable. +func verifyCount(ctx context.Context, conn *pgx.Conn) (int, error) { + vtx, err := conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("BEGIN (verify): %w", err) + } + var count int + if err := vtx.QueryRow(ctx, fmt.Sprintf("SELECT count(*) AS verify_read FROM %s", txTable)).Scan(&count); err != nil { + return 0, fmt.Errorf("verify SELECT: %w", err) + } + if err := vtx.Commit(ctx); err != nil { + return 0, fmt.Errorf("COMMIT (verify): %w", err) + } + return count, nil +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// Exec mode in play: pgx v5's default QueryExecMode is +// QueryExecModeCacheStatement ("cache_statement") -- pgx.Connect does not +// override it here, so this is the mode used. Under cache_statement, pgx +// consults its per-connection statement cache (keyed by SQL text) FIRST: +// only a cache miss -- the first occurrence of a given SQL text -- sends an +// extended-protocol Parse (via Prepare); every subsequent call with the +// same SQL text goes through execPrepared, i.e. Bind/Execute only against +// the already-parsed server-side statement (pgx v5.7.5 conn.go, the +// QueryExecModeCacheStatement branches; also its QueryExecMode doc comment: +// "Queries are executed in a single round trip after the statement is +// cached"). In the 50x loop below that means iteration 0 Parses once and +// iterations 1-49 are Bind/Execute-only reuse of one server-side prepared +// statement -- so every iteration exercises real extended-protocol prepared +// statements multiplexed by ProxySQL, with no warm-up threshold to cross +// (unlike psycopg3's prepare_threshold -- see prepared.py's docstring); +// the loop's job is to prove that cached server-side statement keeps +// resolving correctly across many round trips through the proxy. +// Placeholders are pgx-native ($1, $2), unlike Python's psycopg %s -- see +// the plan's Global Constraints on driver-native placeholder syntax. +func prepared() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + for i := 0; i < 50; i++ { + var sum int + if err := conn.QueryRow(ctx, "SELECT $1::int + $2::int", i, 1).Scan(&sum); err != nil { + return fmt.Errorf("iteration %d: %w", i, err) + } + if sum != i+1 { + return fmt.Errorf("iteration %d: got %d, want %d", i, sum, i+1) + } + } + return nil +} + +// distinctiveTZ is the session-isolation probe value. NEVER application_name +// -- ProxySQL lists it in ignore_vars, so it can never reflect a client SET +// through the proxy (see session_isolation.py's docstring). TimeZone is a +// tracked/forwarded/reset variable, so it is a valid probe. +const distinctiveTZ = "Antarctica/Troll" + +// sessionIsolation: session state set on one connection must not leak to a +// different connection. Mirrors behaviors/session_isolation.py exactly, +// including closing connection A before opening B (see the module's +// docstring for why: it makes it possible, not guaranteed, for B to reuse +// A's just-freed backend connection, which is what makes this a real test +// of ProxySQL resetting/not-inheriting session state on reuse). +func sessionIsolation() error { + ctx := context.Background() + a, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect A: %w", err) + } + var b *pgx.Conn + defer func() { + // Idempotent-safe backstop, matching the Python finally: closing A + // again after the deliberate early close below is a safe no-op. + if a != nil { + a.Close(ctx) + } + if b != nil { + b.Close(ctx) + } + }() + + if _, err := a.Exec(ctx, fmt.Sprintf("SET TimeZone = '%s'", distinctiveTZ)); err != nil { + return fmt.Errorf("SET TimeZone (A): %w", err) + } + var tzA string + if err := a.QueryRow(ctx, "SHOW TimeZone").Scan(&tzA); err != nil { + return fmt.Errorf("SHOW TimeZone (A): %w", err) + } + if tzA != distinctiveTZ { + return fmt.Errorf("SHOW TimeZone (A) = %q, want %q", tzA, distinctiveTZ) + } + // Close A before B opens (deliberate -- see the doc comment above). + if err := a.Close(ctx); err != nil { + return fmt.Errorf("close A: %w", err) + } + + b, err = pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect B: %w", err) + } + var tzB string + if err := b.QueryRow(ctx, "SHOW TimeZone").Scan(&tzB); err != nil { + return fmt.Errorf("SHOW TimeZone (B): %w", err) + } + if tzB == distinctiveTZ { + return fmt.Errorf("session state leaked across connections: B's TimeZone is %q", tzB) + } + return nil +} + +func dispatch(behavior string) int { + var fn func() error + switch behavior { + case "connect": + fn = connect + case "transactions": + fn = transactions + case "prepared": + fn = prepared + case "session_isolation": + fn = sessionIsolation + default: + fmt.Fprintf(os.Stderr, "unknown behavior: %q\n", behavior) + return 2 + } + if err := fn(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: behaviors-go ") + os.Exit(2) + } + os.Exit(dispatch(os.Args[1])) +} diff --git a/test/pg-compat/drivers/go/go.mod b/test/pg-compat/drivers/go/go.mod new file mode 100644 index 0000000000..9a995e90d6 --- /dev/null +++ b/test/pg-compat/drivers/go/go.mod @@ -0,0 +1,12 @@ +module proxysql-pg-compat/behaviors-go + +go 1.23.0 + +require github.com/jackc/pgx/v5 v5.7.5 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/test/pg-compat/drivers/go/go.sum b/test/pg-compat/drivers/go/go.sum new file mode 100644 index 0000000000..85a678b1fa --- /dev/null +++ b/test/pg-compat/drivers/go/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java new file mode 100644 index 0000000000..332d43f4e2 --- /dev/null +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -0,0 +1,347 @@ +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Properties; + +/** + * behaviors-java: Java/pgjdbc behavior CLI stub. + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): {@code behaviors-java } where + * {@code } is one of connect, transactions, prepared, + * session_isolation. + *
    + *
  • exit 0 -> behavior passed
  • + *
  • exit 1 -> behavior assertion failed (reason on stderr)
  • + *
  • exit 2 -> usage/infra error (unknown behavior name, + * not-yet-implemented behavior, missing/invalid env, etc.)
  • + *
+ * No stdout output is required on pass. + * + * All four behaviors (connect, transactions, prepared, session_isolation) + * are implemented end to end; an unknown behavior name is the only exit-2 + * case {@code dispatch()} produces. + * + * Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default + * "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser"; sslmode disabled; client_encoding + * pinned to UTF8. + * + * Encoding-pin finding (verified empirically for SP3-Task-1, see report): + * pgjdbc 42.7.4 accepts the {@code options} connection property set to + * {@code -c client_encoding=UTF8} and forwards it as a libpq-style startup + * option -- confirmed by running {@code SHOW client_encoding} through this + * exact stub against the ProxySQL PG frontend, which returned {@code UTF8}. + * No post-connect {@code SET client_encoding} statement is needed. + */ +public class Behaviors { + + private static String proxyHost() { + String h = System.getenv("PGCOMPAT_PROXY_HOST"); + return (h == null || h.isEmpty()) ? "proxysql" : h; + } + + private static String proxyPort() { + String p = System.getenv("PGCOMPAT_PROXY_PORT"); + return (p == null || p.isEmpty()) ? "6133" : p; + } + + private static Connection openConnection() throws Exception { + String url = "jdbc:postgresql://" + proxyHost() + ":" + proxyPort() + "/testuser"; + Properties props = new Properties(); + props.setProperty("user", "testuser"); + props.setProperty("password", "testuser"); + props.setProperty("sslmode", "disable"); + // Encoding pin: pgjdbc does not accept client_encoding as a direct + // connection property, but does forward `options` as libpq-style + // startup options -- this is the form ProxySQL/postgres accepts. + props.setProperty("options", "-c client_encoding=UTF8"); + return DriverManager.getConnection(url, props); + } + + // connect: a fresh connection can run a trivial query. The simplest + // possible contract -- if this fails, nothing else is meaningful for + // this driver/target. Mirrors behaviors/connect.py, plus an explicit + // assertion that the options=-c client_encoding=UTF8 pin actually took + // effect (recorded SP-2 finding; asserting it here keeps any + // encoding-pin regression visible in every run). + private static void connect() throws Exception { + try (Connection conn = openConnection(); + Statement st = conn.createStatement()) { + try (ResultSet rs = st.executeQuery("SELECT 1")) { + if (!rs.next()) { + throw new AssertionError("SELECT 1 returned no rows"); + } + int one = rs.getInt(1); + if (one != 1) { + throw new AssertionError("SELECT 1 returned " + one + ", want 1"); + } + } + try (ResultSet rs = st.executeQuery("SHOW client_encoding")) { + if (!rs.next()) { + throw new AssertionError("SHOW client_encoding returned no rows"); + } + String enc = rs.getString(1); + if (!"UTF8".equals(enc)) { + throw new AssertionError("client_encoding is \"" + enc + + "\", want \"UTF8\" (options pin did not take effect)"); + } + } + } + } + + // txTable is per-language (parallel-safe with the other drivers' behavior + // programs, which each use their own behavior_tx_t_ table -- see + // the plan's Global Constraints). + private static final String TX_TABLE = "behavior_tx_t_java"; + + // transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through + // ProxySQL. Mirrors behaviors/transactions.py exactly, including its + // RW-split trap fix: every verification read runs inside its own + // explicit setAutoCommit(false).../commit() pair (see verifyCount()) + // so it is pinned to the same (writer) backend connection as the + // preceding INSERT/COMMIT, instead of racing replication lag on a bare + // SELECT routed to a reader hostgroup. The verify-read carries the same + // "AS verify_read" alias as the Python/Go behaviors for + // pg_stat_statements traceability. + private static void transactions() throws Exception { + try (Connection conn = openConnection()) { + try { + try (Statement st = conn.createStatement()) { + st.execute("DROP TABLE IF EXISTS " + TX_TABLE); + st.execute("CREATE TABLE " + TX_TABLE + " (id int)"); + } + + conn.setAutoCommit(false); + try (Statement st = conn.createStatement()) { + st.execute("INSERT INTO " + TX_TABLE + " VALUES (1)"); + } + conn.rollback(); + conn.setAutoCommit(true); + + int count0 = verifyCount(conn); + if (count0 != 0) { + throw new AssertionError("rollback did not discard the insert"); + } + + conn.setAutoCommit(false); + try (Statement st = conn.createStatement()) { + st.execute("INSERT INTO " + TX_TABLE + " VALUES (2)"); + } + conn.commit(); + conn.setAutoCommit(true); + + int count1 = verifyCount(conn); + if (count1 != 1) { + throw new AssertionError("commit did not persist the insert"); + } + } finally { + // Leave no state behind whether or not the assertions above + // passed (mirrors the Python/Go finally-equivalent cleanup), + // using a table name distinct from other languages/behaviors + // so runs never collide. If an exception above left + // autocommit off mid-transaction, restore it (rollback + + // setAutoCommit(true)) before the DROP so the connection is + // usable; a cleanup failure here is caught and only printed + // -- it must never mask the real error propagating out of + // this try block. + cleanupTable(conn, TX_TABLE); + } + } + } + + // verifyCount runs the RW-split-safe verification read described in the + // transactions() comment above: its own explicit + // setAutoCommit(false)/commit() pair wrapping a single + // "SELECT count(*) AS verify_read" against TX_TABLE. + private static int verifyCount(Connection conn) throws Exception { + conn.setAutoCommit(false); + int count; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery( + "SELECT count(*) AS verify_read FROM " + TX_TABLE)) { + if (!rs.next()) { + throw new AssertionError("verify_read returned no rows"); + } + count = rs.getInt(1); + } + conn.commit(); + conn.setAutoCommit(true); + return count; + } + + // cleanupTable restores the connection to a usable autocommit state (in + // case an exception left a transaction open) and drops the table. It + // never throws -- any failure here is printed to stderr and swallowed + // so it cannot mask a real assertion/exception already propagating out + // of the caller's try block. + private static void cleanupTable(Connection conn, String table) { + try { + if (!conn.getAutoCommit()) { + try { + conn.rollback(); + } catch (Exception ignore) { + // best effort; setAutoCommit below still runs + } + conn.setAutoCommit(true); + } + try (Statement st = conn.createStatement()) { + st.execute("DROP TABLE IF EXISTS " + table); + } + } catch (Exception e) { + System.err.println("cleanup failed (suppressed, not the real error): " + e); + } + } + + // prepared: a parameterized statement, reused many times, keeps working + // across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. + // + // pgjdbc-specific mechanism (the value of this port): pgjdbc starts every + // PreparedStatement as a client-side-substituted "simple" query and only + // promotes it to a real server-side NAMED statement (extended-protocol + // Parse-once/Bind+Execute-many) once the SAME PreparedStatement object has + // been executed more than `prepareThreshold` times (default 5; see + // org.postgresql.jdbc.PgConnection / PGProperty.PREPARE_THRESHOLD). We + // therefore prepare ONCE outside the loop and reuse that single + // PreparedStatement object for all 50 executions -- re-preparing per + // iteration would reset the threshold counter and the back half of the + // loop would never leave simple-query mode. Past iteration 5, this test + // is genuinely exercising a real named prepared statement multiplexed by + // ProxySQL across its backend connection pool -- the classic + // connection-pooler trap ("prepared statement \"S_1\" does not exist") + // that this port exists to probe. + private static void prepared() throws Exception { + try (Connection conn = openConnection(); + PreparedStatement ps = conn.prepareStatement("SELECT ?::int + ?::int AS sum")) { + for (int i = 0; i < 50; i++) { + ps.setInt(1, i); + ps.setInt(2, 1); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new AssertionError("iteration " + i + ": no rows returned"); + } + int sum = rs.getInt(1); + if (sum != i + 1) { + throw new AssertionError( + "iteration " + i + ": got " + sum + ", want " + (i + 1)); + } + } + } + } + } + + // distinctiveTz is the session-isolation probe value. NEVER + // application_name -- ProxySQL lists it in ignore_vars, so it can never + // reflect a client SET through the proxy (see session_isolation.py's + // docstring). TimeZone is a tracked/forwarded/reset variable, so it is a + // valid probe. + private static final String DISTINCTIVE_TZ = "Antarctica/Troll"; + + // sessionIsolation: session state set on one connection must not leak to + // a different connection. Mirrors behaviors/session_isolation.py + // exactly, including closing connection A before opening B (see the + // Python module's docstring for why: it makes it possible, not + // guaranteed, for B to reuse A's just-freed backend connection, which is + // what makes this a real test of ProxySQL resetting/not-inheriting + // session state on reuse). + private static void sessionIsolation() throws Exception { + Connection a = null; + Connection b = null; + try { + a = openConnection(); + try (Statement st = a.createStatement()) { + st.execute("SET TimeZone = '" + DISTINCTIVE_TZ + "'"); + } + try (Statement st = a.createStatement(); + ResultSet rs = st.executeQuery("SHOW TimeZone")) { + if (!rs.next()) { + throw new AssertionError("SHOW TimeZone (A) returned no rows"); + } + String tzA = rs.getString(1); + if (!DISTINCTIVE_TZ.equals(tzA)) { + throw new AssertionError( + "SHOW TimeZone (A) = \"" + tzA + "\", want \"" + DISTINCTIVE_TZ + "\""); + } + } + // Close A before B opens (deliberate -- see the doc comment + // above). The finally below closes A again as a + // resource-hygiene backstop on an assert failure above; + // closeQuietly() is idempotent-safe so that repeat call is a + // safe no-op. + a.close(); + + b = openConnection(); + try (Statement st = b.createStatement(); + ResultSet rs = st.executeQuery("SHOW TimeZone")) { + if (!rs.next()) { + throw new AssertionError("SHOW TimeZone (B) returned no rows"); + } + String tzB = rs.getString(1); + if (DISTINCTIVE_TZ.equals(tzB)) { + throw new AssertionError( + "session state leaked across connections: B's TimeZone is \"" + tzB + "\""); + } + } + } finally { + closeQuietly(a); + closeQuietly(b); + } + } + + // closeQuietly is the idempotent-safe backstop referenced above: closing + // an already-closed (or never-opened) connection is a safe no-op, same + // as the Python adapter's close(). + private static void closeQuietly(Connection c) { + if (c == null) { + return; + } + try { + if (!c.isClosed()) { + c.close(); + } + } catch (Exception ignore) { + // best-effort cleanup only + } + } + + private static int dispatch(String behavior) { + try { + switch (behavior) { + case "connect": + connect(); + return 0; + case "transactions": + transactions(); + return 0; + case "prepared": + prepared(); + return 0; + case "session_isolation": + sessionIsolation(); + return 0; + default: + System.err.println("unknown behavior: " + behavior); + return 2; + } + } catch (UnsupportedOperationException e) { + System.err.println(e.getMessage()); + return 2; + } catch (AssertionError e) { + System.err.println(e.getMessage()); + return 1; + } catch (Exception e) { + System.err.println(e.toString()); + return 1; + } + } + + public static void main(String[] args) { + if (args.length != 1) { + System.err.println("usage: behaviors-java "); + System.exit(2); + } + System.exit(dispatch(args[0])); + } +} diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js new file mode 100644 index 0000000000..da909ed3b4 --- /dev/null +++ b/test/pg-compat/drivers/node/behaviors.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +/** + * behaviors-node: node-postgres (pg) behavior CLI stub. + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): `behaviors-node ` where is one + * of connect, transactions, prepared, session_isolation. + * exit 0 -> behavior passed + * exit 1 -> behavior assertion failed (reason on stderr) + * exit 2 -> usage/infra error (unknown behavior name, not-yet-implemented + * behavior, missing/invalid env, etc.) + * No stdout output is required on pass. + * + * All four behaviors (connect, transactions, prepared, session_isolation) + * are implemented end to end; an unknown behavior name is the only exit-2 + * case dispatch() produces. + * + * Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default + * "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser"; ssl disabled; client_encoding pinned + * to UTF8. + * + * Encoding-pin finding (verified empirically for SP3-Task-1, see report): + * node-postgres's ConnectionParameters reads a `client_encoding` key + * straight off the config object (lib/connection-parameters.js) and, if + * set, sends it as a startup-packet parameter -- confirmed by inspecting + * pg@8.13.1's source AND by `SHOW client_encoding` returning `UTF8` + * through this exact stub against the ProxySQL PG frontend (the connect + * behavior below asserts this on every run). No post-connect + * `SET client_encoding` is needed for this driver. + */ +'use strict'; + +const { Client } = require('pg'); + +function clientConfig() { + return { + host: process.env.PGCOMPAT_PROXY_HOST || 'proxysql', + port: parseInt(process.env.PGCOMPAT_PROXY_PORT || '6133', 10), + user: 'testuser', + password: 'testuser', + database: 'testuser', + ssl: false, + client_encoding: 'UTF8', + }; +} + +// connect: a fresh connection can run a trivial query. The simplest +// possible contract -- if this fails, nothing else is meaningful for this +// driver/target. Mirrors behaviors/connect.py, plus an explicit assertion +// that the client_encoding=UTF8 pin actually took effect (recorded SP-2 +// finding; asserting it here keeps any encoding-pin regression visible in +// every run). +async function connect() { + const client = new Client(clientConfig()); + await client.connect(); + try { + const res = await client.query('SELECT 1'); + const one = res.rows[0]['?column?'] !== undefined ? res.rows[0]['?column?'] : Object.values(res.rows[0])[0]; + if (one !== 1) { + throw new Error(`SELECT 1 returned ${one}, want 1`); + } + const encRes = await client.query('SHOW client_encoding'); + const enc = encRes.rows[0].client_encoding; + if (enc !== 'UTF8') { + throw new Error(`client_encoding is ${JSON.stringify(enc)}, want "UTF8" (config pin did not take effect)`); + } + } finally { + await client.end(); + } +} + +// txTable is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table -- see the +// plan's Global Constraints). +const TX_TABLE = 'behavior_tx_t_node'; + +// transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through +// ProxySQL. Mirrors behaviors/transactions.py exactly, including its +// RW-split trap fix: every verification read runs inside its own explicit +// BEGIN/COMMIT so it is pinned to the same (writer) backend connection as +// the preceding INSERT/COMMIT, instead of racing replication lag on a bare +// SELECT routed to a reader hostgroup. The verify-read carries the same +// "AS verify_read" alias as the other language ports for pg_stat_statements +// traceability. +// +// node-pg trap note: `count(*)` returns PostgreSQL's int8/bigint type, which +// node-pg deliberately returns as a STRING (not a JS number) by default -- +// JS numbers cannot losslessly represent the full int8 range, so pg's +// built-in type parser leaves int8 as text unless the app opts into a +// custom parser (pg-types). Comparing count to a number with `===` would +// therefore always be false even when the value is correct. This behavior +// compares against the string "0"/"1" deliberately, to reflect exactly what +// the driver hands back rather than silently coercing it away. +async function transactions() { + const client = new Client(clientConfig()); + await client.connect(); + try { + await client.query(`DROP TABLE IF EXISTS ${TX_TABLE}`); + await client.query(`CREATE TABLE ${TX_TABLE} (id int)`); + + await client.query('BEGIN'); + await client.query(`INSERT INTO ${TX_TABLE} VALUES (1)`); + await client.query('ROLLBACK'); + + let count = await verifyCount(client); + if (count !== '0') { + throw new Error(`rollback did not discard the insert: count=${count}, want "0"`); + } + + await client.query('BEGIN'); + await client.query(`INSERT INTO ${TX_TABLE} VALUES (2)`); + await client.query('COMMIT'); + + count = await verifyCount(client); + if (count !== '1') { + throw new Error(`commit did not persist the insert: count=${count}, want "1"`); + } + } finally { + // Leave no state behind whether or not the assertions above passed + // (mirrors the Python/Go/Java finally-equivalent cleanup), using a + // table name distinct from other languages/behaviors so runs never + // collide. If an exception above left the connection mid-transaction, + // best-effort ROLLBACK first (catching/ignoring its own error) so the + // DROP below is not itself rejected by an aborted transaction; a + // cleanup failure is caught and only printed -- it must never mask the + // original error propagating out of this try block. + try { + await client.query('ROLLBACK'); + } catch (e) { + // no open/aborted transaction to roll back -- expected on the happy + // path, ignored. + } + try { + await client.query(`DROP TABLE IF EXISTS ${TX_TABLE}`); + } catch (e) { + process.stderr.write(`cleanup failed (suppressed, not the real error): ${e}\n`); + } + await client.end(); + } +} + +// verifyCount runs the RW-split-safe verification read described in the +// transactions() comment above: its own explicit BEGIN/COMMIT wrapping a +// single "SELECT count(*) AS verify_read" against TX_TABLE. Returns the raw +// string node-pg hands back for int8 (see transactions()'s docstring). +async function verifyCount(client) { + await client.query('BEGIN'); + const res = await client.query(`SELECT count(*) AS verify_read FROM ${TX_TABLE}`); + await client.query('COMMIT'); + return res.rows[0].verify_read; +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// node-pg-specific mechanism (the value of this port): node-pg's distinct +// strategy is explicit NAMED prepared statements -- passing a `name` on the +// query config object makes node-pg send an extended-protocol Parse message +// with that statement name ONLY the first time that name is used on this +// connection; every subsequent query() call with the same `name` skips +// Parse and sends Bind+Execute only, reusing the already-parsed statement +// server-side (see pg/lib/client.js's query() -- it tracks previously +// parsed statement names per connection). Unlike psycopg3 (prepared.py, +// auto-prepares after a threshold) or pgjdbc (Behaviors.java, promotes +// after prepareThreshold executions), node-pg's named-statement reuse is +// unconditional and explicit from the very first call: every one of the 50 +// iterations below -- not just a "back half" past some warm-up count -- +// exercises a real extended-protocol Parse-once/Bind+Execute-many sequence +// multiplexed by ProxySQL, which is exactly the connection-pooler trap +// ("prepared statement ... does not exist") this port exists to probe. +// Placeholders are node-pg-native ($1, $2), same wire syntax as pgx -- +// unlike Python's psycopg %s (see prepared.py's docstring). +async function prepared() { + const client = new Client(clientConfig()); + await client.connect(); + try { + for (let i = 0; i < 50; i++) { + const res = await client.query({ + name: 'pgcompat_add', + text: 'SELECT $1::int + $2::int AS sum', + values: [i, 1], + }); + // node-pg parses int4 (the ::int cast's result type) as a JS number + // already -- unlike int8/count(*) above, no manual coercion is + // needed here. Verified: typeof res.rows[0].sum === 'number'. + const sum = res.rows[0].sum; + if (typeof sum !== 'number') { + throw new Error(`iteration ${i}: sum came back as ${typeof sum} (${JSON.stringify(sum)}), want a JS number`); + } + if (sum !== i + 1) { + throw new Error(`iteration ${i}: got ${sum}, want ${i + 1}`); + } + } + } finally { + await client.end(); + } +} + +// DISTINCTIVE_TZ is the session-isolation probe value. NEVER +// application_name -- ProxySQL lists it in ignore_vars, so it can never +// reflect a client SET through the proxy (see session_isolation.py's +// docstring). TimeZone is a tracked/forwarded/reset variable, so it is a +// valid probe. +const DISTINCTIVE_TZ = 'Antarctica/Troll'; + +// sessionIsolation: session state set on one connection must not leak to a +// different connection. Mirrors behaviors/session_isolation.py exactly, +// including closing connection A before opening B (see the Python module's +// docstring for why: it makes it possible, not guaranteed, for B to reuse +// A's just-freed backend connection, which is what makes this a real test +// of ProxySQL resetting/not-inheriting session state on reuse). Never +// application_name -- see DISTINCTIVE_TZ's comment above. +async function sessionIsolation() { + const a = new Client(clientConfig()); + let b = null; + let aEnded = false; + let bEnded = false; + await a.connect(); + try { + await a.query(`SET TimeZone = '${DISTINCTIVE_TZ}'`); + const tzARes = await a.query('SHOW TimeZone'); + const tzA = tzARes.rows[0].TimeZone; + if (tzA !== DISTINCTIVE_TZ) { + throw new Error(`SHOW TimeZone (A) = ${JSON.stringify(tzA)}, want ${JSON.stringify(DISTINCTIVE_TZ)}`); + } + // Close A before B opens (deliberate -- see the doc comment above). + // The finally below ends A again as a resource-hygiene backstop on an + // assert failure above. end() is defensive, not strictly required -- + // it is a no-op on an already-ended client in pg@8.13.1 (verified via a + // mock server during Task 4 review); the aEnded flag is kept anyway so + // this does not depend on that no-op behavior continuing to hold across + // driver upgrades. + await a.end(); + aEnded = true; + + b = new Client(clientConfig()); + await b.connect(); + const tzBRes = await b.query('SHOW TimeZone'); + const tzB = tzBRes.rows[0].TimeZone; + if (tzB === DISTINCTIVE_TZ) { + throw new Error(`session state leaked across connections: B's TimeZone is ${JSON.stringify(tzB)}`); + } + await b.end(); + bEnded = true; + } finally { + if (!aEnded) { + try { + await a.end(); + } catch (e) { + // best-effort cleanup only + } + } + if (b !== null && !bEnded) { + try { + await b.end(); + } catch (e) { + // best-effort cleanup only + } + } + } +} + +const BEHAVIOR_FNS = { + connect, + transactions, + prepared, + session_isolation: sessionIsolation, +}; + +async function dispatch(behavior) { + // hasOwnProperty guard: a prototype-chain key ("constructor", "toString") + // must be an unknown behavior, not a callable. + const fn = Object.prototype.hasOwnProperty.call(BEHAVIOR_FNS, behavior) + ? BEHAVIOR_FNS[behavior] : undefined; + if (!fn) { + process.stderr.write(`unknown behavior: ${behavior}\n`); + return 2; + } + try { + await fn(); + } catch (err) { + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + return 1; + } + return 0; +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 1) { + process.stderr.write('usage: behaviors-node \n'); + process.exit(2); + } + process.exit(await dispatch(args[0])); +} + +main(); diff --git a/test/pg-compat/drivers/node/package-lock.json b/test/pg-compat/drivers/node/package-lock.json new file mode 100644 index 0000000000..c0bc5b56df --- /dev/null +++ b/test/pg-compat/drivers/node/package-lock.json @@ -0,0 +1,149 @@ +{ + "name": "pg-compat-behaviors-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pg-compat-behaviors-node", + "version": "1.0.0", + "dependencies": { + "pg": "8.13.1" + } + }, + "node_modules/pg": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz", + "integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/test/pg-compat/drivers/node/package.json b/test/pg-compat/drivers/node/package.json new file mode 100644 index 0000000000..bc11a12efe --- /dev/null +++ b/test/pg-compat/drivers/node/package.json @@ -0,0 +1,10 @@ +{ + "name": "pg-compat-behaviors-node", + "private": true, + "version": "1.0.0", + "description": "node-postgres (pg) behavior CLI for the pg-compat driver matrix harness.", + "main": "behaviors.js", + "dependencies": { + "pg": "8.13.1" + } +} diff --git a/test/pg-compat/drivers/prisma/behaviors.mjs b/test/pg-compat/drivers/prisma/behaviors.mjs new file mode 100644 index 0000000000..91256a5c0a --- /dev/null +++ b/test/pg-compat/drivers/prisma/behaviors.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * behaviors-prisma: Prisma ORM behavior CLI (SP3-Task 5, the ORM tier). + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): `behaviors-prisma ` where is one + * of connect, transactions, prepared, session_isolation. + * exit 0 -> behavior passed + * exit 1 -> behavior assertion failed (reason on stderr) + * exit 2 -> usage error only (unknown behavior name / wrong arg count). + * All four behaviors are implemented, so there is no NotImplementedError / + * "not yet wired" exit-2 path -- exit 2 is reserved for CLI misuse. + * No stdout output is required on pass. + * + * Why Prisma is in scope (and why failures here are FINDINGS, not blockers): + * Prisma is the notorious connection-pooler breaker. Its query engine (a + * Rust binary) ALWAYS speaks the extended protocol with server-side prepared + * statements and makes its own connection-pooling assumptions, which is + * exactly the combination that trips proxies that multiplex client sessions + * across a smaller set of backend connections. A behavior that works direct + * but fails through ProxySQL is a catalogued [[xfail]], not a bug to hide. + * + * ---- Env / URL contract (deliberately unchanged from the other drivers) ---- + * The harness only ever sets PGCOMPAT_PROXY_HOST (default "proxysql") and + * PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser", ssl disabled. Prisma's datasource in + * schema.prisma reads url = env("PGCOMPAT_PRISMA_URL"), so this program + * CONSTRUCTS that URL from the two proxy vars and injects it into + * process.env BEFORE instantiating any PrismaClient. That keeps the env + * contract identical to the other language programs (no new required vars): + * the schema's env() simply reads what this program planted. + * + * connection_limit=1 is pinned in the URL on purpose (see session_isolation + * below): Prisma maintains an INTERNAL connection pool per PrismaClient, so + * without this pin a SET on one query and a SHOW on the next could land on + * two different pooled backend connections WITHIN THE SAME CLIENT -- a false + * "leak" that has nothing to do with ProxySQL. Pinning the client to exactly + * one connection makes every statement issued through a given PrismaClient + * hit the same connection, so the isolation probe measures cross-CLIENT + * (i.e. cross backend-connection) state, which is the contract under test. + */ +'use strict'; + +// Build PGCOMPAT_PRISMA_URL from the proxy env vars and plant it BEFORE the +// PrismaClient import is instantiated. (ESM import bindings are resolved +// first, but PrismaClient reads the datasource env only when a client is +// constructed, so setting it here -- top of the module body -- is in time.) +const HOST = process.env.PGCOMPAT_PROXY_HOST || 'proxysql'; +const PORT = process.env.PGCOMPAT_PROXY_PORT || '6133'; +process.env.PGCOMPAT_PRISMA_URL = + `postgresql://testuser:testuser@${HOST}:${PORT}/testuser` + + `?sslmode=disable&connection_limit=1`; + +import { PrismaClient } from '@prisma/client'; + +// TX_TABLE is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table -- see the +// plan's Global Constraints). +const TX_TABLE = 'behavior_tx_t_prisma'; + +// DISTINCTIVE_TZ is the session-isolation probe value. NEVER +// application_name -- ProxySQL lists it in ignore_vars, so it can never +// reflect a client SET through the proxy (see session_isolation.py's +// docstring). TimeZone is a tracked/forwarded/reset variable, a valid probe. +const DISTINCTIVE_TZ = 'Antarctica/Troll'; + +// newClient: the shared client-factory every behavior uses. Besides +// constructing the PrismaClient it applies the client_encoding=UTF8 pin the +// Global Constraints mark MUST-reproduce for every port. +// +// Encoding-pin mechanism (empirical answer, Task 5 review; evidence in the +// SP3-Task 5 report): Prisma's PostgreSQL connector does NOT propagate a +// `client_encoding` URL param -- it is accepted but IGNORED. Probed direct +// against the SQL_ASCII primary (so ProxySQL's own UTF8-forcing could not +// confound the answer): with NO param `SHOW client_encoding` already +// returns UTF8 (the Rust query engine unconditionally sets UTF8 on its +// connections), and even `client_encoding=LATIN1` in the URL still yields +// UTF8 -- proof the param is discarded, while psycopg direct with no pin +// sees the true backend default SQL_ASCII. So the engine structurally +// guarantees UTF8 today; the explicit SET below is the pgjdbc-precedent +// fallback (URL param doesn't propagate -> SET right after construction), +// keeping this port's pin EXPLICIT like the other four instead of relying +// on an undocumented engine default. connection_limit=1 (module header) +// guarantees the SET lands on the same single connection every subsequent +// statement of this client uses. +async function newClient() { + const prisma = new PrismaClient(); + await prisma.$executeRawUnsafe("SET client_encoding TO 'UTF8'"); + return prisma; +} + +// firstVal: pull the single scalar out of a one-row/one-column raw result, +// independent of what Prisma named the column (SHOW returns "TimeZone", +// "client_encoding", etc.). Mirrors the other drivers' Object.values() dance. +function firstVal(rows) { + return Object.values(rows[0])[0]; +} + +// connect: a fresh client can run a trivial query, and the client_encoding +// pin ProxySQL imposes is visible. Mirrors behaviors/connect.py plus the +// UTF8 assertion the node port also carries (recorded SP-2 finding: the +// SQL_ASCII backend reports UTF8 through ProxySQL). +async function connect() { + const prisma = await newClient(); + try { + // int4 literal -> Prisma returns a JS number for `one`; coerce with + // Number() defensively and compare to 1. + const rows = await prisma.$queryRaw`SELECT 1 AS one`; + const one = Number(firstVal(rows)); + if (one !== 1) { + throw new Error(`SELECT 1 returned ${one}, want 1`); + } + const enc = firstVal(await prisma.$queryRawUnsafe('SHOW client_encoding')); + if (enc !== 'UTF8') { + throw new Error(`client_encoding is ${JSON.stringify(enc)}, want "UTF8" (ProxySQL pin did not take effect)`); + } + } finally { + await prisma.$disconnect(); + } +} + +// verifyCount runs the RW-split-safe verification read INSIDE its own +// $transaction (Prisma interactive transaction). Mirrors the other ports' +// "verify read inside its own BEGIN/COMMIT": BEGIN does not match ^SELECT so +// it takes the writer hostgroup, and ProxySQL pins the whole interactive +// transaction to that one backend connection -- so the count is read from +// the same node the INSERT/COMMIT hit, never a lagging replica. The +// count(*)::int cast is deliberate: count(*) is int8, which Prisma would +// return as a JS BigInt; casting to int4 makes Prisma hand back a plain JS +// number so the `=== 0` / `=== 1` comparisons below are apples-to-apples. +// The cast also preserves the distinctive `AS verify_read` alias used for +// pg_stat_statements traceability across all the language ports. +async function verifyCount(prisma) { + const rows = await prisma.$transaction(async (tx) => { + return tx.$queryRawUnsafe(`SELECT count(*)::int AS verify_read FROM ${TX_TABLE}`); + }); + return Number(rows[0].verify_read); +} + +// transactions: $transaction rollback/commit semantics honored end-to-end. +// Mirrors behaviors/transactions.py; the ORM adaptation of "rollback" is the +// documented one: in a Prisma INTERACTIVE transaction there is no explicit +// rollback() call -- THROWING out of the callback makes Prisma roll the +// transaction back. So the rollback leg wraps the INSERT in $transaction and +// deliberately throws "force-rollback", which we catch; the committing leg +// simply returns normally from the callback, so Prisma COMMITs. +async function transactions() { + const prisma = await newClient(); + try { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${TX_TABLE}`); + await prisma.$executeRawUnsafe(`CREATE TABLE ${TX_TABLE} (id int)`); + + // Rollback leg: throw inside the interactive txn -> Prisma rolls back. + let rolledBack = false; + try { + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`INSERT INTO ${TX_TABLE} VALUES (1)`); + throw new Error('force-rollback'); + }); + } catch (e) { + if (e && e.message === 'force-rollback') { + rolledBack = true; + } else { + throw e; // an UNEXPECTED error (e.g. proxy rejected the statement) + } + } + if (!rolledBack) { + throw new Error('interactive transaction did not throw as expected for the rollback leg'); + } + + let count = await verifyCount(prisma); + if (count !== 0) { + throw new Error(`rollback did not discard the insert: count=${count}, want 0`); + } + + // Commit leg: return normally -> Prisma commits. + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`INSERT INTO ${TX_TABLE} VALUES (2)`); + }); + + count = await verifyCount(prisma); + if (count !== 1) { + throw new Error(`commit did not persist the insert: count=${count}, want 1`); + } + } finally { + // Leave no state behind whether or not the assertions above passed + // (mirrors the other ports' finally-equivalent cleanup). A cleanup + // failure is caught and only printed -- it must never mask the original + // error propagating out of this try block. + try { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${TX_TABLE}`); + } catch (e) { + process.stderr.write(`cleanup failed (suppressed, not the real error): ${e}\n`); + } + await prisma.$disconnect(); + } +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// Prisma's distinct (and most-hostile-to-poolers) mechanism: its Rust query +// engine ALWAYS uses the extended protocol with server-side prepared +// statements -- there is no "simple text substitution" mode and no +// threshold to cross (unlike psycopg3's auto-prepare@5 or pgjdbc's +// prepareThreshold). Every one of the 50 $queryRaw calls below therefore +// issues a real Parse/Bind/Execute the engine multiplexes over its single +// (connection_limit is 1 here) backend connection. The tagged-template +// interpolation ${i}/${1} becomes bound parameters $1/$2 -- NOT text +// splicing -- which is exactly the prepared-statement path this port exists +// to probe through the proxy. +async function prepared() { + const prisma = await newClient(); + try { + for (let i = 0; i < 50; i++) { + // ::int (int4) result -> Prisma returns a JS number for `sum` + // (int8/BigInt would only appear for an uncast count()/bigint column; + // verified empirically that int4 comes back as number). Number() makes + // the coercion explicit and tolerant if a build ever returns BigInt. + const rows = await prisma.$queryRaw`SELECT ${i}::int + ${1}::int AS sum`; + const sum = Number(rows[0].sum); + if (sum !== i + 1) { + throw new Error(`iteration ${i}: got ${sum}, want ${i + 1}`); + } + } + } finally { + await prisma.$disconnect(); + } +} + +// sessionIsolation: session state set on one CLIENT must not leak to a +// different CLIENT. Mirrors behaviors/session_isolation.py, including +// closing A before opening B (so B *can* reuse A's just-freed backend +// connection -- the reuse case ProxySQL must reset). Two PrismaClient +// instances, each pinned to connection_limit=1 (see the module header) so +// the SET and the SHOW on A are guaranteed to run on the SAME backend +// connection within A -- otherwise Prisma's internal pool could scatter them +// and produce a false negative unrelated to ProxySQL. +async function sessionIsolation() { + const a = await newClient(); + let b = null; + let aClosed = false; + try { + await a.$executeRawUnsafe(`SET TimeZone = '${DISTINCTIVE_TZ}'`); + const tzA = firstVal(await a.$queryRawUnsafe('SHOW TimeZone')); + if (tzA !== DISTINCTIVE_TZ) { + throw new Error(`SHOW TimeZone (A) = ${JSON.stringify(tzA)}, want ${JSON.stringify(DISTINCTIVE_TZ)}`); + } + // Disconnect A before B connects (deliberate -- see the doc comment). + await a.$disconnect(); + aClosed = true; + + b = await newClient(); + const tzB = firstVal(await b.$queryRawUnsafe('SHOW TimeZone')); + if (tzB === DISTINCTIVE_TZ) { + throw new Error(`session state leaked across connections: B's TimeZone is ${JSON.stringify(tzB)}`); + } + await b.$disconnect(); + b = null; + } finally { + if (!aClosed) { + try { await a.$disconnect(); } catch (e) { /* best-effort cleanup */ } + } + if (b !== null) { + try { await b.$disconnect(); } catch (e) { /* best-effort cleanup */ } + } + } +} + +const BEHAVIOR_FNS = { + connect, + transactions, + prepared, + session_isolation: sessionIsolation, +}; + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 1) { + process.stderr.write('usage: behaviors-prisma \n'); + process.exit(2); + } + const behavior = args[0]; + const fn = Object.prototype.hasOwnProperty.call(BEHAVIOR_FNS, behavior) + ? BEHAVIOR_FNS[behavior] : undefined; + if (!fn) { + process.stderr.write(`unknown behavior: ${behavior}\n`); + process.exit(2); + } + try { + await fn(); + } catch (err) { + // Any error out of a behavior body is a behavior assertion failure + // (exit 1) -- including a proxy rejecting a Prisma prepared statement, + // which is precisely the finding this program exists to surface. + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + process.exit(1); + } + process.exit(0); +} + +main(); diff --git a/test/pg-compat/drivers/prisma/package-lock.json b/test/pg-compat/drivers/prisma/package-lock.json new file mode 100644 index 0000000000..dec2ffe49c --- /dev/null +++ b/test/pg-compat/drivers/prisma/package-lock.json @@ -0,0 +1,120 @@ +{ + "name": "pg-compat-behaviors-prisma", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pg-compat-behaviors-prisma", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "5.22.0" + }, + "devDependencies": { + "prisma": "5.22.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + } + } +} diff --git a/test/pg-compat/drivers/prisma/package.json b/test/pg-compat/drivers/prisma/package.json new file mode 100644 index 0000000000..ad58580123 --- /dev/null +++ b/test/pg-compat/drivers/prisma/package.json @@ -0,0 +1,14 @@ +{ + "name": "pg-compat-behaviors-prisma", + "private": true, + "version": "1.0.0", + "description": "Prisma ORM behavior CLI for the pg-compat driver matrix harness (SP3-Task 5).", + "type": "module", + "main": "behaviors.mjs", + "dependencies": { + "@prisma/client": "5.22.0" + }, + "devDependencies": { + "prisma": "5.22.0" + } +} diff --git a/test/pg-compat/drivers/prisma/schema.prisma b/test/pg-compat/drivers/prisma/schema.prisma new file mode 100644 index 0000000000..88e6063168 --- /dev/null +++ b/test/pg-compat/drivers/prisma/schema.prisma @@ -0,0 +1,45 @@ +// Prisma schema for the pg-compat driver-matrix behavior program (SP3-Task 5). +// +// No REAL models are needed: every behavior uses Prisma's raw-query escape +// hatch ($queryRaw / $executeRawUnsafe / $transaction), so the generated +// client uses none of the model types. However, `prisma generate` (5.22.0) +// refuses to generate from a schema with zero models ("You don't have any +// models defined ... so nothing will be generated" -> non-zero exit), +// verified empirically on the runner toolchain. So we declare ONE dummy +// model `Unused`, @@map-ed to a table `pgcompat_prisma_unused` that is +// NEVER created and NEVER queried, purely to satisfy the generator. It has +// no runtime effect: behaviors.mjs never references `prisma.unused`. +// +// The datasource URL comes from PGCOMPAT_PRISMA_URL, which behaviors.mjs +// constructs at runtime from the PGCOMPAT_PROXY_HOST/PGCOMPAT_PROXY_PORT +// env contract and injects into process.env BEFORE instantiating +// PrismaClient (keeps the harness env contract unchanged -- no new required +// vars). At BUILD time `prisma generate` only needs the variable to exist +// (it does not connect), so the Dockerfile sets a throwaway value. + +datasource db { + provider = "postgresql" + url = env("PGCOMPAT_PRISMA_URL") +} + +generator client { + provider = "prisma-client-js" + // The runner final image is python:3.11-slim (Debian bookworm, OpenSSL 3), + // and the client is generated in node:22-bookworm-slim (also bookworm / + // OpenSSL 3). The query-engine binary target for that platform is + // debian-openssl-3.0.x. Verified empirically: the engine downloaded at + // build time for this target loads and runs under the python:3.11-slim + // final stage (see the SP3-Task 5 report). "native" would resolve to the + // same file when generating inside bookworm, but pinning the explicit + // target makes the build reproducible regardless of the generating host. + binaryTargets = ["debian-openssl-3.0.x"] +} + +// Dummy model to satisfy `prisma generate` (see the datasource comment +// above). Mapped to a table that is never created; behaviors use only raw +// queries and never touch this model. +model Unused { + id Int @id + + @@map("pgcompat_prisma_unused") +} diff --git a/test/pg-compat/drivers/python/__init__.py b/test/pg-compat/drivers/python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/drivers/python/adapter.py b/test/pg-compat/drivers/python/adapter.py new file mode 100644 index 0000000000..6e75d3d951 --- /dev/null +++ b/test/pg-compat/drivers/python/adapter.py @@ -0,0 +1,70 @@ +"""Reference driver adapter: psycopg3 against the ProxySQL PG frontend. + +This is the reuse mechanism SP-3 hooks Java/Go/Node adapters into: each +behavior in ``behaviors/`` is written once against a small adapter +interface (``connect`` via the constructor, ``exec_simple``, ``exec_params``, +``begin``/``commit``/``rollback``, ``close``) and every driver gets the same +behavior for free by implementing that interface. + +Named-prepared-statement methods (``prepare(name, sql)`` / +``exec_prepared(name, params)``) are deliberately NOT implemented here. +psycopg3 has no explicit named-prepared-statement API of its own -- it +auto-prepares a parameterized statement after it has been executed +``prepare_threshold`` (default 5) times on the same connection (see +psycopg's ``prepared.py``), which is exactly what ``behaviors/prepared.py`` +exercises through ``exec_params``. Add ``prepare``/``exec_prepared`` here +(and to the shared behaviors) only when a driver that needs explicit named +statements (e.g. a Node/Go client) is wired up in SP-3. + +Env contract: connects to ``PGCOMPAT_PROXY_HOST``/``PGCOMPAT_PROXY_PORT`` +(testuser/testuser, db ``testuser`` by default) -- the same ProxySQL PG +frontend used everywhere else in this harness (see conftest.py / +harness/targets.py). ``client_encoding=UTF8`` is pinned in the DSN for the +same reason targets.py pins it: the dbdeployer backend databases default to +SQL_ASCII, which psycopg maps to Python's restrictive 'ascii' codec. +""" +import os + +import psycopg + + +class PsycopgAdapter: + def __init__(self, dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"] + p = os.environ["PGCOMPAT_PROXY_PORT"] + self.conn = psycopg.connect( + f"host={h} port={p} user=testuser password=testuser dbname={dbname} " + f"sslmode=disable client_encoding=UTF8", + autocommit=True, + ) + + def exec_simple(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def exec_params(self, sql, params, binary=False): + with self.conn.cursor(binary=binary) as cur: + cur.execute(sql, params) + return cur.fetchall() if cur.description else None + + def begin(self): + self.conn.autocommit = False + + def commit(self): + self.conn.commit() + self.conn.autocommit = True + + def rollback(self): + self.conn.rollback() + self.conn.autocommit = True + + def close(self): + # Idempotent: behaviors/session_isolation.py closes its first + # connection explicitly before opening the second (deliberately, so + # the second connection can land on the same freed backend), then + # closes it again from a `finally` guarding the whole behavior body. + # Guard on psycopg's `closed` property so the repeat call is a safe + # no-op instead of erroring on an already-closed connection. + if not self.conn.closed: + self.conn.close() diff --git a/test/pg-compat/harness/__init__.py b/test/pg-compat/harness/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py new file mode 100644 index 0000000000..cdda331e06 --- /dev/null +++ b/test/pg-compat/harness/diff.py @@ -0,0 +1,206 @@ +"""The differential engine. + +Runs the same SQL statements against several targets (see harness.targets) +and asserts that every proxy target is byte-for-byte indistinguishable from +its FORMAT-MATCHED direct baseline: same status tag, same column names, same +type OIDs, same row values. ``compare`` returns ``(ok, detail_text)``. + +Format-matched: a ``*_binary`` proxy target is compared only against +``direct_binary`` and a ``*_text`` proxy target only against ``direct_text``. +This is deliberate -- psycopg decodes text and binary wire formats through +different code paths, so a proxy-vs-direct comparison is only apples-to-apples +within the same result format. + +Case metadata (parsed from ``-- key: value`` comment lines): + ``-- skip-targets: name1 name2`` targets to exclude + ``-- only-targets: name1 name2`` restrict to these targets + +Both target lists are matched with shell-style globs (``fnmatch``), so +``only-targets: proxy_native_*`` selects every native proxy target and a +name with no wildcard character is still an exact match. + ``-- transactional: false`` parsed for completeness; the shipped + pure-SELECT cases are stateless so it is + not acted on here. + +Native backend axis: for AVAILABLE native targets the backend mode is set via +the admin (``pgsql-use_native_backend_protocol``) before running, and the +GLOBAL variable's prior value is snapshotted/restored around the target loop +(see ``_run``) so one case's native-mode toggle never leaks into the next. +Unavailable targets (the norm today -- PR #5882 unmerged) are simply not run; +the test layer surfaces them as skips. ``compare`` therefore gracefully +handles absent targets -- only the proxy targets actually present in +``results`` are checked. +""" +import re +from fnmatch import fnmatchcase + +import psycopg + +from harness.targets import NATIVE_VAR, native_var_present + + +def _parse_meta(sql): + skip = set() + for m in re.findall(r"--\s*skip-targets:\s*(.+)", sql): + skip.update(m.split()) + only = set() + for m in re.findall(r"--\s*only-targets:\s*(.+)", sql): + only.update(m.split()) + return skip, only + + +def baseline_name(name): + """The direct target a proxy target must be compared against. + + Single definition shared by the target filter in ``_run`` and the assertion + in ``compare``: if the two disagreed about which baseline a proxy target + needs, ``_run`` could omit exactly the target ``compare`` then demands. + """ + return "direct_binary" if name.endswith("binary") else "direct_text" + + +def _matches_any(name, patterns): + # Shell-style globbing, so a documented pattern such as "proxy_native_*" + # actually selects the native targets. fnmatchcase (not fnmatch) keeps + # matching case-sensitive and platform-independent; target names are + # lowercase identifiers, and a plain name with no metacharacter still + # compares as an exact match. + return any(fnmatchcase(name, p) for p in patterns) + + +def _statements(sql): + # Strip comment lines PER-LINE before the ";"-split. The previous + # chunk-based filter (`split(";")` then drop chunks starting with "--") + # silently discarded an ENTIRE case whose first line is a metadata + # comment: with only one trailing ";" the whole file is a single chunk + # beginning with "--", so the comment AND the SQL were thrown away + # together and zero statements ran (a vacuous pass). Known limitations + # of this simple splitter: no ";" inside string literals, and no + # trailing "--" comments appended to statement lines. + body = "\n".join( + line for line in sql.splitlines() + if not line.strip().startswith("--") + ) + return [s.strip() for s in body.split(";") if s.strip()] + + +def _parse_case_file(case_file): + with open(case_file) as f: + sql = f.read() + # Metadata regexes run on the ORIGINAL text (comment lines included); + # only statement extraction works on the comment-stripped body. + skip, only = _parse_meta(sql) + return _statements(sql), skip, only + + +def _run_on(target, stmts, admin, native_present): + # Toggle the backend-protocol mode ONLY when the variable exists and the + # target participates in that axis (native_backend is not None). With the + # variable absent (today), native targets are unavailable and never reach + # here, and libpq/direct targets need no toggle (libpq is the only mode). + if native_present and target.native_backend is not None: + admin.set_var(NATIVE_VAR, bool(target.native_backend)) + admin.load_vars() + + out = [] + with psycopg.connect(target.dsn, autocommit=True) as conn: + for s in stmts: + with conn.cursor(binary=target.binary) as cur: + cur.execute(s) + cols = [(d.name, d.type_code) for d in (cur.description or [])] + rows = cur.fetchall() if cur.description else None + out.append((cur.statusmessage, cols, rows)) + return out + + +def _run(stmts, targets, admin, skip, only): + native_present = native_var_present(admin) if admin is not None else False + # Snapshot/restore NATIVE_VAR around the target loop. Dormant today (the + # variable is absent -- PR #5882 unmerged -- so native_present is False, + # snapshot() is never called, and this is a pure no-op: zero admin + # round-trips beyond the native_var_present() probe already required + # above). The day #5882 merges, native_present flips True and every + # _run_on() call that toggles a native target's backend-protocol mode + # (see _run_on) leaves the GLOBAL runtime variable at whatever it last + # set it to; without restoring it here that value would leak into every + # subsequent case run in the same process. Restoring in a ``finally`` + # guarantees the global is put back even if a target raises mid-loop. + saved = admin.snapshot([NATIVE_VAR]) if native_present else None + try: + selected = [ + t for t in targets + if t.available + and not _matches_any(t.name, skip) + and (not only or _matches_any(t.name, only)) + ] + # An only/skip list that selects proxy targets but drops their direct + # baselines would make compare() report "baseline unavailable" and fail + # the case no matter how transparent the proxy actually is -- the + # documented `only-targets: proxy_native_*` names no direct target at + # all. The baselines are what the assertion is *against*, not part of + # what is being selected, so pull each selected proxy target's + # format-matched baseline back in regardless of the filters. + chosen = {t.name for t in selected} + needed = {baseline_name(t.name) for t in selected if not t.name.startswith("direct")} + by_name = {t.name: t for t in targets} + for name in sorted(needed - chosen): + t = by_name.get(name) + if t is not None and t.available: + selected.append(t) + + results = {} + for t in selected: + results[t.name] = _run_on(t, stmts, admin, native_present) + return results + finally: + if saved is not None: + admin.restore(saved) + + +def run_case(case_file, targets, admin=None): + """Run every statement in ``case_file`` against all available targets.""" + stmts, skip, only = _parse_case_file(case_file) + if not stmts: + # An empty case must be a LOUD error, never a vacuous pass: with no + # statements every target returns [] and compare() trivially succeeds. + raise ValueError( + f"{case_file}: no executable statements parsed — " + "check comment/semicolon handling" + ) + return _run(stmts, targets, admin, skip, only) + + +def run_case_sql(sql, targets, admin=None): + """Inline-SQL variant of ``run_case`` (a SQL string, not a file).""" + skip, only = _parse_meta(sql) + stmts = _statements(sql) + if not stmts: + raise ValueError( + "inline case: no executable statements parsed — " + "check comment/semicolon handling" + ) + return _run(stmts, targets, admin, skip, only) + + +def compare(results): + """Every proxy target must equal its format-matched direct baseline. + + Absent targets are handled gracefully: only proxy targets present in + ``results`` are compared, each against its direct baseline (which is always + available). Returns ``(ok, detail_text)``. + """ + diffs = [] + for name in sorted(results): + if name.startswith("direct"): + continue + b_name = baseline_name(name) + b = results.get(b_name) + if b is None: + diffs.append(f"{name}: format-matched baseline {b_name} unavailable") + continue + res = results[name] + if res != b: + diffs.append( + f"{name} != {b_name}\n got: {res}\n base: {b}" + ) + return (not diffs, "\n".join(diffs)) diff --git a/test/pg-compat/harness/oracle.py b/test/pg-compat/harness/oracle.py new file mode 100644 index 0000000000..42e466af24 --- /dev/null +++ b/test/pg-compat/harness/oracle.py @@ -0,0 +1,91 @@ +"""Routing oracle: prove WHERE a query landed by reading each backend node's +own ``pg_stat_statements`` view. + +Design (a) from the task brief's two options: connect to every backend as +the sandbox **superuser** (``postgres``), not as ``testuser``. Two reasons: + + * ``pg_stat_statements_reset()`` requires superuser (or an explicit + ``GRANT EXECUTE``); ``testuser`` has neither. Rather than add a second + no-reset baseline-delta design (option (b)), connecting as ``postgres`` + keeps the API a simple reset()/calls_for() pair, matching the brief. + * The superuser sees ALL sessions' statements on that node (not just its + own), which matters here because the workload runs through ProxySQL as + ``testuser`` while the oracle needs a clear, unfiltered view. + +The password is NOT a fixed/default credential: the infra entrypoint +(test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh) sets +``ALTER ROLE postgres WITH PASSWORD '${ROOT_PASSWORD}'`` where +``ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10)`` (see +docker-compose-init.bash). ``run-pg-compat.bash`` already forwards +``INFRA_ID`` into the pytest container (``-e INFRA_ID``), so the same +derivation is reproduced here in Python rather than requiring a new env +var or touching the infra entrypoint. + +Verified live against sdd-sp2: connecting as +``postgres`` / ``sha256("sdd-sp2")[:10]`` to each of the three per-node +DSNs (host+port from PGCOMPAT_{PRIMARY,REPLICA1,REPLICA2}_{HOST,PORT} -- +there is no single PGCOMPAT_BACKEND_PORT, see harness/targets.py) succeeds, +``pg_stat_statements_reset()`` runs, and a probe query +``SELECT 42 AS oracle_probe`` is normalized by pg_stat_statements to +``SELECT $1 AS oracle_probe`` (literal folded to a parameter placeholder, +as expected). A substring pattern like ``%oracle_probe%`` still matches the +normalized text since "oracle_probe" itself is not a literal that gets +folded, so callers do not need to spell out ``$1`` in their pattern. +""" +import hashlib +import os + +import psycopg + +_BACKENDS = { + "primary": ("PGCOMPAT_PRIMARY_HOST", "PGCOMPAT_PRIMARY_PORT"), + "replica1": ("PGCOMPAT_REPLICA1_HOST", "PGCOMPAT_REPLICA1_PORT"), + "replica2": ("PGCOMPAT_REPLICA2_HOST", "PGCOMPAT_REPLICA2_PORT"), +} + + +def _root_password(): + # Same derivation as docker-compose-init.bash: + # ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10) + infra_id = os.environ["INFRA_ID"] + return hashlib.sha256(infra_id.encode()).hexdigest()[:10] + + +def _dsn(host, port): + return ( + f"host={host} port={port} user=postgres password={_root_password()} " + f"dbname=testuser sslmode=disable client_encoding=UTF8" + ) + + +def _connect(name): + host_var, port_var = _BACKENDS[name] + return psycopg.connect( + _dsn(os.environ[host_var], os.environ[port_var]), + autocommit=True, + ) + + +def reset_all(): + """Reset pg_stat_statements counters on every backend node.""" + for name in _BACKENDS: + with _connect(name) as conn, conn.cursor() as cur: + cur.execute("SELECT pg_stat_statements_reset()") + + +def calls_for(pattern): + """Sum of ``calls`` per node for statements whose normalized query text + LIKE-matches ``pattern`` (e.g. ``"%oracle_probe%"``). + + Returns ``{"primary": int, "replica1": int, "replica2": int}``. + """ + out = {} + for name in _BACKENDS: + with _connect(name) as conn, conn.cursor() as cur: + cur.execute( + "SELECT COALESCE(SUM(calls), 0) FROM pg_stat_statements " + "WHERE query LIKE %s", + (pattern,), + ) + out[name] = int(cur.fetchone()[0]) + return out diff --git a/test/pg-compat/harness/proxysql.py b/test/pg-compat/harness/proxysql.py new file mode 100644 index 0000000000..c26fabc141 --- /dev/null +++ b/test/pg-compat/harness/proxysql.py @@ -0,0 +1,106 @@ +"""Admin config primitive for the pg-compat suite. + +Talks to ProxySQL's admin interface over the PG wire protocol (port 6132) +via psycopg. Provides the read/SET/LOAD/snapshot/restore cycle that later +pg-compat tests use to flip a runtime variable, exercise behavior, and put +the variable back. + +Deviation from the brief: the brief's DSN used user/password `admin`/`admin`. +Empirically, ProxySQL's PG-protocol admin interface rejects the literal +username `admin` from any peer that is not 127.0.0.1/::1/localhost +(PgSQL_Session.cpp: "User '%s' can only connect locally" — the check is a +strcmp() against the literal string "admin", scoped to the ADMIN_HOSTGROUP +default hostgroup). Since this harness always connects from a separate +container over the docker network, the literal `admin` user can never log +in here. ProxySQL ships a second admin credential pair via +`admin-admin_credentials` (default `admin:admin;radmin:radmin`) where +`radmin` maps to the same ADMIN_HOSTGROUP/privileges but is NOT subject to +the localhost-only check (the strcmp only matches "admin"). So this harness +authenticates as radmin/radmin, verified against the running sdd-sp2 infra: + docker exec psql -h proxysql -p 6132 -U admin -d admin ... -> FATAL: User 'admin' can only connect locally + docker exec psql -h proxysql -p 6132 -U radmin -d admin ... -> works +""" +import os + +import psycopg + + +def _admin_dsn(): + host = os.environ["PGCOMPAT_ADMIN_HOST"] + port = os.environ["PGCOMPAT_ADMIN_PORT"] + # See module docstring: "admin" is restricted to loopback connections + # only; "radmin" carries the same admin privileges without that + # restriction, so it is what a remote (containerized) client must use. + return f"host={host} port={port} user=radmin password=radmin dbname=admin sslmode=disable" + + +def _sql_quote(value): + """SQL-quote a string literal for the ProxySQL admin parser. + + Escapes by doubling single quotes (verified live against the admin: + `SET pgsql-server_version='16.1''test'` stores `16.1'test` and reads + back correctly from global_variables). Backslashes are passed through + literally — the admin's SQLite-based parser uses standard-conforming + string literals and does not treat backslash as an escape character. + NUL cannot be represented in a SQL string literal, so it is rejected. + """ + if "\x00" in value: + raise ValueError("NUL byte not representable in a SQL string literal") + return "'" + value.replace("'", "''") + "'" + + +class Admin: + def __init__(self): + # prepare_threshold=None disables psycopg's automatic server-side + # prepared statements. ProxySQL's PG-protocol admin interface does NOT + # support the extended-query Parse/Bind path, so once psycopg silently + # promoted a repeated statement to a prepared one (default threshold = + # 5 executions) the admin returned "Feature not supported". Admin + # queries are cheap and infrequent, so plain simple-protocol execution + # is both correct and sufficient here. + self.conn = psycopg.connect( + _admin_dsn(), autocommit=True, prepare_threshold=None + ) + + def query(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def set_var(self, name, value): + # str -> SQL-quoted, single quotes doubled (see _sql_quote). + # Always quoting strings is safe even for numeric variables: + # verified live that SET name=1 and SET name='1' behave + # identically, so restore() (whose values from snapshot() + # are always str) round-trips every variable type. + # bool -> bare true/false (verified live: SET + # pgsql-connection_warming=true / =false are accepted and + # read back as "true"/"false"). Checked before the generic + # path because bool is a subclass of int. + # int/float -> bare, unquoted. + if isinstance(value, bool): + literal = "true" if value else "false" + elif isinstance(value, str): + literal = _sql_quote(value) + else: + literal = str(value) + self.query(f"SET {name}={literal}") + + def load_vars(self): + self.query("LOAD PGSQL VARIABLES TO RUNTIME") + + def snapshot(self, var_names): + # Variable names are expected to be literals from trusted call + # sites, but quote them with the same doubling as values for + # consistency/safety. + placeholders = ",".join(_sql_quote(v) for v in var_names) + rows = self.query( + "SELECT variable_name, variable_value FROM global_variables " + f"WHERE variable_name IN ({placeholders})" + ) + return dict(rows) + + def restore(self, saved): + for name, value in saved.items(): + self.set_var(name, value) + self.load_vars() diff --git a/test/pg-compat/harness/targets.py b/test/pg-compat/harness/targets.py new file mode 100644 index 0000000000..e7d33a9943 --- /dev/null +++ b/test/pg-compat/harness/targets.py @@ -0,0 +1,109 @@ +"""The 6 differential targets for the pg-compat engine. + +Each SQL case is run against every AVAILABLE target and ProxySQL must be +indistinguishable from a direct PostgreSQL backend (status tag, column +names, type OIDs, rows). The target matrix is a 3-way product: + + {proxy, direct} x {libpq-backend, native-backend} x {text, binary} + +collapsed to 6 because the native-backend axis does not apply to a direct +connection: + + proxy_libpq_text proxy_libpq_binary + proxy_native_text proxy_native_binary + direct_text direct_binary + +Native-backend axis (spec 2.2): the two ``proxy_native_*`` targets toggle +``pgsql-use_native_backend_protocol``. That variable does NOT exist in this +build (PR #5882 unmerged), so ``all_targets`` probes the admin once and marks +those two targets ``available=False`` with a reason. The differential test +reports them as pytest SKIPS (never silent omissions, never failures); when +#5882 merges the probe returns present and they light up with ZERO code +changes here. + +Env contract (see test/tap/groups/pg-compat/env.sh + run-pg-compat.bash): +proxy = ``PGCOMPAT_PROXY_HOST``/``PGCOMPAT_PROXY_PORT`` (testuser/testuser, +db testuser); direct primary = ``PGCOMPAT_PRIMARY_HOST``/ +``PGCOMPAT_PRIMARY_PORT`` (per-node vars -- there is NO PGCOMPAT_BACKEND_PORT). +""" +import os +from dataclasses import dataclass +from typing import Optional + +# psycopg is imported so callers can ``from harness import targets`` and reach +# the same driver the engine uses; diff.py performs the actual connections. +import psycopg # noqa: F401 + +NATIVE_VAR = "pgsql-use_native_backend_protocol" +NATIVE_ABSENT_REASON = f"{NATIVE_VAR} absent (PR #5882 not merged)" + + +def _dsn(host, port, dbname="testuser", user="testuser", pw="testuser"): + # client_encoding is pinned to UTF8 on EVERY target so proxy and direct + # runs are apples-to-apples. Without it the two sides get DIFFERENT + # defaults (verified live on the sdd-sp2 infra): the dbdeployer backend + # databases are SQL_ASCII, so a direct session defaults client_encoding + # to SQL_ASCII (psycopg then maps it to Python's 'ascii' codec and cannot + # even send non-ASCII SQL like 'héllo'), while a session THROUGH ProxySQL + # reports client_encoding=UTF8 (ProxySQL imposes it on its backend + # connections rather than inheriting the server default -- a session- + # default divergence flagged in the Task 6 report). Pinning the parameter + # standardizes the client side only; compare() still requires identical + # status/columns/OIDs/rows. + return ( + f"host={host} port={port} user={user} password={pw} " + f"dbname={dbname} sslmode=disable client_encoding=UTF8" + ) + + +def _proxy(): + return _dsn(os.environ["PGCOMPAT_PROXY_HOST"], os.environ["PGCOMPAT_PROXY_PORT"]) + + +def _direct(): + # Per-node env vars; there is deliberately no single PGCOMPAT_BACKEND_PORT + # in this infra (the dbdeployer container exposes three ports on one host). + return _dsn(os.environ["PGCOMPAT_PRIMARY_HOST"], os.environ["PGCOMPAT_PRIMARY_PORT"]) + + +@dataclass +class Target: + name: str + dsn: str + binary: bool + native_backend: Optional[bool] # True=native, False=libpq, None=direct (N/A) + available: bool = True + skip_reason: str = "" + + +def native_var_present(admin): + """True iff ProxySQL knows ``pgsql-use_native_backend_protocol``. + + Probes the admin once. Absent today (PR #5882 unmerged) -> the two native + targets are marked unavailable. + """ + rows = admin.query( + "SELECT count(*) FROM global_variables " + f"WHERE variable_name='{NATIVE_VAR}'" + ) + return int(rows[0][0]) > 0 + + +def all_targets(admin): + present = native_var_present(admin) + + def _native(name, binary): + return Target( + name, _proxy(), binary, True, + available=present, + skip_reason="" if present else NATIVE_ABSENT_REASON, + ) + + return [ + Target("proxy_libpq_text", _proxy(), False, False), + Target("proxy_libpq_binary", _proxy(), True, False), + _native("proxy_native_text", False), + _native("proxy_native_binary", True), + Target("direct_text", _direct(), False, None), + Target("direct_binary", _direct(), True, None), + ] diff --git a/test/pg-compat/harness/xfail.py b/test/pg-compat/harness/xfail.py new file mode 100644 index 0000000000..44590ba34b --- /dev/null +++ b/test/pg-compat/harness/xfail.py @@ -0,0 +1,42 @@ +"""Loader for the pg-compat xfail / finding catalogue (test/pg-compat/xfail.toml). + +Two independent sections live in that one file (see its header comment and +README.md for the full policy): + + [[xfail]] -- known divergences that currently fail a specific test_id. + load() returns these; conftest.py turns each into an + xfail(strict=False) marker on the matching test. + [[finding]] -- verified ProxySQL behavioral differences that do NOT + currently fail any test (e.g. because the harness + neutralizes them), so there is no test_id to attach a + marker to. findings() returns these for completeness / + tooling; nothing in conftest.py consumes them today. +""" +import os + +import tomli + +_XFAIL_TOML = os.path.join(os.path.dirname(__file__), "..", "xfail.toml") + + +def _load_toml(): + if not os.path.exists(_XFAIL_TOML): + return {} + with open(_XFAIL_TOML, "rb") as f: + return tomli.load(f) + + +def load(): + """Return the list of [[xfail]] entries (each a dict with test_id/mode/reason/ref). + + Returns [] if xfail.toml is missing entirely. + """ + return _load_toml().get("xfail", []) + + +def findings(): + """Return the list of [[finding]] entries (each a dict with summary/mode/detail/discovered_by/ref). + + Returns [] if xfail.toml is missing entirely. + """ + return _load_toml().get("finding", []) diff --git a/test/pg-compat/pytest.ini b/test/pg-compat/pytest.ini new file mode 100644 index 0000000000..3182775ee3 --- /dev/null +++ b/test/pg-compat/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +# -r xX: always surface xfailed/xpassed summary lines by default (discovery- +# phase reporting, spec sec 2.1) so an xpass -- a fix that closed a catalogued +# divergence in xfail.toml -- is never missed just because -rxX wasn't passed +# on the command line. Still overridable/augmentable via extra -r flags. +addopts = -rxX diff --git a/test/pg-compat/requirements.txt b/test/pg-compat/requirements.txt new file mode 100644 index 0000000000..57a1cd3ee2 --- /dev/null +++ b/test/pg-compat/requirements.txt @@ -0,0 +1,73 @@ +# Fully locked: exact versions for the whole transitive tree, each with the +# SHA-256 of every wheel pip may legitimately select on linux (pure-python, +# plus manylinux x86_64 and aarch64 for the compiled ones). Consumed with +# `pip install --require-hashes --only-binary :all:` in the Dockerfile, so an +# install aborts if any artifact does not match a hash recorded here. +# +# This file is generated, not hand-edited. To regenerate after a version bump: +# 1. edit the direct requirements below and resolve the tree: +# pip install --dry-run --only-binary :all: --report r.json -r +# 2. for each name==version in the report, take the sha256 of every wheel +# matching *-py3-none-any.whl / *manylinux*_{x86_64,aarch64}.whl from +# https://pypi.org/pypi///json +# +# Direct requirements are psycopg[binary], asyncpg, pytest and tomli; the rest +# are transitive. pytest is held at >=9.0.3 as a SECURITY FLOOR: +# GHSA-6w46-j5rx-g56g (insecure tmpdir handling) affects every release before +# 9.0.3, so no 8.x pin could pick up the fix. The suite uses only stable APIs +# and pytest 9 requires Python >=3.10, which the 3.11 base image satisfies. + +psycopg==3.2.13 \ + --hash=sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a +psycopg-binary==3.2.13 \ + --hash=sha256:00ac1f1832c11ebf7ce3e30cd9cd9ec4d32b7d4aabe02e5cc6dca1b6ecff215d \ + --hash=sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c \ + --hash=sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61 \ + --hash=sha256:1c9e7ddbb1fe0c99ebe73e4658722d6e6fb7058dacac0fbe98653cf01a7a6871 \ + --hash=sha256:27150515de5f709e4142429db6fd36a1d01f0b8b17d915b5f7bb095364465398 \ + --hash=sha256:7350d9cc4e35529c4548ddda34a1c17f28d3f3a8f792c25cd67e8a04952ed415 \ + --hash=sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b \ + --hash=sha256:9ac329532f36342ff99fc1aefdbb531563bec03c7bc3ae934c8347a7a61339df \ + --hash=sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b \ + --hash=sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45 \ + --hash=sha256:cbbac4cd5b0e14b91ad8244268ca3fc2f527d1a337b489af57d7669c9d2e1a24 \ + --hash=sha256:d3aec6e2f1cf4deb1b9a3ac287c0591479f3bd851d0a911d628f8c2c71c14f4a \ + --hash=sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847 \ + --hash=sha256:f062d725898bf6fc5cfc6349a0d08ee09f129deb14d7fcd5c30f9f1b349f39dc +asyncpg==0.30.0 \ + --hash=sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737 \ + --hash=sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a \ + --hash=sha256:26683d3b9a62836fad771a18ecf4659a30f348a561279d6227dab96182f46144 \ + --hash=sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f \ + --hash=sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956 \ + --hash=sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4 \ + --hash=sha256:5b290f4726a887f75dcd1b3006f484252db37602313f806e9ffc4e5996cfe5cb \ + --hash=sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3 \ + --hash=sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33 \ + --hash=sha256:a3479a0d9a852c7c84e822c073622baca862d1217b10a02dd57ee4a7a081f708 \ + --hash=sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af \ + --hash=sha256:f86b0e2cd3f1249d6fe6fd6cfe0cd4538ba994e2d8249c0491925629b9104d0f +pytest==9.1.1 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 +Pygments==2.20.0 \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +iniconfig==2.3.0 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pluggy==1.6.0 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +typing_extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 diff --git a/test/pg-compat/run-pg-compat.bash b/test/pg-compat/run-pg-compat.bash new file mode 100755 index 0000000000..a0e4107717 --- /dev/null +++ b/test/pg-compat/run-pg-compat.bash @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build the pg-compat pytest image and run it joined to the infra's backend +# network, so it can reach ProxySQL and the PG backends purely by DNS alias +# (no host ports are published — see the plan's Global Constraints). +set -euo pipefail +: "${INFRA_ID:?}"; : "${WORKSPACE:?}" +NETWORK="${INFRA_ID}_backend" + +# Populates PGCOMPAT_{PRIMARY,REPLICA1,REPLICA2}_{HOST,PORT} and +# PGCOMPAT_TOXI_* (per-node vars; there is no single PGCOMPAT_BACKEND_PORT). +source "${WORKSPACE}/test/tap/groups/pg-compat/env.sh" + +# ProxySQL frontend (PG protocol) and admin-over-PG-protocol ports/host. +# The proxysql container is started with --hostname proxysql and +# --network-alias proxysql on this network (see start-proxysql-isolated.bash). +export PGCOMPAT_PROXY_HOST="${PGCOMPAT_PROXY_HOST:-proxysql}" +export PGCOMPAT_PROXY_PORT="${PGCOMPAT_PROXY_PORT:-6133}" +export PGCOMPAT_ADMIN_HOST="${PGCOMPAT_ADMIN_HOST:-proxysql}" +export PGCOMPAT_ADMIN_PORT="${PGCOMPAT_ADMIN_PORT:-6132}" + +# --network=host: this environment's default docker bridge network has no +# egress to the internet from build containers (buildkit's isolated build +# network cannot resolve/reach deb.debian.org or PyPI at all, verified by +# hand: DNS and raw-IP both fail on the default network, while the host +# itself has working internet access). --network=host runs apt-get/pip +# install steps using the host's network namespace so the image can build +# in environments like this one; the resulting image is unaffected by the +# network used at build time. +docker build --network=host -t proxysql-pg-compat:latest "${WORKSPACE}/test/pg-compat" + +# Forward every PGCOMPAT_* env var currently set (from env.sh plus the +# PROXY_/ADMIN_ overrides above) as -e flags, so new vars added to env.sh +# in the future are picked up automatically without editing this script. +ENV_ARGS=() +while IFS='=' read -r name _; do + [ -n "${name}" ] || continue + ENV_ARGS+=("-e" "${name}") +done < <(env | grep '^PGCOMPAT_') + +# Report output bind mount. The container runs with --rm, so anything pytest +# writes to its own filesystem (e.g. a --junitxml file) is destroyed the +# moment the container exits -- it never reaches the host regardless of "$@". +# Any caller (CI included) that wants a report file back on the host MUST +# write it under /pg-compat-reports inside the container, e.g.: +# run-pg-compat.bash --junitxml=/pg-compat-reports/pg-compat.xml -rxX +# which lands at "${REPORT_DIR}/pg-compat.xml" on the host afterwards. +# Default REPORT_DIR is workspace-relative so CI's github.workspace-based +# artifact-upload path and a dev's ad-hoc invocation both work unmodified; +# override with PGCOMPAT_REPORT_DIR for a different host location. The +# container's default user is root, so files land root-owned on the host; +# CI chowns them back before uploading (see ci-pg-compat.yml). +REPORT_DIR="${PGCOMPAT_REPORT_DIR:-${WORKSPACE}/pg-compat-reports}" +mkdir -p "${REPORT_DIR}" + +docker run --rm --network "${NETWORK}" \ + -e INFRA_ID \ + "${ENV_ARGS[@]}" \ + -v "${REPORT_DIR}:/pg-compat-reports" \ + proxysql-pg-compat:latest "$@" diff --git a/test/pg-compat/tests/_subproc.py b/test/pg-compat/tests/_subproc.py new file mode 100644 index 0000000000..16096332f1 --- /dev/null +++ b/test/pg-compat/tests/_subproc.py @@ -0,0 +1,38 @@ +"""Run a per-language behavior program and translate its exit code into +pytest semantics. The CLI contract: ` ` -> exit 0 pass, +exit 1 assertion-failure (reason on stderr), exit 2 usage/infra error.""" +import os +import subprocess + +import pytest + +def run_behavior(program, behavior): + if not os.path.exists(program): + pytest.skip(f"{program} not present in this image") + timeout = int(os.environ.get("PGCOMPAT_BEHAVIOR_TIMEOUT", "120")) + try: + r = subprocess.run( + [program, behavior], capture_output=True, text=True, timeout=timeout, + env=os.environ.copy(), + ) + except subprocess.TimeoutExpired as exc: + # Report a hung driver as a test FAILURE, not a pytest ERROR: an + # uncaught TimeoutExpired aborts the item during call teardown and + # loses the partial output, which is exactly what identifies where the + # driver hung. TimeoutExpired's captured streams are bytes (or None) + # even with text=True, so decode defensively. + def _text(stream): + if stream is None: + return "" + return stream.decode(errors="replace") if isinstance(stream, bytes) else stream + + pytest.fail( + f"{program} {behavior} -> timed out after {timeout}s\n" + f"stderr:\n{_text(exc.stderr)}\nstdout:\n{_text(exc.stdout)}" + ) + if r.returncode == 0: + return + detail = f"{program} {behavior} -> exit {r.returncode}\nstderr:\n{r.stderr}\nstdout:\n{r.stdout}" + if r.returncode == 2: + pytest.fail(f"infra/usage error (not a behavior failure): {detail}") + pytest.fail(detail) diff --git a/test/pg-compat/tests/test_behaviors.py b/test/pg-compat/tests/test_behaviors.py new file mode 100644 index 0000000000..dac6c2d87e --- /dev/null +++ b/test/pg-compat/tests/test_behaviors.py @@ -0,0 +1,20 @@ +"""Shared, driver-agnostic behavior set run against the Python (psycopg3) +adapter. The reuse mechanism: each module under ``behaviors/`` defines a +single ``run(Adapter)`` that is written once against the small adapter +interface in ``drivers/python/adapter.py`` -- SP-3 adds Java/Go/Node +adapters and parametrizes this same behavior list over them, with zero +changes to the behavior modules themselves. +""" +import pytest + +from behaviors import connect, transactions, prepared, session_isolation +from drivers.python.adapter import PsycopgAdapter + +BEHAVIORS = [connect, transactions, prepared, session_isolation] + + +@pytest.mark.parametrize( + "behavior", BEHAVIORS, ids=[b.__name__.split(".")[-1] for b in BEHAVIORS] +) +def test_behavior_python(behavior): + behavior.run(PsycopgAdapter) diff --git a/test/pg-compat/tests/test_behaviors_go.py b/test/pg-compat/tests/test_behaviors_go.py new file mode 100644 index 0000000000..405be17968 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_go.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-go" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_go(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_java.py b/test/pg-compat/tests/test_behaviors_java.py new file mode 100644 index 0000000000..1a84adcaa5 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_java.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-java" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_java(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_node.py b/test/pg-compat/tests/test_behaviors_node.py new file mode 100644 index 0000000000..5c3a3cfd27 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_node.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-node" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_node(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_prisma.py b/test/pg-compat/tests/test_behaviors_prisma.py new file mode 100644 index 0000000000..200f0e7d38 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_prisma.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-prisma" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_prisma(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_differential.py b/test/pg-compat/tests/test_differential.py new file mode 100644 index 0000000000..de53a67c13 --- /dev/null +++ b/test/pg-compat/tests/test_differential.py @@ -0,0 +1,48 @@ +"""Differential transparency: ProxySQL must be indistinguishable from direct PG. + +Each ``cases/*.sql`` file is run against every available target and compared +against its format-matched direct baseline (see harness.diff.compare). + +The two native-backend targets are unavailable while PR #5882 is unmerged. +``test_target_available`` surfaces them as explicit, reasoned pytest SKIPS +(visible in ``-v`` output) rather than silently omitting them -- and they +flip to PASS automatically once the backend variable exists, with no change +to this file. +""" +import glob +import os + +import pytest + +from harness import targets, diff + +HERE = os.path.dirname(__file__) +CASE_FILES = sorted(glob.glob(os.path.join(HERE, "..", "cases", "*.sql"))) +CASE_IDS = [os.path.basename(f) for f in CASE_FILES] + +# Stable names of the full 6-target matrix (import-time safe: no admin conn). +TARGET_NAMES = [ + "proxy_libpq_text", + "proxy_libpq_binary", + "proxy_native_text", + "proxy_native_binary", + "direct_text", + "direct_binary", +] + + +@pytest.mark.parametrize("case_file", CASE_FILES, ids=CASE_IDS) +def test_case_is_transparent(admin, case_file): + tgts = targets.all_targets(admin) + results = diff.run_case(case_file, tgts, admin) + ok, detail = diff.compare(results) + assert ok, f"{os.path.basename(case_file)} diverged:\n{detail}" + + +@pytest.mark.parametrize("target_name", TARGET_NAMES) +def test_target_available(admin, target_name): + """One item per target; unavailable ones are skipped WITH their reason.""" + by_name = {t.name: t for t in targets.all_targets(admin)} + t = by_name[target_name] + if not t.available: + pytest.skip(t.skip_reason) diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py new file mode 100644 index 0000000000..fe1d25f6ad --- /dev/null +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -0,0 +1,142 @@ +"""Self-check: prove the differential engine actually DETECTS divergence. + +A test oracle that can never fail is worthless. This installs a ProxySQL +query rule that rewrites the canary ``SELECT 1 AS canary`` into +``SELECT 2 AS canary`` ON THE PROXY PATH ONLY, so the proxy targets return a +row that differs from every direct backend. The engine MUST then report +``compare(...) -> not ok``. The rule is always removed in ``finally``. + +Why a rewrite (replace_pattern) and not the brief's literal rule, and not an +error_msg rule -- verified live against the sdd-sp2 admin: + + * The brief's ``INSERT ... (match_digest, replace_pattern, ...)`` violates a + ProxySQL CHECK constraint: + CASE WHEN replace_pattern IS NULL THEN 1 + WHEN replace_pattern IS NOT NULL AND match_pattern IS NOT NULL + THEN 1 ELSE 0 END + i.e. a ``replace_pattern`` REQUIRES a ``match_pattern``. So we match on + ``match_pattern`` (raw query text) instead of ``match_digest``. + + * The infra ships RW-split rules ``rule_id=1`` (``^SELECT.*FOR UPDATE``) and + ``rule_id=101`` (``^SELECT``), both ``apply=1``. Rules evaluate in + ascending ``rule_id``; the first ``apply=1`` match STOPS processing. So a + high rule_id (e.g. the brief's 990001) never runs for a SELECT -- the + reader rule at 101 fires first. The self-check rule must therefore sort + BEFORE 101; we use ``rule_id=90``. + + * ``replace_pattern`` is preferred over ``error_msg`` because the rewrite + keeps BOTH the proxy and direct executions succeeding, so ``compare`` + exercises its real status/column/OID/row comparison. An ``error_msg`` rule + would make the proxy raise, aborting the run before ``compare`` is reached. + +Live evidence (psql through the proxy) with rule 90 installed: + SELECT 1 AS canary -> 2 (rewritten) +and removed: + SELECT 1 AS canary -> 1 +""" +import glob +import os + +from harness import targets, diff + +# Must sort before the infra RW-split reader rule (rule_id 101, apply=1). +SELFCHECK_RULE_ID = 90 + +CASES_DIR = os.path.join(os.path.dirname(__file__), "..", "cases") + + +def test_engine_detects_divergence(admin): + try: + # INSERT + LOAD inside the try so the finally ALWAYS deletes rule 90 + # and reloads, even if either setup statement fails partway (deleting + # a rule that was never inserted is harmless). + admin.query( + "INSERT INTO pgsql_query_rules " + "(rule_id,active,match_pattern,replace_pattern,re_modifiers,apply) " + f"VALUES ({SELFCHECK_RULE_ID},1,'SELECT 1 AS canary'," + "'SELECT 2 AS canary','CASELESS',1)" + ) + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + tgts = targets.all_targets(admin) + results = diff.run_case_sql("SELECT 1 AS canary", tgts, admin) + ok, detail = diff.compare(results) + assert not ok, ( + "differential engine FAILED to detect an injected rewrite " + f"divergence; results={results}" + ) + finally: + admin.query( + f"DELETE FROM pgsql_query_rules WHERE rule_id={SELFCHECK_RULE_ID}" + ) + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + + +def test_target_filter_supports_globs(): + """``only-targets``/``skip-targets`` are documented as glob patterns. + + They were matched with exact set membership, so a documented pattern such + as ``only-targets: proxy_native_*`` matched nothing and silently skipped + EVERY target -- a case that appears to pass while running no targets at + all. Needs no infra: this pins the matcher itself. + """ + assert diff._matches_any("proxy_native_binary", ["proxy_native_*"]) + assert diff._matches_any("proxy_native_text", ["proxy_native_*"]) + assert not diff._matches_any("proxy_libpq_text", ["proxy_native_*"]) + + # A plain name with no metacharacter must still be an exact match. + assert diff._matches_any("direct_text", ["direct_text"]) + assert not diff._matches_any("direct_text", ["direct_tex"]) + assert not diff._matches_any("direct_text", []) + + +def test_baseline_is_never_filtered_out(): + """A target filter must not drop the baseline the assertion needs. + + ``compare()`` checks each proxy target against its format-matched direct + baseline and reports "baseline unavailable" when it is missing. Since + ``only-targets``/``skip-targets`` name proxy targets (the documented + example is ``proxy_native_*``), a filter applied naively removes both + direct targets and the case then fails no matter how transparent the + proxy is. Needs no infra: this pins the pairing rule itself. + """ + assert diff.baseline_name("proxy_native_binary") == "direct_binary" + assert diff.baseline_name("proxy_libpq_binary") == "direct_binary" + assert diff.baseline_name("proxy_native_text") == "direct_text" + assert diff.baseline_name("proxy_libpq_text") == "direct_text" + + +def test_file_pipeline_executes_real_statements(admin): + """Guard against vacuous passes on the FILE-based path. + + Review of the first Task 6 iteration found ``_statements()`` discarded an + entire case file whose first line was a metadata comment (one trailing + ``;`` -> one chunk starting with ``--``), so ``run_case`` executed ZERO + statements, every target returned ``[]``, and ``compare`` passed on + ``[] == []``. The inline-SQL divergence self-check above could not catch + that (no leading comment in its SQL). This test permanently pins the + file-parsing pipeline: every shipped case file must parse to at least one + statement, and running a real case file end-to-end must yield, for EVERY + available target, a non-empty result list whose statements each returned + at least one row (the shipped cases are pure single-row SELECTs). + """ + case_files = sorted(glob.glob(os.path.join(CASES_DIR, "*.sql"))) + assert case_files, f"no case files found under {CASES_DIR}" + + # Every shipped case must parse to >= 1 executable statement. + for cf in case_files: + stmts, _, _ = diff._parse_case_file(cf) + assert stmts, f"{os.path.basename(cf)} parsed to zero statements" + + # And a real file run must produce real, non-empty results per target. + tgts = targets.all_targets(admin) + results = diff.run_case(case_files[0], tgts, admin) + available = [t.name for t in tgts if t.available] + assert sorted(results) == sorted(available), ( + f"expected results for every available target {available}, " + f"got {sorted(results)}" + ) + for name, res in results.items(): + assert res, f"{name}: empty result list — no statement executed" + for status, cols, rows in res: + assert cols, f"{name}: statement returned no columns ({status})" + assert rows, f"{name}: statement returned no rows ({status})" diff --git a/test/pg-compat/tests/test_routing_oracle.py b/test/pg-compat/tests/test_routing_oracle.py new file mode 100644 index 0000000000..498491e664 --- /dev/null +++ b/test/pg-compat/tests/test_routing_oracle.py @@ -0,0 +1,58 @@ +"""Routing oracle: prove *where* a query actually landed by reading each +backend node's own ``pg_stat_statements`` (harness/oracle.py), rather than +trusting ProxySQL's own reporting of what it routed. + +IMPORTANT subtlety (do not "fix" by routing verification reads through the +proxy): a verification query like ``SELECT count(*) FROM pg_stat_statements`` +issued THROUGH the proxy would itself match the ``^SELECT`` reader rule and +get routed to a replica, polluting the very counts being inspected. So +``oracle.calls_for()`` connects directly to each backend node (as the +sandbox superuser -- see harness/oracle.py docstring for why), never through +ProxySQL. +""" +from harness import oracle + +# Real (not TEMP) table: a TEMP table's DDL/DML still executes against +# whichever hostgroup the statement is routed to under the ^SELECT rule +# (write statements -> writer), but a TEMP table would never physically +# replicate to the readers even if a read against it were misrouted there -- +# which would silently launder a real routing bug. A REAL table makes a +# leaked read-on-replica or leaked write-on-replica actually observable via +# pg_stat_statements. Dropped at the end of the test for idempotency across +# repeated runs and across the differential suite (see run-pg-compat.bash). +WRITE_TABLE = "oracle_w" + + +def test_select_lands_on_a_reader(proxy_conn): + oracle.reset_all() + with proxy_conn.cursor() as cur: + for _ in range(20): + cur.execute("SELECT 42 AS oracle_probe") + cur.fetchone() + counts = oracle.calls_for("%oracle_probe%") + # read/write split: SELECTs must hit readers (replicas), never the + # primary/writer. + assert counts["primary"] == 0, f"SELECT hit the primary: {counts}" + assert counts["replica1"] + counts["replica2"] == 20, ( + f"reads not fully accounted for on the replicas: {counts}" + ) + + +def test_write_pins_to_primary(proxy_conn): + oracle.reset_all() + try: + with proxy_conn.cursor() as cur: + cur.execute(f"CREATE TABLE IF NOT EXISTS {WRITE_TABLE} (id int)") + cur.execute(f"INSERT INTO {WRITE_TABLE} VALUES (1)") + counts = oracle.calls_for(f"%{WRITE_TABLE}%") + assert counts["replica1"] == 0 and counts["replica2"] == 0, ( + f"write leaked to a replica: {counts}" + ) + assert counts["primary"] > 0, ( + f"write did not land on the primary at all: {counts}" + ) + finally: + # Always drop, even on assertion failure, so the table never leaks + # into the differential/selfcheck suites or a re-run of this test. + with proxy_conn.cursor() as cur: + cur.execute(f"DROP TABLE IF EXISTS {WRITE_TABLE}") diff --git a/test/pg-compat/tests/test_smoke.py b/test/pg-compat/tests/test_smoke.py new file mode 100644 index 0000000000..c2152e218e --- /dev/null +++ b/test/pg-compat/tests/test_smoke.py @@ -0,0 +1,28 @@ +"""Wiring smoke tests for the pg-compat harness. + +These prove: the pytest container reaches the ProxySQL PG frontend (6133) +and the ProxySQL admin over the PG protocol (6132) on the infra's backend +network, and the admin config primitive (snapshot/set/load/restore) works. +""" + + +def test_proxy_select_one(proxy_conn): + with proxy_conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone()[0] == 1 + + +def test_admin_reconfig_roundtrip(admin): + saved = admin.snapshot(["pgsql-authentication_method"]) + try: + admin.set_var("pgsql-authentication_method", 1) + admin.load_vars() + val = admin.query( + "SELECT variable_value FROM global_variables " + "WHERE variable_name='pgsql-authentication_method'" + ) + assert val[0][0] == "1" + finally: + # Must run even if the assertion above fails, so the suite never + # leaks state into a subsequent run. + admin.restore(saved) diff --git a/test/pg-compat/xfail.toml b/test/pg-compat/xfail.toml new file mode 100644 index 0000000000..4b8066c90f --- /dev/null +++ b/test/pg-compat/xfail.toml @@ -0,0 +1,60 @@ +# pg-compat xfail / finding catalogue +# +# This is a discovery-phase suite (spec sec 2.1): its job is to build a +# living FAILURE INVENTORY, not to stay all-green by construction. Two +# distinct kinds of entries live here: +# +# [[xfail]] -- a KNOWN DIVERGENCE that currently makes a specific test +# fail. Each entry maps one failing test_id to a reason + tracking ref, and +# conftest.py's pytest_collection_modifyitems() marks that test_id +# xfail(strict=False): +# - the test still runs every time; +# - while the divergence persists it reports "xfailed" (expected +# failure), not "failed" -- the suite stays green without anyone +# loosening an assertion; +# - once the underlying bug is fixed the SAME test starts passing and is +# reported as "xpassed" (visible with -rxX) BECAUSE strict=false. That +# is the signal to remove the entry -- the fix landed and the divergence +# is closed. Do NOT flip to strict=true as a "fix": the point of this +# catalogue is that a fix is detected automatically by the run, not by +# someone remembering to update the toml first. +# +# Format (see harness/xfail.py for the loader): +# +# [[xfail]] +# test_id = "tests/test_differential.py::test_case_is_transparent[002_bytea_json_array.sql]" +# mode = "native" # libpq | native | both -- which backend-protocol axis (spec 2.2) this applies to +# reason = "proxy_native_binary bytea OID mismatch on the young native backend path" +# ref = "PR #5882" +# +# There are currently NO active xfail entries: the suite is all-green +# (16 passed, 2 native-path skips pending PR #5882). The list below is +# intentionally empty. + +# [[finding]] -- a REAL, VERIFIED ProxySQL behavioral difference that was +# discovered while building this suite but that does NOT (today) fail any +# test -- typically because the harness neutralizes it (e.g. by pinning a +# session parameter so both sides of a differential comparison are +# apples-to-apples). It has no test_id to attach an xfail marker to, so it +# is recorded here instead, to make sure the finding isn't lost just +# because nothing is currently red. +# +# Fields: summary, mode (libpq | native | both), detail, discovered_by, ref. + +[[finding]] +summary = "ProxySQL imposes client_encoding=UTF8 on backend connections instead of inheriting the server default" +mode = "libpq" +detail = """ +Verified live on the sdd-sp2 infra against the SQL_ASCII dbdeployer backend: +a DIRECT session's client_encoding defaults to SQL_ASCII (the initdb +default), while a session THROUGH ProxySQL reports client_encoding=UTF8 +regardless of the backend's actual default (`SHOW client_encoding`: direct +-> SQL_ASCII, via proxy -> UTF8). This is a session-default / parameter- +transparency divergence, not a row-level query divergence, so it does not +fail the differential engine's compare(). The harness neutralizes it by +pinning client_encoding=UTF8 on EVERY target (proxy and direct alike) in +harness/targets.py::_dsn() so runs are apples-to-apples; that pin is why +this finding has no failing test_id to list under [[xfail]]. +""" +discovered_by = "task 6 differential engine (SQL_ASCII backend)" +ref = "SP-2 Task 6 report; candidate ProxySQL issue" diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index 91d9e0d0fe..f633f507a8 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -153,20 +153,24 @@ "pgsql-admin_metacmds-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-admin_metacmds_describe_all_tables-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-admin_metacmds_describe_queries-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], + "pgsql-auth_method_matrix-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-basic_tests-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-connection_parameters_test-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-copy_from_stdin_session_parameter-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-copy_from_test-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-copy_to_test-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], + "pgsql-datatype_matrix-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-extended_query_protocol_query_rules_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-extended_query_protocol_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-hostgroup_default_query_timeout-t" : [ "legacy-g6","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-issue5384-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], + "pgsql-listen_notify_contract-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-monitor_ssl_connections_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-multiplex_status_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-notice_test-t" : [ "legacy-g6","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-options_startup_params-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-parameterized_kill_queries_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], + "pgsql-pool_churn-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-proxysql_cmd_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-query_cache_soft_ttl_pct-t" : [ "legacy-g6","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], "pgsql-query_cache_test-t" : [ "legacy-g6","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], @@ -187,6 +191,7 @@ "pgsql-reg_test_5899_bind_zero_param_formats-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-retry_guard_in_txn_on_broken_backend-t" : [ "legacy-g2" ], "pgsql-scram_cache_invalidation-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], + "pgsql-server_side_cursors-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-servers_ssl_params-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4" ], "pgsql-set_parameter_validation_test-t" : [ "legacy-g6","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1","set_parser_algorithm_3-g1" ], "pgsql-set_statement_test-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4","set_parser_algorithm_3-g1" ], diff --git a/test/tap/groups/pg-compat/env.sh b/test/tap/groups/pg-compat/env.sh new file mode 100644 index 0000000000..621ef8626b --- /dev/null +++ b/test/tap/groups/pg-compat/env.sh @@ -0,0 +1,25 @@ +# pg-compat TAP group environment (SP-2 polyglot PG test foundation). +# +# The backend is the dbdeployer PG17 primary + 2-replica infra, which deploys +# all three nodes in ONE container (single hostname, three ports). Downstream +# SP-2 tasks (Toxiproxy, ProxySQL config, pytest harness) consume THESE vars. + +export INFRA_TYPE="infra-dbdeployer-pgsql17-repl" + +# Single container -> one host, three auto-derived ports (17.10 => 16710-16712). +export PGCOMPAT_PRIMARY_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_PRIMARY_PORT="16710" +export PGCOMPAT_REPLICA1_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_REPLICA1_PORT="16711" +export PGCOMPAT_REPLICA2_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_REPLICA2_PORT="16712" + +# Toxiproxy sidecar sits between ProxySQL and each PG backend node so later +# SP-2/SP-4 tasks can degrade backends individually (passthrough only today). +export PGCOMPAT_TOXI_ADMIN="toxiproxy.${INFRA_ID}:8474" +export PGCOMPAT_TOXI_PRIMARY_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_PRIMARY_PORT="6001" +export PGCOMPAT_TOXI_REPLICA1_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_REPLICA1_PORT="6002" +export PGCOMPAT_TOXI_REPLICA2_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_REPLICA2_PORT="6003" diff --git a/test/tap/groups/pg-compat/infras.lst b/test/tap/groups/pg-compat/infras.lst new file mode 100644 index 0000000000..46ae11b621 --- /dev/null +++ b/test/tap/groups/pg-compat/infras.lst @@ -0,0 +1 @@ +infra-dbdeployer-pgsql17-repl diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index 2060351ece..fedf75dea6 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -416,27 +416,25 @@ test_wexecvp_syscall_failures-t: test_wexecvp_syscall_failures-t.cpp $(TAP_LDIR) $(CXX) $< $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) -Wl,--wrap=pipe,--wrap=fcntl,--wrap=read,--wrap=poll $(STATIC_LIBS) -o $@ endif -pgsql-extended_query_protocol_test-t: pgsql-extended_query_protocol_test-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -pgsql-reg_test_5273_bind_parameter_format-t: pgsql-reg_test_5273_bind_parameter_format-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -pgsql-reg_test_5899_bind_zero_param_formats-t: pgsql-reg_test_5899_bind_zero_param_formats-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -pgsql-reg_test_5300_threshold_resultset_deadlock-t: pgsql-reg_test_5300_threshold_resultset_deadlock-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -pgsql-reg_test_5866_result_format-t: pgsql-reg_test_5866_result_format-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -test_ffto_pgsql_pipeline-t: test_ffto_pgsql_pipeline-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - -test_ffto_pgsql_stmt_portal-t: test_ffto_pgsql_stmt_portal-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap$(SHLIB_EXT) - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ - +# Every test that links pg_lite_client.cpp shares one link line. pg_lite_client +# implements PostgreSQL MD5 and SCRAM-SHA-256 client authentication, so it needs +# -lscram/-lusual; $(ALLOW_MULTI_DEF) resolves the duplicate symbols between +# libscram/libusual and the other vendored static libs (test binaries only, and +# it is empty on Darwin where the linker has no such flag). +PG_LITE_CLIENT_TESTS := \ + pgsql-extended_query_protocol_test-t \ + pgsql-reg_test_5273_bind_parameter_format-t \ + pgsql-reg_test_5899_bind_zero_param_formats-t \ + pgsql-reg_test_5300_threshold_resultset_deadlock-t \ + pgsql-reg_test_5866_result_format-t \ + pgsql-auth_method_matrix-t \ + pgsql-datatype_matrix-t \ + pgsql-server_side_cursors-t \ + test_ffto_pgsql_pipeline-t \ + test_ffto_pgsql_stmt_portal-t + +$(PG_LITE_CLIENT_TESTS): %: %.cpp pg_lite_client.cpp pg_lite_client.h $(TAP_LDIR)/libtap$(SHLIB_EXT) + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual $(ALLOW_MULTI_DEF) -o $@ MYSQLX_PROTO_DIR := $(PROXYSQL_PATH)/plugins/mysqlx/proto MYSQLX_PROTO_SRCS := $(wildcard $(MYSQLX_PROTO_DIR)/*.pb.cc) diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index f423d6e305..2be67dedec 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -12,6 +12,11 @@ #include #include #include +#include + +extern "C" { +#include "scram.h" +} // Buffer writing helpers static void writeInt32ToBuffer(std::vector& buffer, int32_t value) { @@ -33,6 +38,8 @@ static void writeStringToBuffer(std::vector& buffer, const std::string& buffer.push_back(0); // Null terminator } +static std::string extractErrorMessage(const std::vector& buffer); + // ===== Connection Implementation ===== // Message helpers @@ -293,6 +300,17 @@ void PgConnection::sendStartupPacket() { writeBytes(fullPacket.data(), fullPacket.size()); } +// Reads the 4-byte authentication sub-type from an 'R' message payload. +// Every read of this field must go through here: readMessage() refills the +// buffer on each call, so the length has to be re-validated every time, and +// memcpy avoids the unaligned/strict-aliasing read that a cast would do. +static int32_t readAuthType(const std::vector& buffer) { + if (buffer.size() < 4) throw PgException("Invalid authentication message"); + int32_t netAuthType; + memcpy(&netAuthType, buffer.data(), 4); + return ntohl(netAuthType); +} + void PgConnection::handleAuthentication(const std::string& password) { char type; std::vector buffer; @@ -301,8 +319,8 @@ void PgConnection::handleAuthentication(const std::string& password) { readMessage(type, buffer); if (type == AUTH_TYPE) { - if (buffer.size() < 4) throw PgException("Invalid authentication message"); - int32_t authType = ntohl(*reinterpret_cast(buffer.data())); + int32_t authType = readAuthType(buffer); + if (last_auth_type_ == 0 && authType != 0) last_auth_type_ = authType; if (authType == 0) { // AuthenticationOK return; } @@ -310,27 +328,43 @@ void PgConnection::handleAuthentication(const std::string& password) { sendPassword(password); // After sending password, we need to wait for auth result readMessage(type, buffer); + if (type == ERROR_RESPONSE) + throw PgException("Authentication error: " + extractErrorMessage(buffer)); + if (type == AUTH_TYPE) { + authType = readAuthType(buffer); + if (authType == 0) return; + } + } + else if (authType == 5) { // AuthenticationMD5Password (4-byte salt follows) + if (buffer.size() < 8) throw PgException("Invalid MD5 auth message"); + uint8_t salt[4]; + memcpy(salt, buffer.data() + 4, 4); + sendMD5Password(password, salt); + readMessage(type, buffer); + if (type == ERROR_RESPONSE) + throw PgException("Authentication error: " + extractErrorMessage(buffer)); if (type == AUTH_TYPE) { - authType = ntohl(*reinterpret_cast(buffer.data())); + authType = readAuthType(buffer); if (authType == 0) return; } } + else if (authType == 10) { // AuthenticationSASL (mechanism list follows) + doSASLAuth(password, buffer); + return; // doSASLAuth consumes through AuthenticationOk + } else { throw PgException("Unsupported authentication method: " + std::to_string(authType)); } } else if (type == ERROR_RESPONSE) { - // Extract error message (field type 'M' is the message) - const char* ptr = reinterpret_cast(buffer.data()); - while (*ptr) ptr++; // Skip severity - ptr++; - if (*ptr) { - std::string errorMsg(ptr); + // extractErrorMessage() walks the field list within the buffer bounds; + // the previous hand-rolled scan here ran off the end of a truncated or + // unterminated ErrorResponse. + const std::string errorMsg = extractErrorMessage(buffer); + if (!errorMsg.empty()) { throw PgException("Authentication error: " + errorMsg); } - else { - throw PgException("Authentication error"); - } + throw PgException("Authentication error"); } } } @@ -342,6 +376,143 @@ void PgConnection::sendPassword(const std::string& password) { sendMessage('p', packet); } +// MD5 is not a security choice here: PostgreSQL's AuthenticationMD5Password +// (authType 5) defines the response as an MD5 construction on the wire, so a +// client that must exercise that auth path has to compute exactly this digest +// and nothing else. Test-only client code; ProxySQL's own MD5 auth support is +// what pgsql-auth_method_matrix-t exists to verify. +static std::string md5_hex(const std::string& in) { + unsigned char digest[MD5_DIGEST_LENGTH]; + MD5(reinterpret_cast(in.data()), in.size(), digest); // NOSONAR cpp:S4790 — PG MD5 auth is defined in terms of MD5 + static const char* hx = "0123456789abcdef"; + std::string out; + out.reserve(MD5_DIGEST_LENGTH * 2); + for (int i = 0; i < MD5_DIGEST_LENGTH; ++i) { + out.push_back(hx[digest[i] >> 4]); + out.push_back(hx[digest[i] & 0x0f]); + } + return out; +} + +// PostgreSQL MD5 auth: "md5" + md5( md5(password + user) + salt ) +void PgConnection::sendMD5Password(const std::string& password, const uint8_t salt[4]) { + std::string inner = md5_hex(password + user_); + std::string with_salt = inner; + with_salt.append(reinterpret_cast(salt), 4); + std::string token = "md5" + md5_hex(with_salt); + std::vector packet; + writeStringToBuffer(packet, token); // null-terminated C string + sendMessage('p', packet); +} + +// Parse the human-readable message ('M' field) out of an ErrorResponse ('E') payload. +// ErrorResponse body is a sequence of (1-byte field-type, null-terminated string), +// terminated by a zero field-type byte. +static std::string extractErrorMessage(const std::vector& buffer) { + size_t i = 0; + while (i < buffer.size() && buffer[i] != 0) { + char field = static_cast(buffer[i]); + ++i; + const char* start = reinterpret_cast(buffer.data() + i); + size_t len = 0; + while (i + len < buffer.size() && buffer[i + len] != 0) ++len; + std::string value(start, len); + i += len + 1; // skip the value and its null terminator + if (field == 'M') return value; + } + return std::string(); +} + +// Completes a SCRAM-SHA-256 SASL exchange as the CLIENT, reusing deps/libscram. +// mechListMsg is the AuthenticationSASL(10) payload after the 4-byte authType: +// a sequence of null-terminated mechanism names terminated by an extra null. +// (We do not parse it; ProxySQL offers SCRAM-SHA-256 and we answer with that.) +void PgConnection::doSASLAuth(const std::string& password, + const std::vector& /*mechListMsg*/) { + ScramState* st = scram_state_init(); + PgCredentials cred; + memset(&cred, 0, sizeof(cred)); + snprintf(cred.name, sizeof(cred.name), "%s", user_.c_str()); + snprintf(cred.passwd, sizeof(cred.passwd), "%s", password.c_str()); + cred.has_scram_keys = false; + + char type; + std::vector buffer; + char* client_first = nullptr; + char* client_final = nullptr; + + // 1) SASLInitialResponse ('p'): mechanism name + Int32 length + client-first-message. + // libscram's build_client_first_message already includes the "n,,"" GS2 header + // (it returns "n,,n=,r="), so we send it verbatim. + client_first = build_client_first_message(st); + if (!client_first) { free_scram_state(st); throw PgException(std::string("scram client-first: ") + scram_error()); } + { + std::vector pkt; + writeStringToBuffer(pkt, "SCRAM-SHA-256"); // null-terminated mechanism name + int32_t clen = htonl((int32_t)strlen(client_first)); + const uint8_t* cp = reinterpret_cast(&clen); + pkt.insert(pkt.end(), cp, cp + 4); // Int32 length of client-first + pkt.insert(pkt.end(), client_first, client_first + strlen(client_first)); + sendMessage('p', pkt); + } + + // 2) Expect AuthenticationSASLContinue (authType 11) with the server-first-message. + readMessage(type, buffer); + if (type == ERROR_RESPONSE) { + free(client_first); free_scram_state(st); + throw PgException("scram: " + extractErrorMessage(buffer)); + } + if (type != AUTH_TYPE || buffer.size() < 4 || readAuthType(buffer) != 11) { + free(client_first); free_scram_state(st); + throw PgException("expected AuthenticationSASLContinue(11)"); + } + std::string server_first(reinterpret_cast(buffer.data()) + 4, buffer.size() - 4); + char* server_nonce = nullptr; char* salt = nullptr; int saltlen = 0; int iterations = 0; + if (!read_server_first_message(st, const_cast(server_first.c_str()), + &server_nonce, &salt, &saltlen, &iterations)) { + free(client_first); free_scram_state(st); + throw PgException(std::string("scram read server-first: ") + scram_error()); + } + + // 3) SASLResponse ('p'): client-final-message (with proof derived from plaintext passwd). + client_final = build_client_final_message(st, &cred, server_nonce, salt, saltlen, iterations); + free(salt); // read_server_first_message malloc'd salt and handed us ownership; + salt = nullptr; // build_client_final_message is its only consumer (just read above). + if (!client_final) { free(client_first); free_scram_state(st); throw PgException(std::string("scram client-final: ") + scram_error()); } + { + std::vector pkt(client_final, client_final + strlen(client_final)); + sendMessage('p', pkt); + } + + // 4) Expect AuthenticationSASLFinal (authType 12) with server-final (v=ServerSignature). + // A wrong password surfaces here as an ErrorResponse instead. + readMessage(type, buffer); + if (type == ERROR_RESPONSE) { + free(client_first); free(client_final); free_scram_state(st); + throw PgException("scram: " + extractErrorMessage(buffer)); + } + if (type != AUTH_TYPE || buffer.size() < 4 || readAuthType(buffer) != 12) { + free(client_first); free(client_final); free_scram_state(st); + throw PgException("expected AuthenticationSASLFinal(12)"); + } + { + std::string server_final(reinterpret_cast(buffer.data()) + 4, buffer.size() - 4); + char server_sig[256] = {0}; + if (!read_server_final_message(const_cast(server_final.c_str()), server_sig) || + !verify_server_signature(st, &cred, server_sig)) { + free(client_first); free(client_final); free_scram_state(st); + throw PgException("scram server signature verification failed"); + } + } + free(client_first); free(client_final); free_scram_state(st); + + // 5) Expect AuthenticationOk (0). + readMessage(type, buffer); + if (type == ERROR_RESPONSE) throw PgException("scram: " + extractErrorMessage(buffer)); + if (type == AUTH_TYPE && buffer.size() >= 4 && readAuthType(buffer) == 0) return; + throw PgException("scram: no AuthenticationOk after SASLFinal"); +} + void PgConnection::waitForReady() { char type; std::vector buffer; diff --git a/test/tap/tests/pg_lite_client.h b/test/tap/tests/pg_lite_client.h index 189c2e928a..d11a1082ff 100644 --- a/test/tap/tests/pg_lite_client.h +++ b/test/tap/tests/pg_lite_client.h @@ -142,6 +142,7 @@ class PgConnection { void disconnect(); bool isConnected() const; inline int getSocket() const { return sock_; } + inline int getLastAuthType() const { return last_auth_type_; } void execute(const std::string& query); void executeParams( @@ -207,10 +208,13 @@ class PgConnection { int timeout_ms_ = 0; std::string user_; std::string dbname_; + int last_auth_type_ = 0; void sendStartupPacket(); void handleAuthentication(const std::string& password); void sendPassword(const std::string& password); + void sendMD5Password(const std::string& password, const uint8_t salt[4]); + void doSASLAuth(const std::string& password, const std::vector& mechListMsg); void sendParse(const std::string& query, const std::string& stmtName, const std::vector& paramType); diff --git a/test/tap/tests/pgsql-auth_method_matrix-t.cpp b/test/tap/tests/pgsql-auth_method_matrix-t.cpp new file mode 100644 index 0000000000..7583f460eb --- /dev/null +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -0,0 +1,114 @@ +#include +#include +#include +#include "pg_lite_client.h" // must precede mysql.h: mariadb_version.h #defines + // PROTOCOL_VERSION, colliding with PgConnection's + // static member of the same name (see + // test_ffto_pgsql_pipeline-t.cpp / _stmt_portal-t.cpp) +#include // admin interface is reached via the MySQL client +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; + +// Admin connection (MySQL protocol) used to flip pgsql-authentication_method. +static MYSQL* admin_connect() { + MYSQL* conn = mysql_init(NULL); + if (!mysql_real_connect(conn, cl.admin_host, cl.admin_username, cl.admin_password, + NULL, cl.admin_port, NULL, 0)) { + diag("admin connect failed: %s", mysql_error(conn)); + mysql_close(conn); + return NULL; + } + return conn; +} + +static bool set_frontend_auth_method(MYSQL* admin, int method) { + std::string q = "SET pgsql-authentication_method=" + std::to_string(method); + if (mysql_query(admin, q.c_str())) { diag("SET failed: %s", mysql_error(admin)); return false; } + if (mysql_query(admin, "LOAD PGSQL VARIABLES TO RUNTIME")) { diag("LOAD failed: %s", mysql_error(admin)); return false; } + return true; +} + +// Every floor switch is a PRECONDITION for the assertions that follow it: if the +// SET/LOAD silently fails, the previous floor is still in effect and the next +// assertions test the wrong thing -- e.g. a "scram floor" check would actually be +// exercising md5, and a wrong-password rejection could pass for the wrong reason. +// So a failed switch aborts the test rather than being ignored. +static void require_auth_method(MYSQL* admin, int method) { + if (!set_frontend_auth_method(admin, method)) { + BAIL_OUT("could not configure pgsql-authentication_method=%d (admin SET/LOAD failed)", method); + } +} + +// Attempts a frontend login with pg_lite_client, running a query to prove the +// session is usable. On success, observed_auth_type = the challenge type ProxySQL +// presented (3=cleartext, 5=md5, 10=scram). Returns true on successful auth+query. +static bool try_frontend_login(const std::string& user, const std::string& password, + int& observed_auth_type) { + observed_auth_type = 0; + try { + PgConnection c(2000); + c.connect(cl.pgsql_host, cl.pgsql_port, user /*dbname==user in this infra*/, user, password); + observed_auth_type = c.getLastAuthType(); + c.execute("SELECT 1"); // #5865 runs NO queries; proving the session works is our value-add + c.consumeInputUntilReady(); // actually round-trip the query (execute() only sends): wait for ReadyForQuery + c.disconnect(); + return true; + } catch (const PgException& e) { + diag("login threw: %s", e.what()); + return false; + } +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + + // Per method: (login succeeds + query runs) AND (observed challenge type matches floor). + // Task 1 lands cleartext only (2 assertions); Tasks 2-3 add md5, scram, and failures. + plan(9); + + MYSQL* admin = admin_connect(); + if (!admin) BAIL_OUT("cannot reach admin"); + + // --- Cleartext floor (method = 1) -> expect challenge type 3 on the wire --- + require_auth_method(admin, 1); // affects NEW frontend connections + int auth_type = 0; + bool logged_in = try_frontend_login(cl.pgsql_username, cl.pgsql_password, auth_type); + ok(logged_in, "cleartext floor: login + query succeed"); + ok(auth_type == 3, "cleartext floor: ProxySQL presented challenge type 3 (got %d)", auth_type); + + // --- MD5 floor (method = 2) -> expect challenge type 5 on the wire --- + require_auth_method(admin, 2); + int md5_auth = 0; + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password, md5_auth), + "md5 floor: login + query succeed"); + ok(md5_auth == 5, "md5 floor: ProxySQL presented challenge type 5 (got %d)", md5_auth); + + // --- SCRAM floor (method = 3) -> expect challenge type 10 on the wire --- + require_auth_method(admin, 3); + int scram_auth = 0; + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password, scram_auth), + "scram floor: login + query succeed"); + ok(scram_auth == 10, "scram floor: ProxySQL presented SASL/SCRAM challenge type 10 (got %d)", scram_auth); + + // --- Wrong-password failure paths, one per floor (challenge type irrelevant) --- + int ignore = 0; + require_auth_method(admin, 1); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "cleartext floor: wrong password rejected"); + require_auth_method(admin, 2); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "md5 floor: wrong password rejected"); + require_auth_method(admin, 3); + ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "scram floor: wrong password rejected"); + + // Restore the default floor before exit. Not a BAIL_OUT -- every assertion has + // already run -- but it must not be silent either: leaving the instance on a + // non-default auth floor would corrupt whichever test in this group runs next. + if (!set_frontend_auth_method(admin, 3)) { + diag("WARNING: failed to restore pgsql-authentication_method=3; " + "the instance is left on a non-default auth floor"); + } + mysql_close(admin); + return exit_status(); +} diff --git a/test/tap/tests/pgsql-datatype_matrix-t.cpp b/test/tap/tests/pgsql-datatype_matrix-t.cpp new file mode 100644 index 0000000000..966c530a39 --- /dev/null +++ b/test/tap/tests/pgsql-datatype_matrix-t.cpp @@ -0,0 +1,205 @@ +#include +#include +#include +#include "pg_lite_client.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; + +struct Case { + const char* label; + const char* select_expr; // e.g. "SELECT '\\xdeadbeef'::bytea" + const char* expected_text; // expected value in TEXT format + int32_t expected_oid; // PostgreSQL type OID + const char* expected_binary_hex; // expected wire bytes in BINARY format +}; + +// One representative literal per type; expand freely — adding a row is the unit of work. +// +// expected_binary_hex is the exact payload PostgreSQL's *_send() function emits for +// the literal in the same row. Asserting it (rather than merely asserting that *some* +// non-null payload came back) is what makes the binary half of this test meaningful: +// a silent text fallback, a truncated payload, or a byte-order regression anywhere in +// ProxySQL's PG result path all produce different bytes and are therefore caught. +// Encodings are per src/backend/utils/adt in PostgreSQL and are stable across versions: +// numeric -> int16 ndigits, int16 weight, uint16 sign, uint16 dscale, base-10000 digits +// timestamptz -> int64 microseconds since 2000-01-01 00:00:00 UTC +// jsonb -> 1-byte format version (0x01) followed by the jsonb text rendering +// int4[] -> int32 ndim, int32 hasnull, int32 elemtype, then (dim, lbound), then len+value per element +// inet -> family (2 = AF_INET), netmask bits, is_cidr, address length, address bytes +static const std::vector cases = { + { "bool", "SELECT true", "t", 16, + "01" }, + { "int4", "SELECT 2147483647::int4", "2147483647", 23, + "7fffffff" }, + { "int8", "SELECT 9223372036854775807::int8", "9223372036854775807", 20, + "7fffffffffffffff" }, + { "float8", "SELECT 1.5::float8", "1.5", 701, + "3ff8000000000000" }, + { "numeric", "SELECT 12345.6789::numeric", "12345.6789", 1700, + "0003000100000004000109291a85" }, + { "text_utf8", "SELECT 'héllo'::text", "héllo", 25, + "68c3a96c6c6f" }, + { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17, + "deadbeef" }, + { "uuid", "SELECT '00000000-0000-0000-0000-000000000001'::uuid", + "00000000-0000-0000-0000-000000000001", 2950, + "00000000000000000000000000000001" }, + // A genuine timestamptz (OID 1184). The earlier form applied AT TIME ZONE 'UTC', + // which yields timestamp *without* time zone (OID 1114) and so never exercised + // timestamptz at all. Text rendering of timestamptz depends on the session + // TimeZone GUC, which is why every case forces UTC before running (see run_case). + { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz", + "2020-01-01 00:00:00+00", 1184, + "00023e0786c26000" }, + { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802, + "017b2261223a20317d" }, + { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007, + "0000000100000000000000170000000300000001000000040000000100000004000000020000000400000003" }, + { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869, + "02200004c0a80001" }, +}; + +// Lowercase hex rendering of a raw payload, for both comparison and diagnostics. +static std::string to_hex(const std::vector& bytes) { + static const char* digits = "0123456789abcdef"; + std::string out; + out.reserve(bytes.size() * 2); + for (uint8_t b : bytes) { + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0f]); + } + return out; +} + +// Everything observed for one round-trip of a case at a given result format. +struct Observed { + bool got_result = false; // a RowDescription+DataRow was returned (no ErrorResponse) + int32_t oid = 0; // type OID from RowDescription + int16_t col_format = -1; // per-column result FORMAT CODE from RowDescription: + // this is what ProxySQL actually applied (0=text,1=binary), + // and is the load-bearing check for binary transparency. + bool is_null = true; // DataRow value length == -1 ? + std::string text_value; // value decoded as text (meaningful when col_format==0) + std::vector raw_bytes; // raw DataRow payload for column 0 (both formats) +}; + +// Runs one case through ProxySQL's PG frontend via pg_lite_client's extended-query +// protocol, requesting the given single result format (0=text, 1=binary) for column 0. +// +// We parse RowDescription(T)/DataRow(D) directly rather than calling readResult(): +// readResult() DISCARDS the type OID (pg_lite_client.cpp reads and drops it) and +// PgResult exposes no OID accessor, but this test must assert the OID. So we read the +// full RowDescription field layout ourselves — the SAME layout readResult() uses, +// including the trailing per-column format code that readResult() stores into +// columnFormat() — and additionally keep the OID. The format-code field is the whole +// point of the binary assertions: it proves ProxySQL honored the requested format +// rather than silently downgrading binary to text. +static bool run_case(const Case& c, int16_t fmt, Observed& obs); + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + + // For each case: 1 text assertion + 1 binary assertion. + plan((int)cases.size() * 2); + + for (const auto& c : cases) { + // TEXT: format code must be 0, OID must match, value (as text) must match. + Observed t; + bool ran_t = run_case(c, 0, t); + ok(ran_t && t.got_result && t.col_format == 0 && t.oid == c.expected_oid + && !t.is_null && t.text_value == c.expected_text, + "%s text: fmt=%d oid=%d value='%s'", + c.label, (int)t.col_format, t.oid, t.text_value.c_str()); + + // BINARY: format code must be 1 (ProxySQL actually honored binary), OID must + // match, and the payload must be byte-for-byte what PostgreSQL's binary output + // function produces. Checking the exact bytes — not just "a non-null value came + // back" — is what rules out a silent text fallback or a corrupted payload. + Observed b; + bool ran_b = run_case(c, 1, b); + const std::string got_hex = to_hex(b.raw_bytes); + bool binary_ok = ran_b && b.got_result && b.col_format == 1 + && b.oid == c.expected_oid && !b.is_null + && got_hex == c.expected_binary_hex; + ok(binary_ok, + "%s binary: format honored (columnFormat==%d), oid=%d, payload=%s (expected %s)", + c.label, (int)b.col_format, b.oid, got_hex.c_str(), c.expected_binary_hex); + } + return exit_status(); +} + +static bool run_case(const Case& c, int16_t fmt, Observed& obs) { + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + + // Pin the session time zone so the text rendering of timestamptz is + // deterministic regardless of the backend's configured TimeZone. Harmless for + // every other case, so it runs unconditionally rather than per-case. + conn.execute("SET TIME ZONE 'UTC'"); + conn.consumeInputUntilReady(); + + // Extended protocol: unnamed prepared statement, single result format = fmt. + // NOTE: these queries take no bind parameters, so the param-format array must be + // empty. bindStatementSingleFormat() would unconditionally send a 1-element + // param-format array even for a 0-param Bind, which trips a real ProxySQL bug + // (issue #5899: PgSQL_Connection.cpp stmt_execute_start() rejects num_param_formats==1 + // with num_params==0, though the PG protocol spec allows it). bindStatementEx() with + // an explicit empty paramFormats array is the protocol-correct 0-param bind and + // is orthogonal to the result-format/OID transparency under test here. + conn.prepareStatement("", c.select_expr, false, {}); + conn.bindStatementEx("", "", {}, {}, { fmt }, false); + conn.describePortal("", false); + conn.executePortal("", 0, true); // sync + + // Read: ParseComplete(1), BindComplete(2), RowDescription(T), DataRow(D), + // CommandComplete(C), ReadyForQuery(Z) + char type; std::vector buf; + while (true) { + conn.readMessage(type, buf); + if (type == PgConnection::ROW_DESCRIPTION) { + BufferReader r(buf); + int16_t nfields = r.readInt16(); + if (nfields >= 1) { + r.readString(); // field name + r.readInt32(); // table oid + r.readInt16(); // column attr num + obs.oid = r.readInt32(); // type oid + r.readInt16(); // type size + r.readInt32(); // type modifier + obs.col_format = r.readInt16(); // per-column result FORMAT CODE + } + } else if (type == PgConnection::DATA_ROW) { + BufferReader r(buf); + int16_t ncols = r.readInt16(); + if (ncols >= 1) { + int32_t len = r.readInt32(); + if (len >= 0) { + obs.raw_bytes = r.readBytes(len); + obs.is_null = false; + // Decode to text for the text-format value assertion. In binary + // format the payload is opaque bytes and text_value is unused. + obs.text_value.assign(obs.raw_bytes.begin(), obs.raw_bytes.end()); + obs.got_result = true; + } else { + obs.is_null = true; + obs.got_result = true; + } + } + } else if (type == PgConnection::READY_FOR_QUERY) { + break; + } else if (type == PgConnection::ERROR_RESPONSE) { + conn.disconnect(); + return false; + } + } + conn.disconnect(); + return obs.got_result; + } catch (const PgException& e) { + diag("%s fmt=%d threw: %s", c.label, (int)fmt, e.what()); + return false; + } +} diff --git a/test/tap/tests/pgsql-listen_notify_contract-t.cpp b/test/tap/tests/pgsql-listen_notify_contract-t.cpp new file mode 100644 index 0000000000..afe930b3fd --- /dev/null +++ b/test/tap/tests/pgsql-listen_notify_contract-t.cpp @@ -0,0 +1,55 @@ +#include +#include +#include "libpq-fe.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +static PGConnPtr mk() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + return PGConnPtr(PQconnectdb(ss.str().c_str()), &PQfinish); +} + +static std::string sqlstate_of(PGresult* r) { + const char* s = PQresultErrorField(r, PG_DIAG_SQLSTATE); + return s ? s : ""; +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(4); + + PGConnPtr c = mk(); + ok(c && PQstatus(c.get()) == CONNECTION_OK, "connected for listen/notify contract"); + + // LISTEN over simple protocol -> 0A000 feature_not_supported (libpq path). + PGresult* r = PQexec(c.get(), "LISTEN chan1"); + ok(PQresultStatus(r) == PGRES_FATAL_ERROR && sqlstate_of(r) == "0A000", + "simple LISTEN rejected with 0A000 (got status=%d sqlstate=%s)", + PQresultStatus(r), sqlstate_of(r).c_str()); + PQclear(r); + + // LISTEN over extended protocol (PQexecParams uses Parse/Bind/Execute) -> same 0A000. + r = PQexecParams(c.get(), "LISTEN chan2", 0, NULL, NULL, NULL, NULL, 0); + ok(PQresultStatus(r) == PGRES_FATAL_ERROR && sqlstate_of(r) == "0A000", + "extended LISTEN rejected with 0A000 (got sqlstate=%s)", sqlstate_of(r).c_str()); + PQclear(r); + + // NOTIFY as a plain query completes cleanly and the connection stays usable. + PGConnPtr c2 = mk(); + PGresult* rn = PQexec(c2.get(), "NOTIFY chan1, 'hello'"); + bool notify_ok = (PQresultStatus(rn) == PGRES_COMMAND_OK); + PQclear(rn); + PGresult* rq = PQexec(c2.get(), "SELECT 1"); + bool still_usable = (PQresultStatus(rq) == PGRES_TUPLES_OK); + PQclear(rq); + ok(notify_ok && still_usable, "NOTIFY completes cleanly and connection remains usable"); + + return exit_status(); +} diff --git a/test/tap/tests/pgsql-pool_churn-t.cpp b/test/tap/tests/pgsql-pool_churn-t.cpp new file mode 100644 index 0000000000..d8e326eab8 --- /dev/null +++ b/test/tap/tests/pgsql-pool_churn-t.cpp @@ -0,0 +1,187 @@ +#include +#include +#include +#include +#include +#include +#include "libpq-fe.h" +#include // admin interface is reached via the MySQL client +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +static PGConnPtr mk() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + return PGConnPtr(PQconnectdb(ss.str().c_str()), &PQfinish); +} + +static std::string scalar(PGconn* c, const char* q) { + PGresult* r = PQexec(c, q); + std::string v = (PQresultStatus(r) == PGRES_TUPLES_OK && PQntuples(r) > 0) ? PQgetvalue(r, 0, 0) : ""; + PQclear(r); + return v; +} + +static MYSQL* admin_connect() { + MYSQL* conn = mysql_init(NULL); + if (!mysql_real_connect(conn, cl.admin_host, cl.admin_username, cl.admin_password, + NULL, cl.admin_port, NULL, 0)) { + diag("admin connect failed: %s", mysql_error(conn)); + mysql_close(conn); + return NULL; + } + return conn; +} + +static bool admin_exec(MYSQL* a, const char* q) { + if (mysql_query(a, q)) { diag("admin query failed: '%s' : %s", q, mysql_error(a)); return false; } + MYSQL_RES* r = mysql_store_result(a); + if (r) mysql_free_result(r); + return true; +} + +static std::string admin_scalar(MYSQL* a, const char* q) { + std::string v; + if (mysql_query(a, q)) { diag("admin query failed: '%s' : %s", q, mysql_error(a)); return v; } + MYSQL_RES* r = mysql_store_result(a); + if (r) { + MYSQL_ROW row = mysql_fetch_row(r); + if (row && row[0]) v = row[0]; + mysql_free_result(r); + } + return v; +} + +// Fetch the first two columns of every row (used to snapshot per-hostgroup config). +static std::vector> admin_rows2(MYSQL* a, const char* q) { + std::vector> out; + if (mysql_query(a, q)) { diag("admin query failed: '%s' : %s", q, mysql_error(a)); return out; } + MYSQL_RES* r = mysql_store_result(a); + if (r) { + MYSQL_ROW row; + while ((row = mysql_fetch_row(r))) { + out.emplace_back(row[0] ? row[0] : "", row[1] ? row[1] : ""); + } + mysql_free_result(r); + } + return out; +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(3); + + MYSQL* admin = admin_connect(); + if (!admin) { BAIL_OUT("cannot reach ProxySQL admin"); return exit_status(); } + + // --------------------------------------------------------------------------- + // 1) Session-state isolation across a DETERMINISTICALLY reused backend. + // + // This block runs FIRST (before the connection storm) and caps the backend + // pool to a single connection, so that connection B has no clean alternative + // and is FORCED to reuse connection A's just-freed, session-dirtied backend. + // That is what makes the isolation assertion a real test of ProxySQL's + // backend session-reset path rather than a near-tautology: + // - ProxySQL's get_random_MyConn() PREFERS a clean/perfect-match backend + // over one that needs a session reset (lib/PgSQL_HostGroups_Manager.cpp). + // If a clean spare exists, B is handed it and the reset path never runs, + // so a real reset bug would go undetected. + // - Running the storm first would flood the free pool with clean backends; + // hence the storm runs AFTER this block, and we drain pre-existing idle + // backends to zero before starting. + // - With max_connections=1 and free_connections_pct=0 there is exactly one + // backend and zero clean spares, so B provably reuses A's dirtied backend + // (verified: after A closes, ConnFree=1 on the single hostgroup; B then + // reuses it). B seeing the default TimeZone proves the reset ran. + // + // TimeZone is used (not application_name): application_name is deliberately in + // ProxySQL's ignore_vars (lib/PgSQL_Variables.cpp) and never round-trips, so + // it cannot probe session state. TimeZone IS a tracked variable + // (pgsql_tracked_variables[] in include/proxysql_structs.h) that is forwarded + // to, and reset on, the backend. 'Antarctica/Troll' is a valid, distinctive, + // non-default IANA zone (default is 'GMT'). + // --------------------------------------------------------------------------- + + // Snapshot originals so we can restore regardless of assertion outcome. + // Snapshot max_connections PER-HOSTGROUP so the restore is correct regardless + // of the infra seed: a blanket single-value restore would silently flatten + // distinct per-hostgroup values and corrupt the shared CI infra for later + // tests. free_connections_pct is a single global variable, so a scalar + // snapshot/restore is correct for it. + std::vector> orig_maxconn = admin_rows2(admin, + "SELECT hostgroup_id, max_connections FROM pgsql_servers ORDER BY hostgroup_id"); + std::string orig_free_pct = admin_scalar(admin, + "SELECT variable_value FROM global_variables WHERE variable_name='pgsql-free_connections_pct'"); + if (orig_free_pct.empty()) orig_free_pct = "10"; // documented default + + // Cap the pool to a single backend and keep no clean spares. + admin_exec(admin, "UPDATE pgsql_servers SET max_connections=1"); + admin_exec(admin, "LOAD PGSQL SERVERS TO RUNTIME"); + admin_exec(admin, "SET pgsql-free_connections_pct=0"); + admin_exec(admin, "LOAD PGSQL VARIABLES TO RUNTIME"); + + // Wait for the connection reaper to drain any pre-existing idle backends to 0, + // so the only backend B can reach is the one A dirties below. + bool drained = false; + for (int i = 0; i < 100; ++i) { // up to ~10s + std::string f = admin_scalar(admin, "SELECT IFNULL(SUM(ConnFree),0) FROM stats_pgsql_connection_pool"); + if (f == "0") { drained = true; break; } + usleep(100 * 1000); + } + if (!drained) diag("WARNING: backend free connections did not drain to 0; reuse may be non-deterministic"); + + { + PGConnPtr a = mk(); + PQclear(PQexec(a.get(), "SET TimeZone = 'Antarctica/Troll'")); + std::string a_val = scalar(a.get(), "SHOW TimeZone"); + ok(a_val == "Antarctica/Troll", "connection A sees its own SET TimeZone (got '%s')", a_val.c_str()); + + a.reset(); // close A -> its single backend returns to the pool, session-dirtied + + PGConnPtr b = mk(); // cap=1 + no spares => B must reuse A's dirtied backend + std::string b_val = scalar(b.get(), "SHOW TimeZone"); + ok(b_val != "Antarctica/Troll", + "connection B (forced to reuse A's backend) does NOT inherit A's session state (got '%s')", b_val.c_str()); + } + + // Restore pool config BEFORE the storm and before returning, so the infra is + // left at its originals even if an assertion above failed. Restore EACH + // hostgroup to its own snapshotted value (never a blanket single-value + // UPDATE) so distinct per-hostgroup seeds are preserved. + if (orig_maxconn.empty()) { + // Snapshot failed for some reason: fall back to the infra default rather + // than leaving the cap in place. + admin_exec(admin, "UPDATE pgsql_servers SET max_connections=50"); + } else { + for (const auto& row : orig_maxconn) { + std::string q = "UPDATE pgsql_servers SET max_connections=" + row.second + + " WHERE hostgroup_id=" + row.first; + admin_exec(admin, q.c_str()); + } + } + admin_exec(admin, "LOAD PGSQL SERVERS TO RUNTIME"); + admin_exec(admin, (std::string("SET pgsql-free_connections_pct=") + orig_free_pct).c_str()); + admin_exec(admin, "LOAD PGSQL VARIABLES TO RUNTIME"); + mysql_close(admin); + + // --------------------------------------------------------------------------- + // 2) Connection storm (pool restored to normal): open many short-lived + // connections; none should error out (no pool leak/exhaustion). Count ALL + // failures rather than stopping at the first, for better diagnostics. + // --------------------------------------------------------------------------- + int failures = 0; + for (int i = 0; i < 100; ++i) { + PGConnPtr c = mk(); + if (!c || PQstatus(c.get()) != CONNECTION_OK) { failures++; continue; } + if (scalar(c.get(), "SELECT 1") != "1") { failures++; } + } + ok(failures == 0, "100 sequential short connections all succeed (no pool leak/exhaustion); %d/100 failed", failures); + + return exit_status(); +} diff --git a/test/tap/tests/pgsql-server_side_cursors-t.cpp b/test/tap/tests/pgsql-server_side_cursors-t.cpp new file mode 100644 index 0000000000..aad3534005 --- /dev/null +++ b/test/tap/tests/pgsql-server_side_cursors-t.cpp @@ -0,0 +1,127 @@ +#include +#include +#include "pg_lite_client.h" +#include "libpq-fe.h" +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +CommandLine cl; +using PGConnPtr = std::unique_ptr; + +// Runs a cursor-lifecycle command and verifies it actually succeeded, always +// clearing the PGresult. Previously these were fire-and-forget PQexec() calls: +// the result leaked, and a failed BEGIN or DECLARE surfaced only indirectly as +// a FETCH returning 0 rows -- reporting "FETCH 3 returns 3 rows" as the failure +// while hiding the real cause. `expected` is PGRES_COMMAND_OK for statements +// that return no tuples and PGRES_TUPLES_OK for FETCH. +static bool exec_expect(PGconn* c, const char* sql, ExecStatusType expected) { + PGresult* r = PQexec(c, sql); + const ExecStatusType st = PQresultStatus(r); + const bool good = (st == expected); + if (!good) { + diag("%s failed: %s (%s)", sql, PQresStatus(st), PQerrorMessage(c)); + } + PQclear(r); + return good; +} + +static PGConnPtr backend_conn() { + std::stringstream ss; + ss << "host=" << cl.pgsql_host << " port=" << cl.pgsql_port + << " user=" << cl.pgsql_username << " password=" << cl.pgsql_password + << " dbname=" << cl.pgsql_username << " sslmode=disable"; + PGconn* c = PQconnectdb(ss.str().c_str()); + return PGConnPtr(c, &PQfinish); +} + +// Extended-protocol portal suspension: Execute with maxRows=2 over a 5-row result. +// +// NOTE: the statement bound here has 0 parameters. A naive +// bindStatementSingleFormat("", "", {}, 0, {0}, false) would send a Bind message +// with num_param_formats=1, num_params=0, which trips a real ProxySQL bug +// (issue #5899: PgSQL_Connection.cpp's stmt_execute_start() rejects this with +// "Invalid param format count", even though the PG protocol spec and real +// PostgreSQL both accept num_param_formats==1 unconditionally). We avoid the bug +// here (rather than re-proving it) by using bindStatementEx with an EMPTY +// paramFormats array (num_param_formats=0), which is the protocol-correct way to +// bind a parameterless statement. +static bool portal_suspends_at_2() { + std::vector trace; // message-type trace, dumped only on failure + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + conn.prepareStatement("", "SELECT g FROM generate_series(1,5) g", false, {}); + conn.bindStatementEx("", "", {} /*params*/, {} /*paramFormats EMPTY*/, {} /*resultFormats*/, false); + conn.executePortal("", 2, true); // maxRows=2 -> expect 2 DataRows then PortalSuspended + char type; std::vector buf; int rows = 0; bool suspended = false; + while (true) { + conn.readMessage(type, buf); + trace.push_back(std::string(1, type) + "(len=" + std::to_string(buf.size()) + ")"); + if (type == PgConnection::DATA_ROW) rows++; + else if (type == PgConnection::PORTAL_SUSPENDED) { suspended = true; } + else if (type == PgConnection::READY_FOR_QUERY) break; + else if (type == PgConnection::ERROR_RESPONSE) { conn.disconnect(); break; } + } + conn.disconnect(); + bool ok_result = (rows == 2 && suspended); + if (!ok_result) { + std::string joined; + for (auto& t : trace) joined += t + " "; + diag("portal test observed rows=%d suspended=%d; message trace: %s", + rows, (int)suspended, joined.c_str()); + } + return ok_result; + } catch (const PgException& e) { diag("portal test threw: %s", e.what()); return false; } +} + +int main(int argc, char** argv) { + if (cl.getEnv()) return exit_status(); + plan(4); + + PGConnPtr c = backend_conn(); + ok(c && PQstatus(c.get()) == CONNECTION_OK, "connected for cursor test"); + + // DECLARE / FETCH / MOVE / CLOSE inside a transaction (cursors require a txn). + // BEGIN and DECLARE are preconditions for both FETCH assertions below: without + // them the FETCHes cannot mean anything, so a failure aborts rather than + // producing two misleading row-count failures. + if (!exec_expect(c.get(), "BEGIN", PGRES_COMMAND_OK)) + BAIL_OUT("could not open transaction for cursor test"); + if (!exec_expect(c.get(), "DECLARE cur CURSOR FOR SELECT g FROM generate_series(1,10) g", + PGRES_COMMAND_OK)) + BAIL_OUT("could not declare cursor"); + + PGresult* r = PQexec(c.get(), "FETCH 3 cur"); + ok(PQresultStatus(r) == PGRES_TUPLES_OK && PQntuples(r) == 3, + "FETCH 3 returns 3 rows (status=%s, rows=%d)", + PQresStatus(PQresultStatus(r)), PQntuples(r)); + PQclear(r); + + if (!exec_expect(c.get(), "MOVE 2 cur", PGRES_COMMAND_OK)) // skip 2 + diag("MOVE 2 failed; the following FETCH row count will not be meaningful"); + + r = PQexec(c.get(), "FETCH 10 cur"); // remaining 5 + ok(PQresultStatus(r) == PGRES_TUPLES_OK && PQntuples(r) == 5, + "MOVE 2 then FETCH returns remaining 5 rows (status=%s, rows=%d)", + PQresStatus(PQresultStatus(r)), PQntuples(r)); + PQclear(r); + + exec_expect(c.get(), "CLOSE cur", PGRES_COMMAND_OK); + exec_expect(c.get(), "COMMIT", PGRES_COMMAND_OK); + + // KNOWN GAP (tracked): ProxySQL's PG extended-protocol Execute handler ignores + // the requested row limit (max_rows parsed but never consumed) and never emits + // PortalSuspended, so this assertion currently fails. Wrap it in a todo block so + // the gating group (legacy-g4) is not broken: a `not ok` inside todo does not + // increment `failed` (test/tap/tap/tap.cpp:286), so exit_status() stays 0 while + // the TAP output still records `not ok N # todo `. When portal suspension + // is implemented the assertion passes inside the todo (still RC:0) -> remove wrapper. + todo_start("issue #5900: ProxySQL extended-protocol portal suspension unimplemented: Execute max_rows " + "ignored, no PortalSuspended emitted (lib/PgSQL_Extended_Query_Message.cpp:490). Remove this todo " + "wrapper when portal suspension is implemented."); + ok(portal_suspends_at_2(), "extended-protocol portal suspends at maxRows=2"); + todo_end(); + + return exit_status(); +}