From 61dbcf8db4432beaa927a7fa1f632d3d3ade3fe1 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 00:23:37 +0000 Subject: [PATCH 01/57] docs(test): design spec for PostgreSQL protocol testing (SP-1 + SP-2) Design for a phased PostgreSQL protocol test program: - SP-1: TAP coverage gaps (auth matrix, data types, cursors, pool churn, LISTEN/NOTIFY rejection) in the existing harness, per-PR gating. - SP-2: polyglot test foundation (Toxiproxy + pytest runner + 4-target differential engine + pg_stat_statements routing oracle), nightly + pg-compat label. Informed by surveys of PgBouncer, pgcat, and pgdog test suites. --- ...026-07-08-pgsql-protocol-testing-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md 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..853ad8a684 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -0,0 +1,223 @@ +# 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. + +### 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.) +- **No LISTEN/NOTIFY *delivery* test.** LISTEN is explicitly rejected with `0A000 feature_not_supported` (`PgSQL_Session.cpp:865`, `:6547`) and there is no `NotificationResponse`/`PQnotifies` forwarding path (backends are consumed via libpq). We test the current rejection contract only and capture forwarding as a future feature (see §3.6 and Appendix A). +- **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`). + +### 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 negative test — `pgsql-listen_notify_rejection-t.cpp` + +Pins the **current contract** (chosen option: negative test + feature note): +- `LISTEN chan` over **simple** protocol → asserts `0A000` with message "LISTEN is not supported". +- `LISTEN chan` over **extended** protocol (Parse/Bind/Execute) → same rejection (`PgSQL_Session.cpp:6547` path). +- `NOTIFY chan, 'payload'` as a plain query → completes cleanly (routed as ordinary query), no hang, no crash; connection remains usable afterward. +- `UNLISTEN` behavior asserted consistently. + +Appendix A sketches what real NOTIFY forwarding would require (captured, not built). + +### 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-pgsql-lb/` + +No primary+2-replica topology exists today; SP-2 needs one. +- **Nodes:** `pgdb1` (primary) + `pgdb2`, `pgdb3` (2 streaming replicas), PG 17 (align with `infra-pgsql17-repl`). +- **Fault-injection layer:** one **Toxiproxy** instance exposing one proxy endpoint per backend; ProxySQL's `pgsql_servers` point at the Toxiproxy ports, not the real PG ports. This makes every backend individually degradable (latency, `limit_data` slow-loris, reset-peer) without touching containers — the mechanism SP-4 will lean on. +- **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. This is deliberately *different* from the static placement in `infra-pgsql17-repl`, because the automatic machinery is what read/write split and SP-4 failover actually depend on. +- Follows existing infra conventions (docker-compose + `.env` with `WHG`/`RHG`, `bin/` wait/post scripts, `conf/` layout). Reuses `test/infra/control/` runners; introduces no new manual Docker steps. + +### 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, reduced to **4 targets** (no sharding): + +| Target | Connection | Result format | +|---|---|---| +| `proxy_text` | via ProxySQL | text | +| `proxy_binary` | via ProxySQL | binary | +| `direct_text` | direct to primary | text | +| `direct_binary` | direct to primary | binary | + +- Each case in `cases/NNN_slug.sql` is run against all 4 targets. The engine asserts **identical**: command status tag, column names, **column type OIDs**, row count, and row payloads. `proxy_*` must be indistinguishable from `direct_*`. +- Case metadata in SQL comments (pgdog convention): `-- transactional:`, `-- skip-targets:`, `-- only-targets:`. +- **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 us 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-pgsql-lb` (+ Toxiproxy) via the standard `test/infra/control/` runners → runs `pytest test/pg-compat`. SP-3 will fan out per-language matrix jobs from the same cached binary. +- **Not gating** on normal PRs (heavy, multi-toolchain); 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. +- SP-1 tests follow existing TAP conventions and run under the isolated harness (`run-tests-isolated.bash`, debug binary). + +--- + +## 6. Roadmap — SP-3 and SP-4 (not in this spec) + +- **SP-3 — Driver matrix expansion.** Add adapters under `test/pg-compat/drivers/`: **Java** (pgjdbc, +HikariCP), **Go** (pgx native), **Node.js** (node-postgres, postgres.js, Prisma). Each runs the existing `behaviors/` set + differential cases. CI fans out one matrix job per language from the cached binary. Prisma/pgjdbc are the highest-value targets (aggressive server-side prepared statements historically break poolers). +- **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 — Future feature note: NOTIFY forwarding (not built) + +Real LISTEN/NOTIFY support would require, at minimum: (1) removing 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 a session-mode-only feature); (3) a backend `NotificationResponse`/`PQnotifies` consumption path (ProxySQL currently consumes results via libpq's result API, which swallows async 'A' messages) and forwarding them to the pinned frontend; (4) lifecycle handling for `UNLISTEN` and connection teardown. This is a feature, tracked separately; SP-1 §3.5 only pins the current rejection contract so it can't silently regress. + +--- + +## 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 | +| LISTEN/NOTIFY | **Negative test now + feature appendix** | +| Sharding tests | **Excluded** (no PG sharding in ProxySQL) | +| Differential targets | **4** (proxy/direct × text/binary) | +| Replica routing | **Automatic** `pgsql_replication_hostgroups` + `pg_is_in_recovery()` (new 3-node infra) | From 69b3675b03657386035c77f9266e4263bf729799 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 00:33:39 +0000 Subject: [PATCH 02/57] docs(test): revise PG testing spec per review feedback - New infras use dbdeployer (infra-dbdeployer-pgsql17-repl), matching the existing infra-dbdeployer-* convention; first dbdeployer PG infra. - Frame the initial phase as discovery (failure inventory, xfail catalogue), no expectation of 100% success; SP-2 CI is reporting-oriented. - Add backend-protocol mode (pgsql-use_native_backend_protocol off/on) as a first-class test axis, tracking native-backend PR #5882; differential harness grows to 6 targets (proxy-libpq / proxy-native / direct x text/binary). - Reframe LISTEN/NOTIFY as a per-mode contract test; NOTIFY forwarding is owned by #5882 (already ships pgsql-native_notify-t), not this spec. --- ...026-07-08-pgsql-protocol-testing-design.md | 82 +++++++++++++------ 1 file changed, 55 insertions(+), 27 deletions(-) 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 index 853ad8a684..8dbd422ed8 100644 --- a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -32,10 +32,27 @@ The two highest-leverage borrowed ideas are the **differential harness** (proxy - 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.) -- **No LISTEN/NOTIFY *delivery* test.** LISTEN is explicitly rejected with `0A000 feature_not_supported` (`PgSQL_Session.cpp:865`, `:6547`) and there is no `NotificationResponse`/`PQnotifies` forwarding path (backends are consumed via libpq). We test the current rejection contract only and capture forwarding as a future feature (see §3.6 and Appendix A). +- **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). @@ -45,6 +62,8 @@ The two highest-leverage borrowed ideas are the **differential harness** (proxy **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. @@ -82,15 +101,14 @@ Systematic round-trips, **each type in both text and binary result format**, ass - 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 negative test — `pgsql-listen_notify_rejection-t.cpp` +### 3.5 LISTEN/NOTIFY contract test — `pgsql-listen_notify_contract-t.cpp` -Pins the **current contract** (chosen option: negative test + feature note): -- `LISTEN chan` over **simple** protocol → asserts `0A000` with message "LISTEN is not supported". -- `LISTEN chan` over **extended** protocol (Parse/Bind/Execute) → same rejection (`PgSQL_Session.cpp:6547` path). -- `NOTIFY chan, 'payload'` as a plain query → completes cleanly (routed as ordinary query), no hang, no crash; connection remains usable afterward. -- `UNLISTEN` behavior asserted consistently. +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`: -Appendix A sketches what real NOTIFY forwarding would require (captured, not built). +- **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 @@ -103,13 +121,15 @@ Appendix A sketches what real NOTIFY forwarding would require (captured, not bui **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-pgsql-lb/` +### 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`. -No primary+2-replica topology exists today; SP-2 needs one. -- **Nodes:** `pgdb1` (primary) + `pgdb2`, `pgdb3` (2 streaming replicas), PG 17 (align with `infra-pgsql17-repl`). -- **Fault-injection layer:** one **Toxiproxy** instance exposing one proxy endpoint per backend; ProxySQL's `pgsql_servers` point at the Toxiproxy ports, not the real PG ports. This makes every backend individually degradable (latency, `limit_data` slow-loris, reset-peer) without touching containers — the mechanism SP-4 will lean on. -- **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. This is deliberately *different* from the static placement in `infra-pgsql17-repl`, because the automatic machinery is what read/write split and SP-4 failover actually depend on. -- Follows existing infra conventions (docker-compose + `.env` with `WHG`/`RHG`, `bin/` wait/post scripts, `conf/` layout). Reuses `test/infra/control/` runners; introduces no new manual Docker steps. +- **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/` @@ -151,18 +171,22 @@ Behavior modules for SP-2: ### 4.4 Differential engine (golden-master transparency) -Adapted from pgdog's harness, reduced to **4 targets** (no sharding): +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_text` | via ProxySQL | text | -| `proxy_binary` | via ProxySQL | binary | +| `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` is run against all 4 targets. The engine asserts **identical**: command status tag, column names, **column type OIDs**, row count, and row payloads. `proxy_*` must be indistinguishable from `direct_*`. -- Case metadata in SQL comments (pgdog convention): `-- transactional:`, `-- skip-targets:`, `-- only-targets:`. -- **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 us the same coverage through a *second, independent* mechanism (real driver + direct-backend diff) that catches transparency bugs the TAP self-asserting tests can't. +- 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 @@ -181,8 +205,8 @@ Adapted from pgcat. `harness/oracle.py`: - **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-pgsql-lb` (+ Toxiproxy) via the standard `test/infra/control/` runners → runs `pytest test/pg-compat`. SP-3 will fan out per-language matrix jobs from the same cached binary. -- **Not gating** on normal PRs (heavy, multi-toolchain); nightly failures triaged per `CLAUDE.md`'s "never dismiss as flaky" policy. +- **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 will fan out per-language matrix jobs from the same cached binary. +- **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. --- @@ -191,6 +215,7 @@ Adapted from pgcat. `harness/oracle.py`: - **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). --- @@ -202,9 +227,9 @@ Adapted from pgcat. `harness/oracle.py`: --- -## Appendix A — Future feature note: NOTIFY forwarding (not built) +## Appendix A — NOTIFY forwarding: owned by the native-backend PR (#5882) -Real LISTEN/NOTIFY support would require, at minimum: (1) removing 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 a session-mode-only feature); (3) a backend `NotificationResponse`/`PQnotifies` consumption path (ProxySQL currently consumes results via libpq's result API, which swallows async 'A' messages) and forwarding them to the pinned frontend; (4) lifecycle handling for `UNLISTEN` and connection teardown. This is a feature, tracked separately; SP-1 §3.5 only pins the current rejection contract so it can't silently regress. +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. --- @@ -217,7 +242,10 @@ Real LISTEN/NOTIFY support would require, at minimum: (1) removing the `LISTEN` | 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 | -| LISTEN/NOTIFY | **Negative test now + feature appendix** | +| 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 | **4** (proxy/direct × text/binary) | -| Replica routing | **Automatic** `pgsql_replication_hostgroups` + `pg_is_in_recovery()` (new 3-node infra) | +| 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) | From 23c18ace515fce82e1b7fbc88b068c20516c55be Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 00:45:41 +0000 Subject: [PATCH 03/57] =?UTF-8?q?docs(test):=20SP-1=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20PG=20TAP=20coverage=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task TDD plan: extend pg_lite_client with MD5 + SCRAM-SHA-256 (reusing deps/libscram client funcs), then auth-method matrix, data-type/ binary matrix, server-side cursors, pool churn/session-isolation, and LISTEN/NOTIFY contract tests. Frontend auth driven via the integer pgsql-authentication_method variable (1=cleartext,2=md5,3=scram). --- .../2026-07-08-pgsql-sp1-tap-coverage-gaps.md | 937 ++++++++++++++++++ 1 file changed, 937 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-pgsql-sp1-tap-coverage-gaps.md 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..f1a6ac1c7e --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp1-tap-coverage-gaps.md @@ -0,0 +1,937 @@ +# 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. + +--- + +## 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: Auth-matrix test scaffold + cleartext case + +Establishes the test file, the admin variable-toggling helper, and green coverage for the method ProxySQL already supports through `pg_lite_client` (cleartext, method `1`). No client changes yet. + +**Files:** +- Create: `test/tap/tests/pgsql-auth_method_matrix-t.cpp` +- 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: helper `static bool set_frontend_auth_method(MYSQL* admin, int method)` and `static bool try_frontend_login(int method, const std::string& user, const std::string& password, bool& got_expected_challenge)` reused by Tasks 2–3 (same file). + +- [ ] **Step 1: Write the failing test (cleartext case only)** + +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; returns true on successful auth. +static bool try_frontend_login(const std::string& user, const std::string& password) { + try { + PgConnection c(2000); + c.connect(cl.pgsql_host, cl.pgsql_port, user /*dbname==user in this infra*/, user, password); + c.execute("SELECT 1"); + 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(); + + // 3 methods x (success + wrong-password) = plan grows as cases land. + // Task 1 registers only the cleartext success+failure (2 assertions). + plan(2); + + MYSQL* admin = admin_connect(); + if (!admin) BAIL_OUT("cannot reach admin"); + + // --- Cleartext (method = 1) --- + ok(set_frontend_auth_method(admin, 1), "set frontend auth method = cleartext"); + // NOTE: LOAD PGSQL VARIABLES affects NEW frontend connections. + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), + "cleartext login succeeds with correct password"); + + // 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 (method = 2) --- + ok(set_frontend_auth_method(admin, 2), "set frontend auth method = md5"); + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), + "md5 login succeeds with correct password"); +``` + +- [ ] **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-SHA-256 (method = 3) --- + ok(set_frontend_auth_method(admin, 3), "set frontend auth method = scram"); + ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), + "scram login succeeds with correct password"); + + // --- Wrong-password failure paths, one per method --- + ok(set_frontend_auth_method(admin, 1) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), + "cleartext login FAILS with wrong password"); + ok(set_frontend_auth_method(admin, 2) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), + "md5 login FAILS with wrong password"); + ok(set_frontend_auth_method(admin, 3) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), + "scram login FAILS with wrong password"); +``` + +- [ ] **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" +``` + +--- + +## 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 +}; + +// One representative literal per type; expand freely — adding a row is the unit of work. +static const std::vector cases = { + { "bool", "SELECT true", "t", 16 }, + { "int4", "SELECT 2147483647::int4", "2147483647", 23 }, + { "int8", "SELECT 9223372036854775807::int8", "9223372036854775807", 20 }, + { "float8", "SELECT 1.5::float8", "1.5", 701 }, + { "numeric", "SELECT 12345.6789::numeric", "12345.6789", 1700 }, + { "text_utf8", "SELECT 'héllo'::text", "héllo", 25 }, + { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17 }, + { "uuid", "SELECT '00000000-0000-0000-0000-000000000001'::uuid", + "00000000-0000-0000-0000-000000000001", 2950 }, + { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz AT TIME ZONE 'UTC'", + "2020-01-01 00:00:00", 1114 }, + { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802 }, + { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007 }, + { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, +}; + +// Runs one case through pg_lite_client at the given result format (0=text,1=binary). +// Returns true if the RowDescription OID matches; in text format also checks the value. +static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid); + +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). + plan((int)cases.size() * 2); + + for (const auto& c : cases) { + std::string v_text, v_bin; int32_t oid_text = 0, oid_bin = 0; + bool ok_text = run_case(c, 0, v_text, oid_text); + ok(ok_text && oid_text == c.expected_oid && v_text == c.expected_text, + "%s text: oid=%d value='%s'", c.label, oid_text, v_text.c_str()); + + bool ok_bin = run_case(c, 1, v_bin, oid_bin); + ok(ok_bin && oid_bin == c.expected_oid, + "%s binary: oid=%d (format code honored)", c.label, oid_bin); + } + 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, std::string& observed_value, int32_t& observed_oid) { + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + // Extended protocol: unnamed prepared statement, result format = fmt. + conn.prepareStatement("", c.select_expr, false, {}); + conn.bindStatementSingleFormat("", "", {}, 0 /*param fmt n/a*/, { 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; + bool got_row = false; + 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 + observed_oid = r.readInt32(); // type oid + } + } 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) { + auto bytes = r.readBytes(len); + if (fmt == 0) observed_value.assign(bytes.begin(), bytes.end()); + got_row = true; + } + } + } else if (type == PgConnection::READY_FOR_QUERY) { + break; + } else if (type == PgConnection::ERROR_RESPONSE) { + conn.disconnect(); + return false; + } + } + conn.disconnect(); + return got_row; + } 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 (md5/scram/cleartext + failure) → Tasks 1–3 ✓ (incl. `pg_lite_client` SCRAM/MD5 enabler). +- 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). + +**Placeholder scan:** No TBD/TODO. Two honest verification points are embedded as concrete observe-and-adjust steps (Task 3 Step 5 libscram framing; Task 4 Step 1 `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`, `run_case`, `mk`, `scalar`, `sqlstate_of`). `pg_lite_client` additions (`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). From 1d045a2f6ef532caae6d7495ed7ca6c18729eced Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 00:54:40 +0000 Subject: [PATCH 04/57] docs(test): reposition SP-1 auth tasks around PR #5865 PR #5865 already adds pgsql-verifier_auth-t / -verifier_passthrough-t / pgsql_reconcile_unit-t covering the credential-storage x floor matrix, anti-enumeration, and backend pass-through via libpq (connect success/fail only, no queries, no wire challenge-type assertion). Reposition SP-1's auth-matrix test as the wire-level complement: assert the actual auth CHALLENGE type ProxySQL presents per floor (3/5/10) via a new pg_lite_client getLastAuthType() accessor, plus run a query to prove the session is usable. De-dup the storage-type/floor success/fail (owned by #5865). Add merge-order note and an optional #5865-gated no-downgrade wire assertion. MD5/SCRAM enabler tasks unchanged. --- .../2026-07-08-pgsql-sp1-tap-coverage-gaps.md | 134 +++++++++++++----- 1 file changed, 101 insertions(+), 33 deletions(-) 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 index f1a6ac1c7e..b29a1f7cd5 100644 --- 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 @@ -20,6 +20,22 @@ --- +## 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/`):** @@ -40,20 +56,35 @@ --- -## Task 1: Auth-matrix test scaffold + cleartext case +## Task 1: Wire-level auth-challenge test scaffold + cleartext case -Establishes the test file, the admin variable-toggling helper, and green coverage for the method ProxySQL already supports through `pg_lite_client` (cleartext, method `1`). No client changes yet. +**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: helper `static bool set_frontend_auth_method(MYSQL* admin, int method)` and `static bool try_frontend_login(int method, const std::string& user, const std::string& password, bool& got_expected_challenge)` reused by Tasks 2–3 (same file). +- 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 1: Write the failing test (cleartext case only)** +- [ ] **Step 1b: Write the failing test (cleartext case)** Create `test/tap/tests/pgsql-auth_method_matrix-t.cpp`: @@ -88,12 +119,17 @@ static bool set_frontend_auth_method(MYSQL* admin, int method) { return true; } -// Attempts a frontend login with pg_lite_client; returns true on successful auth. -static bool try_frontend_login(const std::string& user, const std::string& password) { +// 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); - c.execute("SELECT 1"); + 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) { @@ -105,18 +141,19 @@ static bool try_frontend_login(const std::string& user, const std::string& passw int main(int argc, char** argv) { if (cl.getEnv()) return exit_status(); - // 3 methods x (success + wrong-password) = plan grows as cases land. - // Task 1 registers only the cleartext success+failure (2 assertions). + // 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 (method = 1) --- - ok(set_frontend_auth_method(admin, 1), "set frontend auth method = cleartext"); - // NOTE: LOAD PGSQL VARIABLES affects NEW frontend connections. - ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), - "cleartext login succeeds with correct password"); + // --- 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); @@ -188,12 +225,16 @@ Extend `pg_lite_client` to answer an `AuthenticationMD5Password` (authType 5) ch In `pgsql-auth_method_matrix-t.cpp`, bump `plan(2)` → `plan(4)` and after the cleartext block add: ```cpp - // --- MD5 (method = 2) --- - ok(set_frontend_auth_method(admin, 2), "set frontend auth method = md5"); - ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), - "md5 login succeeds with correct password"); + // --- 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 @@ -300,18 +341,21 @@ Extend `pg_lite_client` to complete a SASL/SCRAM-SHA-256 exchange using the vend In `pgsql-auth_method_matrix-t.cpp`, bump `plan(4)` → `plan(9)` and add: ```cpp - // --- SCRAM-SHA-256 (method = 3) --- - ok(set_frontend_auth_method(admin, 3), "set frontend auth method = scram"); - ok(try_frontend_login(cl.pgsql_username, cl.pgsql_password), - "scram login succeeds with correct password"); - - // --- Wrong-password failure paths, one per method --- - ok(set_frontend_auth_method(admin, 1) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), - "cleartext login FAILS with wrong password"); - ok(set_frontend_auth_method(admin, 2) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), - "md5 login FAILS with wrong password"); - ok(set_frontend_auth_method(admin, 3) && !try_frontend_login(cl.pgsql_username, "wrong-pw"), - "scram login FAILS with wrong password"); + // --- 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** @@ -444,6 +488,28 @@ git add test/tap/tests/pg_lite_client.h test/tap/tests/pg_lite_client.cpp test/t 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 @@ -923,15 +989,17 @@ git commit -m "test(pgsql): LISTEN 0A000 rejection + NOTIFY-as-query contract (l ## Self-Review **Spec coverage (SP-1 items → tasks):** -- Auth-method matrix (md5/scram/cleartext + failure) → Tasks 1–3 ✓ (incl. `pg_lite_client` SCRAM/MD5 enabler). +- 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). -**Placeholder scan:** No TBD/TODO. Two honest verification points are embedded as concrete observe-and-adjust steps (Task 3 Step 5 libscram framing; Task 4 Step 1 `readResult` vs raw-parse) — each names the exact thing to check and the fallback, which is guidance, not a placeholder. +**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`, `run_case`, `mk`, `scalar`, `sqlstate_of`). `pg_lite_client` additions (`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`. +**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). From 519e3117e9ea464db0d7175068415be86da4bf74 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:06:15 +0000 Subject: [PATCH 05/57] =?UTF-8?q?docs(test):=20SP-2=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20polyglot=20PG=20test=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10-task plan: dbdeployer-PG validation spike + native fallback, primary+2 replica infra with pg_stat_statements, Toxiproxy sidecar, automatic pgsql_replication_hostgroups routing, in-container pytest harness with admin config-as-primitive, 6-target differential engine (proxy-libpq/ native x text/binary vs direct) + divergence self-check, pg_stat_statements routing oracle + write-pin self-check, shared behavior set + psycopg3 adapter, xfail catalogue, and nightly+label CI. Endpoints env-injected so the harness is decoupled from the topology decision. --- ...026-07-08-pgsql-sp2-polyglot-foundation.md | 1021 +++++++++++++++++ 1 file changed, 1021 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md 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..3e6b696b99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md @@ -0,0 +1,1021 @@ +# 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 | **Greenfield / unproven** in the pinned fork | **Task 1 is a spike** with a hard decision gate → dbdeployer path (Task 2a) or native-`postgres:17` fallback (Task 2b). | +| 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: SPIKE — validate dbdeployer PostgreSQL support (decision gate) + +The user directs new infras use dbdeployer, but PG-on-dbdeployer has no in-repo precedent and may be unsupported by the pinned fork (v2.2.1). This spike resolves it before any infra is built. **This task's deliverable is a decision, not code.** + +**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: Write the decision** + +Create `test/pg-compat/SPIKE-dbdeployer-pg.md` recording: fork PG support (yes/no), the working deploy command + ports (if yes), and the **decision**: +- **DECISION A (dbdeployer works):** proceed with **Task 2a**. +- **DECISION B (dbdeployer lacks PG):** proceed with **Task 2b** (native `postgres:17`), and note the deviation from the "dbdeployer for new infras" directive with the evidence, so the maintainer can accept it or pursue a dbdeployer-fork fix separately. + +- [ ] **Step 4: Commit the spike** + +```bash +git add test/pg-compat/SPIKE-dbdeployer-pg.md +git commit -m "spike(pg-compat): dbdeployer PostgreSQL support decision + evidence" +``` + +--- + +## 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` — `PGCOMPAT_PRIMARY_HOST`, `PGCOMPAT_REPLICA1_HOST`, `PGCOMPAT_REPLICA2_HOST`, `PGCOMPAT_BACKEND_PORT`. (dbdeployer single-container → one host, three ports; native → three hosts, port 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_REPLICA1_HOST="pgsql2.${INFRA_ID}" +export PGCOMPAT_REPLICA2_HOST="pgsql3.${INFRA_ID}" +export PGCOMPAT_BACKEND_PORT="5432" +``` +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_REPLICA1_HOST -e PGCOMPAT_REPLICA2_HOST \ + -e PGCOMPAT_BACKEND_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_BACKEND_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 +import psycopg + +def _parse(case_file): + sql = open(case_file).read() + skip = set(re.findall(r"--\s*skip-targets:\s*(.+)", sql)) + only = set(re.findall(r"--\s*only-targets:\s*(.+)", sql)) + stmts = [s.strip() for s in sql.split(";") if s.strip() and not s.strip().startswith("--")] + 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: + if t.name in skip: continue + if only and t.name not 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): + return psycopg.connect( + f"host={host} port={os.environ['PGCOMPAT_BACKEND_PORT']} user=testuser password=testuser dbname=testuser sslmode=disable", + autocommit=True) + +_BACKENDS = { + "primary": "PGCOMPAT_PRIMARY_HOST", + "replica1": "PGCOMPAT_REPLICA1_HOST", + "replica2": "PGCOMPAT_REPLICA2_HOST", +} + +def reset_all(): + for env in _BACKENDS.values(): + with _c(os.environ[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. From debf333748516e00d27e6cef2fa29d0a63a8d146 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:10:32 +0000 Subject: [PATCH 06/57] =?UTF-8?q?docs(test):=20SP-2=20=E2=80=94=20dbdeploy?= =?UTF-8?q?er=20PG=20support=20confirmed,=20collapse=20spike=20to=20comman?= =?UTF-8?q?d-capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-08-pgsql-sp2-polyglot-foundation.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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 index 3e6b696b99..98345d2927 100644 --- a/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md @@ -24,7 +24,7 @@ | Piece | Status | Mitigation in this plan | |---|---|---| -| dbdeployer PostgreSQL topology | **Greenfield / unproven** in the pinned fork | **Task 1 is a spike** with a hard decision gate → dbdeployer path (Task 2a) or native-`postgres:17` fallback (Task 2b). | +| 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. | @@ -59,9 +59,9 @@ --- -## Task 1: SPIKE — validate dbdeployer PostgreSQL support (decision gate) +## Task 1: Capture the exact dbdeployer PostgreSQL deploy command -The user directs new infras use dbdeployer, but PG-on-dbdeployer has no in-repo precedent and may be unsupported by the pinned fork (v2.2.1). This spike resolves it before any infra is built. **This task's deliverable is a decision, not code.** +**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) @@ -90,17 +90,15 @@ 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: Write the decision** +- [ ] **Step 3: Record the exact command** -Create `test/pg-compat/SPIKE-dbdeployer-pg.md` recording: fork PG support (yes/no), the working deploy command + ports (if yes), and the **decision**: -- **DECISION A (dbdeployer works):** proceed with **Task 2a**. -- **DECISION B (dbdeployer lacks PG):** proceed with **Task 2b** (native `postgres:17`), and note the deviation from the "dbdeployer for new infras" directive with the evidence, so the maintainer can accept it or pursue a dbdeployer-fork fix separately. +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 the spike** +- [ ] **Step 4: Commit** ```bash git add test/pg-compat/SPIKE-dbdeployer-pg.md -git commit -m "spike(pg-compat): dbdeployer PostgreSQL support decision + evidence" +git commit -m "spike(pg-compat): capture exact dbdeployer PostgreSQL deploy command + ports" ``` --- From 8885713349ead76e0df7feba81d81d3b2c3cef31 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:29:03 +0000 Subject: [PATCH 07/57] test(pgsql): auth-method matrix scaffold + cleartext case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-level complement to PR #5865: assert the auth CHALLENGE type ProxySQL presents to the client (cleartext=3) and run a query to prove the session is usable — neither observable through libpq. Adds PgConnection::getLastAuthType() to pg_lite_client and the test scaffold (2 assertions); md5/scram land in the next tasks. --- test/tap/groups/groups.json | 1 + test/tap/tests/Makefile | 3 + test/tap/tests/pg_lite_client.cpp | 1 + test/tap/tests/pg_lite_client.h | 2 + test/tap/tests/pgsql-auth_method_matrix-t.cpp | 74 +++++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 test/tap/tests/pgsql-auth_method_matrix-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index e6003db274..0c739cfa7f 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -146,6 +146,7 @@ "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" ], diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index 107bf620bf..05f71c5a92 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -385,6 +385,9 @@ test_ffto_pgsql_pipeline-t: test_ffto_pgsql_pipeline-t.cpp pg_lite_client.cpp $( test_ffto_pgsql_stmt_portal-t: test_ffto_pgsql_stmt_portal-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ +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 $@ + 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..ad36bcd0a2 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -303,6 +303,7 @@ void PgConnection::handleAuthentication(const std::string& password) { if (type == AUTH_TYPE) { if (buffer.size() < 4) throw PgException("Invalid authentication message"); int32_t authType = ntohl(*reinterpret_cast(buffer.data())); + if (last_auth_type_ == 0 && authType != 0) last_auth_type_ = authType; if (authType == 0) { // AuthenticationOK return; } diff --git a/test/tap/tests/pg_lite_client.h b/test/tap/tests/pg_lite_client.h index 189c2e928a..6727203772 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,6 +208,7 @@ 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); 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..48b9ab8fa2 --- /dev/null +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -0,0 +1,74 @@ +#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; +} + +// 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(); +} From 1bb714478e658e8934baf7d91dc512f1ec87f14a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:36:06 +0000 Subject: [PATCH 08/57] test(pgsql): harden auth-matrix helper per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - try_frontend_login: consumeInputUntilReady() after execute() so it actually round-trips SELECT 1 (execute() only sends) — makes 'login + query succeed' honest; Tasks 2-3 reuse this helper. - BAIL_OUT if the admin auth-method SET/LOAD fails, so a config failure gives a clear diagnostic instead of a confusing wrong-challenge-type failure. Addresses both Minor findings from the Task 1 review; test still 2/2 green. --- test/tap/tests/pgsql-auth_method_matrix-t.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/tap/tests/pgsql-auth_method_matrix-t.cpp b/test/tap/tests/pgsql-auth_method_matrix-t.cpp index 48b9ab8fa2..1d4a5f3e8c 100644 --- a/test/tap/tests/pgsql-auth_method_matrix-t.cpp +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -41,7 +41,8 @@ static bool try_frontend_login(const std::string& user, const std::string& passw 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.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) { @@ -61,7 +62,8 @@ int main(int argc, char** argv) { 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 + if (!set_frontend_auth_method(admin, 1)) // affects NEW frontend connections + BAIL_OUT("could not configure cleartext auth floor (admin SET/LOAD failed)"); 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"); From 0a421b501ade56d447c9716734d1a28773a8d89e Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:38:57 +0000 Subject: [PATCH 09/57] test(pgsql): pg_lite_client MD5 frontend auth + matrix md5 case --- test/tap/tests/pg_lite_client.cpp | 37 +++++++++++++++++++ test/tap/tests/pg_lite_client.h | 1 + test/tap/tests/pgsql-auth_method_matrix-t.cpp | 9 ++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index ad36bcd0a2..305f18c5e7 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -12,6 +12,7 @@ #include #include #include +#include // Buffer writing helpers static void writeInt32ToBuffer(std::vector& buffer, int32_t value) { @@ -316,6 +317,18 @@ void PgConnection::handleAuthentication(const std::string& password) { 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 == AUTH_TYPE) { + authType = ntohl(*reinterpret_cast(buffer.data())); + if (authType == 0) return; + } + // fall through to error handling on non-OK + } else { throw PgException("Unsupported authentication method: " + std::to_string(authType)); } @@ -343,6 +356,30 @@ void PgConnection::sendPassword(const std::string& password) { sendMessage('p', packet); } +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); +} + 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 6727203772..b4003338e9 100644 --- a/test/tap/tests/pg_lite_client.h +++ b/test/tap/tests/pg_lite_client.h @@ -213,6 +213,7 @@ class PgConnection { 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 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 index 1d4a5f3e8c..f4460c00c9 100644 --- a/test/tap/tests/pgsql-auth_method_matrix-t.cpp +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -56,7 +56,7 @@ int main(int argc, char** argv) { // 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); + plan(4); MYSQL* admin = admin_connect(); if (!admin) BAIL_OUT("cannot reach admin"); @@ -69,6 +69,13 @@ int main(int argc, char** argv) { 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 --- + 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); + // restore default before exit set_frontend_auth_method(admin, 3); mysql_close(admin); From 8751dbfc98f8df63a5131f80a98f2611c917cb2c Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:47:02 +0000 Subject: [PATCH 10/57] test(pgsql): pg_lite_client SCRAM-SHA-256 frontend auth + matrix scram/failure cases --- test/tap/tests/pg_lite_client.cpp | 124 +++++++++++++++++- test/tap/tests/pg_lite_client.h | 1 + test/tap/tests/pgsql-auth_method_matrix-t.cpp | 18 ++- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index 305f18c5e7..16fa80af96 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -14,6 +14,10 @@ #include #include +extern "C" { +#include "scram.h" +} + // Buffer writing helpers static void writeInt32ToBuffer(std::vector& buffer, int32_t value) { value = htonl(value); @@ -34,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 @@ -312,6 +318,8 @@ 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 = ntohl(*reinterpret_cast(buffer.data())); if (authType == 0) return; @@ -323,11 +331,16 @@ void PgConnection::handleAuthentication(const std::string& password) { 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())); if (authType == 0) return; } - // fall through to error handling on non-OK + } + 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)); @@ -380,6 +393,115 @@ void PgConnection::sendMD5Password(const std::string& password, const uint8_t sa 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 || + 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 derived from plaintext passwd). + 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). + // 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 || + 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 == ERROR_RESPONSE) throw PgException("scram: " + extractErrorMessage(buffer)); + if (type == AUTH_TYPE && buffer.size() >= 4 && + ntohl(*reinterpret_cast(buffer.data())) == 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 b4003338e9..d11a1082ff 100644 --- a/test/tap/tests/pg_lite_client.h +++ b/test/tap/tests/pg_lite_client.h @@ -214,6 +214,7 @@ class PgConnection { 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 index f4460c00c9..5cf4522d5e 100644 --- a/test/tap/tests/pgsql-auth_method_matrix-t.cpp +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -56,7 +56,7 @@ int main(int argc, char** argv) { // 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(4); + plan(9); MYSQL* admin = admin_connect(); if (!admin) BAIL_OUT("cannot reach admin"); @@ -76,6 +76,22 @@ int main(int argc, char** argv) { "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 --- + 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"); + // restore default before exit set_frontend_auth_method(admin, 3); mysql_close(admin); From 3a51d035ace6012df79d0a51928a772c909d6f9f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:52:18 +0000 Subject: [PATCH 11/57] fix(pgsql): free SCRAM salt buffer leaked from read_server_first_message (Task 3 review) --- test/tap/tests/pg_lite_client.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index 16fa80af96..c901552ed4 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -465,6 +465,8 @@ void PgConnection::doSASLAuth(const std::string& password, // 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)); From 86d44487d6f95e71dae041fb1c91a999be0be927 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 01:57:47 +0000 Subject: [PATCH 12/57] test(pgsql): data-type/binary-encoding matrix (text+binary, oid+value) Round-trips a representative literal per PG type (bool, int4, int8, float8, numeric, text, bytea, uuid, timestamptz, jsonb, int4[], inet) through the ProxySQL PG frontend via pg_lite_client's extended-protocol API, asserting the DataRow value, the RowDescription type OID, and the requested result-format code, in both text and binary result formats. Adjustments vs the task brief: - Makefile rule needs -lscram -lusual -Wl,--allow-multiple-definition: pg_lite_client.cpp compiles doSASLAuth() unconditionally (not gated behind an #ifdef), so those symbols are always referenced at link time regardless of which auth method a given test exercises. - run_case() binds via bindStatementEx() with an explicit empty paramFormats array instead of bindStatementSingleFormat(). The latter unconditionally sends a 1-element param-format array even when there are 0 bind parameters, which trips a real ProxySQL bug: PgSQL_Connection.cpp's stmt_execute_start() only expands num_param_formats==1 into num_params when num_params > 1, so num_param_formats==1 with 0 actual params falls into the mismatch-error branch ("Invalid param format count: got 1, expected 0"), even though the PG Bind message spec defines num_param_formats==1 as applying to all parameters regardless of their count (confirmed against PostgreSQL's own exec_bind_message, which only errors when numPFormats > 1 && numPFormats != numParams). Real clients never send a param-format array for a 0-param bind, so the test now does the same; the divergence itself is out of scope for this data-type matrix and is called out in the task report for separate follow-up. All 24 assertions (12 types x text+binary) pass against the sdd-pg1 debug ProxySQL build, confirming transparent OID/value/format-code handling on the libpq backend path for this type set. --- test/tap/groups/groups.json | 1 + test/tap/tests/Makefile | 3 + test/tap/tests/pgsql-datatype_matrix-t.cpp | 117 +++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 test/tap/tests/pgsql-datatype_matrix-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index 0c739cfa7f..bfef5a7ea8 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -152,6 +152,7 @@ "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-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" ], diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index 05f71c5a92..de995f6589 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -388,6 +388,9 @@ test_ffto_pgsql_stmt_portal-t: test_ffto_pgsql_stmt_portal-t.cpp pg_lite_client. 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 $@ +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) -lscram -lusual -Wl,--allow-multiple-definition -o $@ + MYSQLX_PROTO_DIR := $(PROXYSQL_PATH)/plugins/mysqlx/proto MYSQLX_PROTO_SRCS := $(wildcard $(MYSQLX_PROTO_DIR)/*.pb.cc) 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..a49d8a76c6 --- /dev/null +++ b/test/tap/tests/pgsql-datatype_matrix-t.cpp @@ -0,0 +1,117 @@ +#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 +}; + +// One representative literal per type; expand freely — adding a row is the unit of work. +static const std::vector cases = { + { "bool", "SELECT true", "t", 16 }, + { "int4", "SELECT 2147483647::int4", "2147483647", 23 }, + { "int8", "SELECT 9223372036854775807::int8", "9223372036854775807", 20 }, + { "float8", "SELECT 1.5::float8", "1.5", 701 }, + { "numeric", "SELECT 12345.6789::numeric", "12345.6789", 1700 }, + { "text_utf8", "SELECT 'héllo'::text", "héllo", 25 }, + { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17 }, + { "uuid", "SELECT '00000000-0000-0000-0000-000000000001'::uuid", + "00000000-0000-0000-0000-000000000001", 2950 }, + { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz AT TIME ZONE 'UTC'", + "2020-01-01 00:00:00", 1114 }, + { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802 }, + { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007 }, + { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, +}; + +// Runs one case through pg_lite_client at the given result format (0=text,1=binary). +// Returns true if the RowDescription OID matches; in text format also checks the value. +static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid); + +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). + plan((int)cases.size() * 2); + + for (const auto& c : cases) { + std::string v_text, v_bin; int32_t oid_text = 0, oid_bin = 0; + bool ok_text = run_case(c, 0, v_text, oid_text); + ok(ok_text && oid_text == c.expected_oid && v_text == c.expected_text, + "%s text: oid=%d value='%s'", c.label, oid_text, v_text.c_str()); + + bool ok_bin = run_case(c, 1, v_bin, oid_bin); + ok(ok_bin && oid_bin == c.expected_oid, + "%s binary: oid=%d (format code honored)", c.label, oid_bin); + } + return exit_status(); +} + +static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid) { + try { + PgConnection conn(2000); + conn.connect(cl.pgsql_host, cl.pgsql_port, cl.pgsql_username, cl.pgsql_username, cl.pgsql_password); + // Extended protocol: unnamed prepared statement, result format = fmt. + // NOTE: these queries take no bind parameters, so the param-format array + // must be empty (a real client would never send one for a 0-param Bind). + // bindStatementSingleFormat() unconditionally sends a 1-element param-format + // array ({singleFormat}) regardless of param count, which triggers a real + // ProxySQL bug (see PgSQL_Connection.cpp stmt_execute_start(): the format-count + // normalization only expands num_param_formats==1 when param_values.size() > 1, + // so numPFormats==1 with 0 actual params falls into the mismatch-error branch, + // even though the PG protocol spec says num_param_formats==1 applies to all + // parameters regardless of how many there are). Use bindStatementEx() with an + // explicit empty paramFormats array to avoid tripping that bug, since it is + // orthogonal to what this test is verifying (result-format/type-OID transparency). + 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; + bool got_row = false; + 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 + observed_oid = r.readInt32(); // type oid + } + } 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) { + auto bytes = r.readBytes(len); + if (fmt == 0) observed_value.assign(bytes.begin(), bytes.end()); + got_row = true; + } + } + } else if (type == PgConnection::READY_FOR_QUERY) { + break; + } else if (type == PgConnection::ERROR_RESPONSE) { + conn.disconnect(); + return false; + } + } + conn.disconnect(); + return got_row; + } catch (const PgException& e) { + diag("%s fmt=%d threw: %s", c.label, (int)fmt, e.what()); + return false; + } +} From e90633a5234cbc68a68e50240728c94977eeb85f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:08:38 +0000 Subject: [PATCH 13/57] fix(pgsql): make datatype-matrix binary assertions verify columnFormat + payload (Task 4 review) The binary-format assertions were tautological: run_case parsed the RowDescription only up to the type OID (never the trailing per-column format-code field) and captured the DataRow value only for text format. Each binary assertion therefore checked just the OID (identical in text and binary) and 'a row exists' -- it would have passed unchanged even if ProxySQL had silently downgraded the requested binary result format to text, which is precisely the #5866 transparency class this is meant to catch. Rework run_case to read the full RowDescription field layout (the same layout readResult() uses, including the per-column format code it stores into columnFormat()) while additionally keeping the type OID -- which readResult()/PgResult discard, and which both assertions here need. The DataRow payload for column 0 is now captured for both formats. Assert: text : columnFormat==0 AND oid==expected AND value(text)==expected binary: columnFormat==1 (ProxySQL honored binary) AND oid==expected AND non-null; plus an end-to-end decode of the int4 case (payload is exactly 4 bytes, big-endian == 2147483647) to prove the bytes are real binary, not text mislabeled binary. Fix the assertion message to describe what is actually checked. Also drop the stray blank line after the new Makefile rule. 24/24 pass with real binary checks: payload sizes match the fixed-width binary encodings (int4=4, int8=8, uuid=16, timestamptz=8, bool=1 bytes) and differ from the text forms, and every column reports columnFormat==1. --- test/tap/tests/Makefile | 1 - test/tap/tests/pgsql-datatype_matrix-t.cpp | 120 +++++++++++++++------ 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index de995f6589..c8df37ef57 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -391,7 +391,6 @@ pgsql-auth_method_matrix-t: pgsql-auth_method_matrix-t.cpp pg_lite_client.cpp $( 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) -lscram -lusual -Wl,--allow-multiple-definition -o $@ - MYSQLX_PROTO_DIR := $(PROXYSQL_PATH)/plugins/mysqlx/proto MYSQLX_PROTO_SRCS := $(wildcard $(MYSQLX_PROTO_DIR)/*.pb.cc) MYSQLX_PROTO_OBJS := $(patsubst $(MYSQLX_PROTO_DIR)/%.pb.cc,$(ODIR)/mysqlx_proto_%.pb.o,$(MYSQLX_PROTO_SRCS)) diff --git a/test/tap/tests/pgsql-datatype_matrix-t.cpp b/test/tap/tests/pgsql-datatype_matrix-t.cpp index a49d8a76c6..26e517ada7 100644 --- a/test/tap/tests/pgsql-datatype_matrix-t.cpp +++ b/test/tap/tests/pgsql-datatype_matrix-t.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "pg_lite_client.h" #include "command_line.h" #include "tap.h" @@ -32,63 +33,108 @@ static const std::vector cases = { { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, }; -// Runs one case through pg_lite_client at the given result format (0=text,1=binary). -// Returns true if the RowDescription OID matches; in text format also checks the value. -static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid); +// 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 (value+oid) + 1 binary assertion (oid+format code). + // For each case: 1 text assertion + 1 binary assertion. plan((int)cases.size() * 2); for (const auto& c : cases) { - std::string v_text, v_bin; int32_t oid_text = 0, oid_bin = 0; - bool ok_text = run_case(c, 0, v_text, oid_text); - ok(ok_text && oid_text == c.expected_oid && v_text == c.expected_text, - "%s text: oid=%d value='%s'", c.label, oid_text, v_text.c_str()); + // 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()); - bool ok_bin = run_case(c, 1, v_bin, oid_bin); - ok(ok_bin && oid_bin == c.expected_oid, - "%s binary: oid=%d (format code honored)", c.label, oid_bin); + // BINARY: format code must be 1 (ProxySQL actually honored binary), OID must + // match, and a non-null value must be returned. For the fixed-width int4 case + // we additionally decode the payload end-to-end to prove the bytes are real + // binary (4 bytes, big-endian) rather than a text string mislabeled as binary. + Observed b; + bool ran_b = run_case(c, 1, b); + bool binary_ok = ran_b && b.got_result && b.col_format == 1 + && b.oid == c.expected_oid && !b.is_null; + std::string extra; + if (std::string(c.label) == "int4") { + bool decode_ok = b.raw_bytes.size() == 4; + int64_t decoded = 0; + if (decode_ok) { + uint32_t u = (uint32_t(b.raw_bytes[0]) << 24) | (uint32_t(b.raw_bytes[1]) << 16) + | (uint32_t(b.raw_bytes[2]) << 8) | uint32_t(b.raw_bytes[3]); + decoded = (int32_t)u; + decode_ok = (decoded == 2147483647); + } + binary_ok = binary_ok && decode_ok; + extra = " [int4 binary payload " + std::to_string(b.raw_bytes.size()) + + " bytes -> " + std::to_string(decoded) + "]"; + } + ok(binary_ok, + "%s binary: format honored (columnFormat==%d), oid=%d, %zu payload bytes%s", + c.label, (int)b.col_format, b.oid, b.raw_bytes.size(), extra.c_str()); } return exit_status(); } -static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid) { +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); - // Extended protocol: unnamed prepared statement, result format = fmt. - // NOTE: these queries take no bind parameters, so the param-format array - // must be empty (a real client would never send one for a 0-param Bind). - // bindStatementSingleFormat() unconditionally sends a 1-element param-format - // array ({singleFormat}) regardless of param count, which triggers a real - // ProxySQL bug (see PgSQL_Connection.cpp stmt_execute_start(): the format-count - // normalization only expands num_param_formats==1 when param_values.size() > 1, - // so numPFormats==1 with 0 actual params falls into the mismatch-error branch, - // even though the PG protocol spec says num_param_formats==1 applies to all - // parameters regardless of how many there are). Use bindStatementEx() with an - // explicit empty paramFormats array to avoid tripping that bug, since it is - // orthogonal to what this test is verifying (result-format/type-OID transparency). + // 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 + // (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) + // Read: ParseComplete(1), BindComplete(2), RowDescription(T), DataRow(D), + // CommandComplete(C), ReadyForQuery(Z) char type; std::vector buf; - bool got_row = false; 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 - observed_oid = r.readInt32(); // type oid + 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); @@ -96,9 +142,15 @@ static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, in if (ncols >= 1) { int32_t len = r.readInt32(); if (len >= 0) { - auto bytes = r.readBytes(len); - if (fmt == 0) observed_value.assign(bytes.begin(), bytes.end()); - got_row = true; + 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) { @@ -109,7 +161,7 @@ static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, in } } conn.disconnect(); - return got_row; + return obs.got_result; } catch (const PgException& e) { diag("%s fmt=%d threw: %s", c.label, (int)fmt, e.what()); return false; From 6db3321eb84b803ed0c8cd7cd02989468b65a9a4 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:16:28 +0000 Subject: [PATCH 14/57] test(pgsql): server-side cursors (DECLARE/FETCH/MOVE) + portal suspension --- test/tap/groups/groups.json | 1 + test/tap/tests/Makefile | 3 + .../tap/tests/pgsql-server_side_cursors-t.cpp | 85 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 test/tap/tests/pgsql-server_side_cursors-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index bfef5a7ea8..626e9fa4f0 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -180,6 +180,7 @@ "pgsql-reg_test_5866_result_format-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/tests/Makefile b/test/tap/tests/Makefile index c8df37ef57..db7b88d5ea 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -391,6 +391,9 @@ pgsql-auth_method_matrix-t: pgsql-auth_method_matrix-t.cpp pg_lite_client.cpp $( 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) -lscram -lusual -Wl,--allow-multiple-definition -o $@ +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) -lscram -lusual -Wl,--allow-multiple-definition -o $@ + MYSQLX_PROTO_DIR := $(PROXYSQL_PATH)/plugins/mysqlx/proto MYSQLX_PROTO_SRCS := $(wildcard $(MYSQLX_PROTO_DIR)/*.pb.cc) MYSQLX_PROTO_OBJS := $(patsubst $(MYSQLX_PROTO_DIR)/%.pb.cc,$(ODIR)/mysqlx_proto_%.pb.o,$(MYSQLX_PROTO_SRCS)) 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..7f7506e3e4 --- /dev/null +++ b/test/tap/tests/pgsql-server_side_cursors-t.cpp @@ -0,0 +1,85 @@ +#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; + +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 +// (Task 4 finding: 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). + 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(); +} From 07c2ff77c7a85a8d26fdb4ddb0521764eb5515e3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:19:40 +0000 Subject: [PATCH 15/57] test(pgsql): mark portal-suspension assertion TODO (ProxySQL extended-protocol gap) --- test/tap/tests/pgsql-server_side_cursors-t.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/tap/tests/pgsql-server_side_cursors-t.cpp b/test/tap/tests/pgsql-server_side_cursors-t.cpp index 7f7506e3e4..0ac570cafd 100644 --- a/test/tap/tests/pgsql-server_side_cursors-t.cpp +++ b/test/tap/tests/pgsql-server_side_cursors-t.cpp @@ -79,7 +79,18 @@ int main(int argc, char** argv) { PQexec(c.get(), "CLOSE cur"); PQexec(c.get(), "COMMIT"); + // 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("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(); } From a005a8665ce6c2d501fe1bc92bab24eb48ca2cc2 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:26:21 +0000 Subject: [PATCH 16/57] test(pgsql): pool churn + session-state isolation across reuse --- test/tap/groups/groups.json | 1 + test/tap/tests/pgsql-pool_churn-t.cpp | 77 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 test/tap/tests/pgsql-pool_churn-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index 626e9fa4f0..e448853770 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -161,6 +161,7 @@ "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" ], 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..637068c8e9 --- /dev/null +++ b/test/tap/tests/pgsql-pool_churn-t.cpp @@ -0,0 +1,77 @@ +#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 backend reuse. + // Connection A sets a session GUC, then A is closed so its backend connection + // is returned to ProxySQL's pool. Connection B is then opened fresh: if + // ProxySQL reuses A's now-idle backend for B (which is exactly what pooling/ + // multiplexing is meant to do), B must NOT observe A's session state - a + // reset/isolation step must have run on the backend before handing it to B. + // Closing A first (rather than keeping A and B open concurrently) is what + // makes this a real reuse test: with A still open, ProxySQL could simply hand + // B an entirely different backend without ever exercising the reset path, + // which would make the "B doesn't see A's state" assertion pass trivially + // regardless of correctness. Closing A forces the backend to become eligible + // for reuse before B connects. + // + // NB: application_name is deliberately excluded from ProxySQL's tracked + // variables (see PgSQL_Variables::PgSQL_Variables() ignore_vars, in + // lib/PgSQL_Variables.cpp) - ProxySQL owns application_name on the backend + // connection for its own bookkeeping, so a client's SET application_name is + // parsed but never forwarded/tracked. That makes it unsuitable here: it + // would never round-trip regardless of pooling correctness. TimeZone *is* a + // tracked variable (pgsql_tracked_variables[] in include/proxysql_structs.h) + // that IS forwarded to, and reset on, the backend, so it actually exercises + // the reuse/reset path we want to test. + { + PGConnPtr a = mk(); + PQclear(PQexec(a.get(), "SET TimeZone = 'America/New_York'")); + std::string a_val = scalar(a.get(), "SHOW TimeZone"); + ok(a_val == "America/New_York", "connection A sees its own SET TimeZone"); + + a.reset(); // close A, returning its backend to the pool + + PGConnPtr b = mk(); + std::string b_val = scalar(b.get(), "SHOW TimeZone"); + ok(b_val != "America/New_York", "connection B does NOT inherit A's session state (got '%s')", b_val.c_str()); + } + + return exit_status(); +} From 60e6ce0a5646ee1971255dc932f4a2dbea660fc3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:39:13 +0000 Subject: [PATCH 17/57] test(pgsql): make session-isolation deterministically exercise backend reuse (Task 6 review) --- test/tap/tests/pgsql-pool_churn-t.cpp | 148 ++++++++++++++++++++------ 1 file changed, 113 insertions(+), 35 deletions(-) diff --git a/test/tap/tests/pgsql-pool_churn-t.cpp b/test/tap/tests/pgsql-pool_churn-t.cpp index 637068c8e9..7d299d209c 100644 --- a/test/tap/tests/pgsql-pool_churn-t.cpp +++ b/test/tap/tests/pgsql-pool_churn-t.cpp @@ -2,7 +2,9 @@ #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" @@ -25,53 +27,129 @@ static std::string scalar(PGconn* c, const char* q) { 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; +} + 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 backend reuse. - // Connection A sets a session GUC, then A is closed so its backend connection - // is returned to ProxySQL's pool. Connection B is then opened fresh: if - // ProxySQL reuses A's now-idle backend for B (which is exactly what pooling/ - // multiplexing is meant to do), B must NOT observe A's session state - a - // reset/isolation step must have run on the backend before handing it to B. - // Closing A first (rather than keeping A and B open concurrently) is what - // makes this a real reuse test: with A still open, ProxySQL could simply hand - // B an entirely different backend without ever exercising the reset path, - // which would make the "B doesn't see A's state" assertion pass trivially - // regardless of correctness. Closing A forces the backend to become eligible - // for reuse before B connects. + 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. // - // NB: application_name is deliberately excluded from ProxySQL's tracked - // variables (see PgSQL_Variables::PgSQL_Variables() ignore_vars, in - // lib/PgSQL_Variables.cpp) - ProxySQL owns application_name on the backend - // connection for its own bookkeeping, so a client's SET application_name is - // parsed but never forwarded/tracked. That makes it unsuitable here: it - // would never round-trip regardless of pooling correctness. TimeZone *is* a - // tracked variable (pgsql_tracked_variables[] in include/proxysql_structs.h) - // that IS forwarded to, and reset on, the backend, so it actually exercises - // the reuse/reset path we want to test. + // 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. + std::string orig_maxconn = admin_scalar(admin, + "SELECT max_connections FROM pgsql_servers ORDER BY hostgroup_id LIMIT 1"); + std::string orig_free_pct = admin_scalar(admin, + "SELECT variable_value FROM global_variables WHERE variable_name='pgsql-free_connections_pct'"); + if (orig_maxconn.empty()) orig_maxconn = "50"; // fall back to the infra default + 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 = 'America/New_York'")); + PQclear(PQexec(a.get(), "SET TimeZone = 'Antarctica/Troll'")); std::string a_val = scalar(a.get(), "SHOW TimeZone"); - ok(a_val == "America/New_York", "connection A sees its own SET TimeZone"); + ok(a_val == "Antarctica/Troll", "connection A sees its own SET TimeZone (got '%s')", a_val.c_str()); - a.reset(); // close A, returning its backend to the pool + a.reset(); // close A -> its single backend returns to the pool, session-dirtied - PGConnPtr b = mk(); + 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 != "America/New_York", "connection B does NOT inherit A's session state (got '%s')", b_val.c_str()); + 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. + admin_exec(admin, (std::string("UPDATE pgsql_servers SET max_connections=") + orig_maxconn).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(); } From 8a5a6c60c7365e1233ae43d1a540546ac48c3440 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:46:06 +0000 Subject: [PATCH 18/57] test(pgsql): LISTEN 0A000 rejection + NOTIFY-as-query contract (libpq path) --- test/tap/groups/groups.json | 1 + .../tests/pgsql-listen_notify_contract-t.cpp | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 test/tap/tests/pgsql-listen_notify_contract-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index e448853770..01131a10ec 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -156,6 +156,7 @@ "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-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" ], 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(); +} From 5cb2abc776d8be5a9c38e8b9e490b0a31bee8f3e Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 02:54:02 +0000 Subject: [PATCH 19/57] test(pgsql): scope pool_churn cap/restore per-hostgroup + document SCRAM Makefile flags (final review) --- test/tap/tests/Makefile | 1 + test/tap/tests/pgsql-pool_churn-t.cpp | 42 +++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index db7b88d5ea..8edd788d1d 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -385,6 +385,7 @@ test_ffto_pgsql_pipeline-t: test_ffto_pgsql_pipeline-t.cpp pg_lite_client.cpp $( test_ffto_pgsql_stmt_portal-t: test_ffto_pgsql_stmt_portal-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ +# -lscram/-lusual: pg_lite_client.cpp uses libscram client SCRAM; --allow-multiple-definition resolves duplicate symbols between libscram/libusual and other vendored static libs (test binaries only). 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 $@ diff --git a/test/tap/tests/pgsql-pool_churn-t.cpp b/test/tap/tests/pgsql-pool_churn-t.cpp index 7d299d209c..d8e326eab8 100644 --- a/test/tap/tests/pgsql-pool_churn-t.cpp +++ b/test/tap/tests/pgsql-pool_churn-t.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include "libpq-fe.h" #include // admin interface is reached via the MySQL client @@ -57,6 +58,21 @@ static std::string admin_scalar(MYSQL* a, const char* q) { 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); @@ -93,11 +109,15 @@ int main(int argc, char** argv) { // --------------------------------------------------------------------------- // Snapshot originals so we can restore regardless of assertion outcome. - std::string orig_maxconn = admin_scalar(admin, - "SELECT max_connections FROM pgsql_servers ORDER BY hostgroup_id LIMIT 1"); + // 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_maxconn.empty()) orig_maxconn = "50"; // fall back to the infra default if (orig_free_pct.empty()) orig_free_pct = "10"; // documented default // Cap the pool to a single backend and keep no clean spares. @@ -131,8 +151,20 @@ int main(int argc, char** argv) { } // Restore pool config BEFORE the storm and before returning, so the infra is - // left at its originals even if an assertion above failed. - admin_exec(admin, (std::string("UPDATE pgsql_servers SET max_connections=") + orig_maxconn).c_str()); + // 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"); From f220a70c4b4420b82b7eed9a57a8d509b09de1ea Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:13:30 +0000 Subject: [PATCH 20/57] test(pgsql): cross-reference filed issues #5899/#5900 in test comments --- test/tap/tests/pgsql-datatype_matrix-t.cpp | 4 ++-- test/tap/tests/pgsql-server_side_cursors-t.cpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/tap/tests/pgsql-datatype_matrix-t.cpp b/test/tap/tests/pgsql-datatype_matrix-t.cpp index 26e517ada7..5f714fb373 100644 --- a/test/tap/tests/pgsql-datatype_matrix-t.cpp +++ b/test/tap/tests/pgsql-datatype_matrix-t.cpp @@ -110,8 +110,8 @@ static bool run_case(const Case& c, int16_t fmt, Observed& obs) { // 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 - // (PgSQL_Connection.cpp stmt_execute_start() rejects num_param_formats==1 with - // num_params==0, though the PG protocol spec allows it). bindStatementEx() with + // (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, {}); diff --git a/test/tap/tests/pgsql-server_side_cursors-t.cpp b/test/tap/tests/pgsql-server_side_cursors-t.cpp index 0ac570cafd..e65badee17 100644 --- a/test/tap/tests/pgsql-server_side_cursors-t.cpp +++ b/test/tap/tests/pgsql-server_side_cursors-t.cpp @@ -23,7 +23,7 @@ static PGConnPtr backend_conn() { // 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 -// (Task 4 finding: PgSQL_Connection.cpp's stmt_execute_start() rejects this with +// (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 @@ -86,8 +86,8 @@ int main(int argc, char** argv) { // 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("ProxySQL extended-protocol portal suspension unimplemented: Execute max_rows ignored, " - "no PortalSuspended emitted (lib/PgSQL_Extended_Query_Message.cpp:490). Remove this todo " + 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(); From f7e04e7b7f4bffb296f0a67263426bc4dab11910 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:22:53 +0000 Subject: [PATCH 21/57] spike(pg-compat): capture exact dbdeployer PostgreSQL deploy command + ports --- test/pg-compat/SPIKE-dbdeployer-pg.md | 354 ++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 test/pg-compat/SPIKE-dbdeployer-pg.md diff --git a/test/pg-compat/SPIKE-dbdeployer-pg.md b/test/pg-compat/SPIKE-dbdeployer-pg.md new file mode 100644 index 0000000000..86eea9aba0 --- /dev/null +++ b/test/pg-compat/SPIKE-dbdeployer-pg.md @@ -0,0 +1,354 @@ +# 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 + +# Unpack via dbdeployer (as a NON-ROOT user — see §4) +cd /root/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/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/own the downloaded .deb files, then, as pguser: +su - pguser -c '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 + +Run as `pguser` (see §4), after `dbdeployer defaults update reserved-ports '0'` (same +reserved-ports-clearing step as the MySQL GR image; **relevant for PG too since dbdeployer +reserves 5432 by default**): + +```bash +dbdeployer deploy replication 17.10 \ + --provider=postgresql \ + --topology=master-slave \ + --nodes=3 \ + --bind-address=0.0.0.0 \ + --base-port=5432 \ + -c listen_addresses=0.0.0.0 \ + -c max_connections=500 \ + -c shared_preload_libraries=pg_stat_statements +``` + +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. `--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. +5. `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. +6. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory + tree directly. +7. 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. From c4926e1eb4796f37087e2189c087392d052512eb Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:30:39 +0000 Subject: [PATCH 22/57] spike(pg-compat): fix doc gaps from review (deb ownership step, reserved-ports claim, final command block) --- test/pg-compat/SPIKE-dbdeployer-pg.md | 82 ++++++++++++++++++++------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/test/pg-compat/SPIKE-dbdeployer-pg.md b/test/pg-compat/SPIKE-dbdeployer-pg.md index 86eea9aba0..37b19d5879 100644 --- a/test/pg-compat/SPIKE-dbdeployer-pg.md +++ b/test/pg-compat/SPIKE-dbdeployer-pg.md @@ -68,12 +68,22 @@ apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root # postgresql-17_17.10-1.pgdg22.04+1_amd64.deb # postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb -# Unpack via dbdeployer (as a NON-ROOT user — see §4) -cd /root/pgdebs -dbdeployer unpack --provider=postgresql \ +# 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/opt/postgresql/17.10" + 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 @@ -136,8 +146,13 @@ for PG (`unpack`, `deploy postgresql`, `deploy replication --provider=postgresql ```bash useradd -m -s /bin/bash pguser -# copy/own the downloaded .deb files, then, as pguser: -su - pguser -c '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' +# 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` @@ -146,20 +161,45 @@ commands only, similar to how official `postgres` images drop privileges. ## 5. The exact WORKING `dbdeployer deploy replication` command -Run as `pguser` (see §4), after `dbdeployer defaults update reserved-ports '0'` (same -reserved-ports-clearing step as the MySQL GR image; **relevant for PG too since dbdeployer -reserves 5432 by default**): +### 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 \ - --base-port=5432 \ - -c listen_addresses=0.0.0.0 \ - -c max_connections=500 \ - -c shared_preload_libraries=pg_stat_statements + --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: @@ -335,17 +375,21 @@ psql -h -p 16710 -U postgres -c "SELECT 1 AS remote_ok;" -- 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. `--base-port`, `--bind-address`, `-c my-cnf-options`, and `--db-user`/`--db-password` are all +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. -5. `pg_hba.conf` defaults to loopback-only `trust` rules — must append a wider `host ... 0.0.0.0/0` +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. -6. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory +7. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory tree directly. -7. No dedicated replication role/slots — replicas stream as `postgres` itself with no password +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. From c8cc4e667dca5f8c58b03aa932bcf197ee4f1605 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:46:24 +0000 Subject: [PATCH 23/57] infra(pg-compat): dbdeployer PG17 primary+2-replica infra with pg_stat_statements --- test/infra/infra-dbdeployer-pgsql17-repl/.env | 13 ++ .../bin/docker-pgsql-post.bash | 63 ++++++ .../bin/docker-proxy-post.bash | 12 ++ .../docker-compose-destroy.bash | 11 + .../docker-compose-init.bash | 153 ++++++++++++++ .../docker-compose.yml | 22 ++ .../docker/Dockerfile | 85 ++++++++ .../docker/build.sh | 8 + .../docker/entrypoint.sh | 196 ++++++++++++++++++ test/tap/groups/pg-compat/env.sh | 15 ++ test/tap/groups/pg-compat/infras.lst | 1 + 11 files changed, 579 insertions(+) create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/.env create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh create mode 100644 test/tap/groups/pg-compat/env.sh create mode 100644 test/tap/groups/pg-compat/infras.lst 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..ffefe792ca --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -0,0 +1,12 @@ +#!/bin/bash +# Placeholder: ProxySQL configuration for this PG replication backend is added +# in SP-2 Task 4 (pgsql_servers + pgsql_replication_hostgroups + monitor). Kept +# as a no-op so the control flow (docker-compose-init.bash / ensure-infras.bash) +# that unconditionally invokes ./bin/docker-proxy-post.bash succeeds today. +set -e +set -o pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "${SCRIPT_DIR}/../.env" ] && . "${SCRIPT_DIR}/../.env" + +echo ">>> docker-proxy-post.bash (infra-dbdeployer-pgsql17-repl): no-op placeholder (ProxySQL config lands in SP-2 Task 4)." +exit 0 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..9a0841c4f9 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -0,0 +1,153 @@ +#!/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 MySQL 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 +[ -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..c93ef81310 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml @@ -0,0 +1,22 @@ +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} + +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..d6dee385e6 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile @@ -0,0 +1,85 @@ +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. +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. +ENV PATH="/home/pguser/opt/postgresql/17.10/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..0fce4b7cf9 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh @@ -0,0 +1,196 @@ +#!/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. +ROOT_PASSWORD="${ROOT_PASSWORD:-default_password}" +INFRA="${INFRA:-infra-dbdeployer-pgsql17-repl}" + +# --------------------------------------------------------------------------- +# 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/tap/groups/pg-compat/env.sh b/test/tap/groups/pg-compat/env.sh new file mode 100644 index 0000000000..fa28d100f4 --- /dev/null +++ b/test/tap/groups/pg-compat/env.sh @@ -0,0 +1,15 @@ +# 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" 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 From 218e552c0174ccabf02772fb03943dabbaddd06f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:54:26 +0000 Subject: [PATCH 24/57] infra(pg-compat): parameterize Dockerfile PATH on PG_VERSION + review minors --- .../docker-compose-init.bash | 2 +- .../infra-dbdeployer-pgsql17-repl/docker/Dockerfile | 9 ++++++--- .../infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh | 8 ++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash index 9a0841c4f9..84e75b544d 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -126,7 +126,7 @@ if [ -f /.dockerenv ]; then docker network connect "${INFRA_ID}_backend" "${RUNNER_ID}" || true fi -# 6. Wait for dbdeployer entrypoint to finish MySQL deployment +# 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 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile index d6dee385e6..7438b52efa 100644 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile @@ -5,7 +5,8 @@ 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. +# 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 @@ -73,8 +74,10 @@ 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. -ENV PATH="/home/pguser/opt/postgresql/17.10/bin:${PATH}" +# 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 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh index 0fce4b7cf9..ffeb6c1d90 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh @@ -7,9 +7,10 @@ echo "dbdeployer entrypoint: deploying PostgreSQL 17 streaming replication" echo " (1 primary + 2 replicas, master-slave topology)" echo "========================================================================" -# Passed in via docker-compose environment. +# 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}" -INFRA="${INFRA:-infra-dbdeployer-pgsql17-repl}" # --------------------------------------------------------------------------- # 1. Detect the pre-baked PostgreSQL version (unpacked at image-build time). @@ -150,6 +151,9 @@ ${PSQL_PRIMARY} -v ON_ERROR_STOP=1 -c "SET client_min_messages='error';" \ -c "CREATE DATABASE testuser OWNER testuser;" psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d testuser -v ON_ERROR_STOP=1 \ -c "SET client_min_messages='error';" -c "GRANT ALL ON SCHEMA public TO testuser;" +# Intentional second grant: the same grant against the 'postgres' database's +# public schema, since some tests use 'postgres' as their default DB (mirrors +# docker-pgsql16-single/bin/docker-pgsql-post.bash, which grants on both). ${PSQL_PRIMARY} -v ON_ERROR_STOP=1 \ -c "SET client_min_messages='error';" -c "GRANT ALL ON SCHEMA public TO testuser;" From 230fbea1cabcd240646d4332599842996d7a90e5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:58:58 +0000 Subject: [PATCH 25/57] infra(pg-compat): Toxiproxy sidecar with per-backend passthrough proxies --- .../bin/toxiproxy-bootstrap.sh | 68 +++++++++++++++++++ .../docker-compose-init.bash | 12 ++++ .../docker-compose.yml | 13 ++++ test/tap/groups/pg-compat/env.sh | 10 +++ 4 files changed, 103 insertions(+) create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh 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/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash index 84e75b544d..b46888cc50 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -146,6 +146,18 @@ 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 "================================================================================" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml index c93ef81310..d010f3e6fa 100644 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml @@ -16,6 +16,19 @@ services: # 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" diff --git a/test/tap/groups/pg-compat/env.sh b/test/tap/groups/pg-compat/env.sh index fa28d100f4..621ef8626b 100644 --- a/test/tap/groups/pg-compat/env.sh +++ b/test/tap/groups/pg-compat/env.sh @@ -13,3 +13,13 @@ 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" From f738a401fb904cc13d1ae08c1772d6ce5a01fd04 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:11:10 +0000 Subject: [PATCH 26/57] config(pg-compat): automatic pgsql_replication_hostgroups via Toxiproxy Fill in infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash (was a no-op placeholder) and add its conf/proxysql/infra-config.sql: all three PG backends load into the writer hostgroup addressed through Toxiproxy (toxiproxy.${INFRA_ID}:6001/6002/6003), pgsql_replication_hostgroups drives automatic rw-split via check_type='read_only', and the monitor (monitor/monitor role, 1000ms read_only_interval) demotes replicas by polling pg_is_in_recovery() through the proxy. Uses ${INFRA_ID} rather than ${INFRA} for hostnames/comment-tagging: ${INFRA} is only reliably exported when this script runs via docker-compose-init.bash, not via ensure-infras.bash's already-running reconfigure path, which would otherwise template unresolvable "toxiproxy." hostnames. docker-compose.yml already provisions the toxiproxy.${INFRA_ID}/dbdeployer1.${INFRA_ID} aliases for this reason. Verified end-to-end on infra sdd-sp2: applied via test/infra/control/ensure-infras.bash, monitor demotes both replicas within a few seconds (runtime_pgsql_servers: 1 writer + 2 readers, pgsql_server_read_only_log clean), and frontend routing on port 6133 is correct (SELECT pg_is_in_recovery() -> reader, writes -> writer). --- .../bin/docker-proxy-post.bash | 35 +++++++-- .../conf/proxysql/infra-config.sql | 73 +++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql 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 index ffefe792ca..17444ac579 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -1,12 +1,35 @@ #!/bin/bash -# Placeholder: ProxySQL configuration for this PG replication backend is added -# in SP-2 Task 4 (pgsql_servers + pgsql_replication_hostgroups + monitor). Kept -# as a no-op so the control flow (docker-compose-init.bash / ensure-infras.bash) -# that unconditionally invokes ./bin/docker-proxy-post.bash succeeds today. +# 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}" -echo ">>> docker-proxy-post.bash (infra-dbdeployer-pgsql17-repl): no-op placeholder (ProxySQL config lands in SP-2 Task 4)." -exit 0 +# 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. +while ! docker exec "${PROXY_CONTAINER}" mysql -uadmin -padmin -h127.0.0.1 -P6032 -e 'SELECT 1' >/dev/null 2>&1; do + echo -n '.' + sleep 1 +done + +# Pre-process the SQL template. +SQL_TEMPLATE=$(cat ./conf/proxysql/infra-config.sql) +SQL_CONTENT=$(eval "echo \"${SQL_TEMPLATE}\"") + +# Apply configuration via docker exec using psql (ProxySQL Admin supports PG protocol on port 6132). +echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -h127.0.0.1 -p6132 -Uadmin -dadmin 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; From 09336acc42729627d1d4a199be19303d9aedaa4b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:16:00 +0000 Subject: [PATCH 27/57] config(pg-compat): fail proxy-post non-zero on SQL errors (ON_ERROR_STOP) Add -v ON_ERROR_STOP=1 to the psql invocation that applies infra-config.sql to the ProxySQL admin (PG protocol, port 6132). Without it psql prints SQL-level errors (bad token, constraint violation, duplicate rule_id) but continues and exits 0, so set -e never fires and the script's fail-non-zero contract was silently defeated -- connection failures were caught, SQL failures were not. Matches the precedent in this infra's docker/entrypoint.sh and infra-pgsql17-repl's init-replication.sh. Verified on sdd-sp2: a bogus statement piped with ON_ERROR_STOP=1 now aborts with exit 3 at the first error (without the flag the same input kept executing and exited 0), and a full ensure-infras.bash re-apply still exits 0 leaving runtime_pgsql_servers in the expected 1-writer/2-reader state. --- .../bin/docker-proxy-post.bash | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 17444ac579..29bb8578c1 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -31,5 +31,9 @@ done SQL_TEMPLATE=$(cat ./conf/proxysql/infra-config.sql) SQL_CONTENT=$(eval "echo \"${SQL_TEMPLATE}\"") -# Apply configuration via docker exec using psql (ProxySQL Admin supports PG protocol on port 6132). -echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -h127.0.0.1 -p6132 -Uadmin -dadmin +# 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 From 080f7833a7e571cce695aa8252b1193c3ffec99d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:28:41 +0000 Subject: [PATCH 28/57] test(pg-compat): pytest harness skeleton + admin config primitive + smoke --- test/pg-compat/Dockerfile | 7 +++ test/pg-compat/README.md | 82 ++++++++++++++++++++++++++++++ test/pg-compat/conftest.py | 24 +++++++++ test/pg-compat/harness/__init__.py | 0 test/pg-compat/harness/proxysql.py | 67 ++++++++++++++++++++++++ test/pg-compat/pytest.ini | 2 + test/pg-compat/requirements.txt | 4 ++ test/pg-compat/run-pg-compat.bash | 43 ++++++++++++++++ test/pg-compat/tests/test_smoke.py | 28 ++++++++++ 9 files changed, 257 insertions(+) create mode 100644 test/pg-compat/Dockerfile create mode 100644 test/pg-compat/README.md create mode 100644 test/pg-compat/conftest.py create mode 100644 test/pg-compat/harness/__init__.py create mode 100644 test/pg-compat/harness/proxysql.py create mode 100644 test/pg-compat/pytest.ini create mode 100644 test/pg-compat/requirements.txt create mode 100755 test/pg-compat/run-pg-compat.bash create mode 100644 test/pg-compat/tests/test_smoke.py diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile new file mode 100644 index 0000000000..5441775525 --- /dev/null +++ b/test/pg-compat/Dockerfile @@ -0,0 +1,7 @@ +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"] diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md new file mode 100644 index 0000000000..0997ab9e1a --- /dev/null +++ b/test/pg-compat/README.md @@ -0,0 +1,82 @@ +# 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 (added in a later task; see +`docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md`) 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). + +## 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. + +## 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. +- `conftest.py` — `admin` (session-scoped `Admin`) and `proxy_conn` + (function-scoped psycopg connection to the ProxySQL PG frontend) + fixtures shared by all tests. +- `tests/` — the test suite (`test_smoke.py` today). +- `SPIKE-dbdeployer-pg.md` — prior spike notes on the dbdeployer PG infra; + left as-is. diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py new file mode 100644 index 0000000000..511f6f5758 --- /dev/null +++ b/test/pg-compat/conftest.py @@ -0,0 +1,24 @@ +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() 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/proxysql.py b/test/pg-compat/harness/proxysql.py new file mode 100644 index 0000000000..303cb310d1 --- /dev/null +++ b/test/pg-compat/harness/proxysql.py @@ -0,0 +1,67 @@ +"""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" + + +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): + # Verified empirically against the running admin: both + # `SET name=1` and `SET name='1'` are accepted and applied + # identically for a numeric variable (pgsql-authentication_method). + # Quote only non-numeric strings, matching the brief. + 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): + placeholders = ",".join(f"'{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/pytest.ini b/test/pg-compat/pytest.ini new file mode 100644 index 0000000000..5ee6477165 --- /dev/null +++ b/test/pg-compat/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/test/pg-compat/requirements.txt b/test/pg-compat/requirements.txt new file mode 100644 index 0000000000..46c18ce4d1 --- /dev/null +++ b/test/pg-compat/requirements.txt @@ -0,0 +1,4 @@ +psycopg[binary]==3.2.* +asyncpg==0.30.* +pytest==8.* +tomli==2.* diff --git a/test/pg-compat/run-pg-compat.bash b/test/pg-compat/run-pg-compat.bash new file mode 100755 index 0000000000..55581412ca --- /dev/null +++ b/test/pg-compat/run-pg-compat.bash @@ -0,0 +1,43 @@ +#!/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_') + +docker run --rm --network "${NETWORK}" \ + -e INFRA_ID \ + "${ENV_ARGS[@]}" \ + proxysql-pg-compat:latest "$@" 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) From b4b811f4c9f9acda9baf81e2c0a1f0f1d372c7ac Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:33:59 +0000 Subject: [PATCH 29/57] test(pg-compat): SQL-safe quoting in Admin.set_var/snapshot --- test/pg-compat/harness/proxysql.py | 42 +++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/test/pg-compat/harness/proxysql.py b/test/pg-compat/harness/proxysql.py index 303cb310d1..5812a228e7 100644 --- a/test/pg-compat/harness/proxysql.py +++ b/test/pg-compat/harness/proxysql.py @@ -34,6 +34,21 @@ def _admin_dsn(): 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): self.conn = psycopg.connect(_admin_dsn(), autocommit=True) @@ -44,17 +59,32 @@ def query(self, sql): return cur.fetchall() if cur.description else None def set_var(self, name, value): - # Verified empirically against the running admin: both - # `SET name=1` and `SET name='1'` are accepted and applied - # identically for a numeric variable (pgsql-authentication_method). - # Quote only non-numeric strings, matching the brief. - self.query(f"SET {name}={value!r}" if isinstance(value, str) else f"SET {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): - placeholders = ",".join(f"'{v}'" for v in 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})" From 96316bfa349540f6752429e5edf056b3c86dc570 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:44:05 +0000 Subject: [PATCH 30/57] test(pg-compat): 6-target differential engine + cases + divergence self-check --- test/pg-compat/cases/001_scalars.sql | 2 + test/pg-compat/cases/002_bytea_json_array.sql | 2 + test/pg-compat/harness/diff.py | 129 ++++++++++++++++++ test/pg-compat/harness/proxysql.py | 11 +- test/pg-compat/harness/targets.py | 98 +++++++++++++ test/pg-compat/tests/test_differential.py | 48 +++++++ .../tests/test_differential_selfcheck.py | 63 +++++++++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 test/pg-compat/cases/001_scalars.sql create mode 100644 test/pg-compat/cases/002_bytea_json_array.sql create mode 100644 test/pg-compat/harness/diff.py create mode 100644 test/pg-compat/harness/targets.py create mode 100644 test/pg-compat/tests/test_differential.py create mode 100644 test/pg-compat/tests/test_differential_selfcheck.py 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/harness/diff.py b/test/pg-compat/harness/diff.py new file mode 100644 index 0000000000..721cd66452 --- /dev/null +++ b/test/pg-compat/harness/diff.py @@ -0,0 +1,129 @@ +"""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 exactly these targets + ``-- 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. 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 + +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 _statements(sql): + return [ + s.strip() + for s in sql.split(";") + if s.strip() and not s.strip().startswith("--") + ] + + +def _parse_case_file(case_file): + with open(case_file) as f: + sql = f.read() + 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 + results = {} + for t in targets: + if not t.available: + continue + if t.name in skip: + continue + if only and t.name not in only: + continue + results[t.name] = _run_on(t, stmts, admin, native_present) + return results + + +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) + 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) + return _run(_statements(sql), 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)``. + """ + def base(name): + return "direct_binary" if name.endswith("binary") else "direct_text" + + diffs = [] + for name in sorted(results): + if name.startswith("direct"): + continue + b_name = base(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/proxysql.py b/test/pg-compat/harness/proxysql.py index 5812a228e7..c26fabc141 100644 --- a/test/pg-compat/harness/proxysql.py +++ b/test/pg-compat/harness/proxysql.py @@ -51,7 +51,16 @@ def _sql_quote(value): class Admin: def __init__(self): - self.conn = psycopg.connect(_admin_dsn(), autocommit=True) + # 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: diff --git a/test/pg-compat/harness/targets.py b/test/pg-compat/harness/targets.py new file mode 100644 index 0000000000..467fb84046 --- /dev/null +++ b/test/pg-compat/harness/targets.py @@ -0,0 +1,98 @@ +"""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"): + return ( + f"host={host} port={port} user={user} password={pw} " + f"dbname={dbname} sslmode=disable" + ) + + +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/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..39b5b43fd3 --- /dev/null +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -0,0 +1,63 @@ +"""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 +""" +from harness import targets, diff + +# Must sort before the infra RW-split reader rule (rule_id 101, apply=1). +SELFCHECK_RULE_ID = 90 + + +def test_engine_detects_divergence(admin): + 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") + try: + 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") From a3f91dd6aee750c6a6220ea581aaccfef708ce8a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:56:31 +0000 Subject: [PATCH 31/57] fix(pg-compat): line-aware case parsing + empty-case guard + pipeline self-check (Task 6 review) --- test/pg-compat/harness/diff.py | 35 +++++++++-- test/pg-compat/harness/targets.py | 13 +++- .../tests/test_differential_selfcheck.py | 59 ++++++++++++++++--- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py index 721cd66452..315b7f3a1b 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -42,16 +42,26 @@ def _parse_meta(sql): def _statements(sql): - return [ - s.strip() - for s in sql.split(";") - if s.strip() and not s.strip().startswith("--") - ] + # 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 @@ -93,13 +103,26 @@ def _run(stmts, targets, admin, skip, only): 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) - return _run(_statements(sql), targets, admin, skip, only) + 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): diff --git a/test/pg-compat/harness/targets.py b/test/pg-compat/harness/targets.py index 467fb84046..e7d33a9943 100644 --- a/test/pg-compat/harness/targets.py +++ b/test/pg-compat/harness/targets.py @@ -39,9 +39,20 @@ 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" + f"dbname={dbname} sslmode=disable client_encoding=UTF8" ) diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py index 39b5b43fd3..dc70c9530f 100644 --- a/test/pg-compat/tests/test_differential_selfcheck.py +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -34,21 +34,29 @@ 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): - 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") 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) @@ -61,3 +69,40 @@ def test_engine_detects_divergence(admin): f"DELETE FROM pgsql_query_rules WHERE rule_id={SELFCHECK_RULE_ID}" ) admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + + +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})" From fb5ce59272142e0936a0c6ce36eab42ebdf37f4f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:03:19 +0000 Subject: [PATCH 32/57] test(pg-compat): pg_stat_statements routing oracle + write-pin self-check --- test/pg-compat/harness/oracle.py | 91 +++++++++++++++++++++ test/pg-compat/tests/test_routing_oracle.py | 58 +++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 test/pg-compat/harness/oracle.py create mode 100644 test/pg-compat/tests/test_routing_oracle.py 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/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}") From 5e8435426ea067e62d3399b4eda5b079a9c52efe Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:10:58 +0000 Subject: [PATCH 33/57] test(pg-compat): shared behavior set + Python (psycopg3) driver adapter Adds the driver-agnostic behavior set (connect, transactions, prepared, session_isolation) behind a small adapter interface, plus the psycopg3 reference adapter, so SP-3 can add Java/Go/Node adapters against the same behaviors with no changes to the behavior modules. Two adaptations to the original brief, folded in from SP-1 lessons: - session_isolation probes TimeZone instead of application_name, since ProxySQL hardcodes application_name in ignore_vars (lib/PgSQL_Variables.cpp) and never forwards it to the backend. - transactions wraps every verification read in an explicit begin()/commit() so it pins to the writer hostgroup, avoiding a replica read-after-write race under the RW-split (^SELECT -> reader). Verified empirically via the routing oracle: the verify-read lands 2/0/0 on primary/replica1/replica2. Also fixes a real psycopg3 API mismatch in the brief's prepared.py (SQL must use %s placeholders, not raw $1/$2, for cursor.execute(sql, params)). --- test/pg-compat/behaviors/__init__.py | 0 test/pg-compat/behaviors/connect.py | 11 ++++ test/pg-compat/behaviors/prepared.py | 33 ++++++++++ test/pg-compat/behaviors/session_isolation.py | 46 ++++++++++++++ test/pg-compat/behaviors/transactions.py | 56 +++++++++++++++++ test/pg-compat/drivers/__init__.py | 0 test/pg-compat/drivers/python/__init__.py | 0 test/pg-compat/drivers/python/adapter.py | 63 +++++++++++++++++++ test/pg-compat/tests/test_behaviors.py | 20 ++++++ 9 files changed, 229 insertions(+) create mode 100644 test/pg-compat/behaviors/__init__.py create mode 100644 test/pg-compat/behaviors/connect.py create mode 100644 test/pg-compat/behaviors/prepared.py create mode 100644 test/pg-compat/behaviors/session_isolation.py create mode 100644 test/pg-compat/behaviors/transactions.py create mode 100644 test/pg-compat/drivers/__init__.py create mode 100644 test/pg-compat/drivers/python/__init__.py create mode 100644 test/pg-compat/drivers/python/adapter.py create mode 100644 test/pg-compat/tests/test_behaviors.py 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..1d10aea54b --- /dev/null +++ b/test/pg-compat/behaviors/connect.py @@ -0,0 +1,11 @@ +"""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() + assert a.exec_simple("SELECT 1")[0][0] == 1 + a.close() diff --git a/test/pg-compat/behaviors/prepared.py b/test/pg-compat/behaviors/prepared.py new file mode 100644 index 0000000000..2831d4a21a --- /dev/null +++ b/test/pg-compat/behaviors/prepared.py @@ -0,0 +1,33 @@ +"""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() + for i in range(50): + r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) + assert r[0][0] == i + 1 + 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..a64290b694 --- /dev/null +++ b/test/pg-compat/behaviors/session_isolation.py @@ -0,0 +1,46 @@ +"""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() + a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") + assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ + a.close() + + b = Adapter() + val = b.exec_simple("SHOW TimeZone")[0][0] + b.close() + assert val != DISTINCTIVE_TZ, "session state leaked across connections" diff --git a/test/pg-compat/behaviors/transactions.py b/test/pg-compat/behaviors/transactions.py new file mode 100644 index 0000000000..ad70fac3f9 --- /dev/null +++ b/test/pg-compat/behaviors/transactions.py @@ -0,0 +1,56 @@ +"""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 + # (harness/oracle.py's own probe table is "oracle_w") so runs never + # collide. + a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") + a.close() 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/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..28ca76a572 --- /dev/null +++ b/test/pg-compat/drivers/python/adapter.py @@ -0,0 +1,63 @@ +"""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): + self.conn.close() 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) From 1745a9d8337c46e631714583e75fb5f75d7bcd65 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:17:36 +0000 Subject: [PATCH 34/57] test(pg-compat): xfail catalogue with xpass reporting (discovery phase) --- test/pg-compat/README.md | 52 +++++++++++++++++++++++++--- test/pg-compat/conftest.py | 27 +++++++++++++++ test/pg-compat/harness/xfail.py | 42 +++++++++++++++++++++++ test/pg-compat/pytest.ini | 5 +++ test/pg-compat/xfail.toml | 60 +++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 test/pg-compat/harness/xfail.py create mode 100644 test/pg-compat/xfail.toml diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index 0997ab9e1a..5a81ea20fc 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -9,9 +9,8 @@ interface, running against the `infra-dbdeployer-pgsql17-repl` backend 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 (added in a later task; see -`docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md`) rather -than by loosening assertions. +`xfail` entries in `xfail.toml` (see "xfail / finding catalogue" below) +rather than by loosening assertions. ## Running @@ -69,14 +68,57 @@ 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. -- `tests/` — the test suite (`test_smoke.py` today). + 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/conftest.py b/test/pg-compat/conftest.py index 511f6f5758..8f47c19755 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -3,6 +3,7 @@ import psycopg import pytest +from harness import xfail as _xfail from harness.proxysql import Admin @@ -22,3 +23,29 @@ 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): + for item in items: + entry = _XFAILS.get(item.nodeid) + if entry: + item.add_marker( + pytest.mark.xfail( + reason=f'{entry["reason"]} ({entry["ref"]})', strict=False + ) + ) 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 index 5ee6477165..3182775ee3 100644 --- a/test/pg-compat/pytest.ini +++ b/test/pg-compat/pytest.ini @@ -1,2 +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/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" From a95fa02f8a5f51777f4c960266a18030818579a9 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:32:44 +0000 Subject: [PATCH 35/57] ci(pg-compat): nightly + label-gated workflow (caller + staged reusable, non-gating) Wires test/pg-compat/ into CI: schedule (nightly) + pg-compat-labeled PRs + manual dispatch, building ProxySQL inline (CI-3p-* model) rather than chaining off CI-trigger/CI-builds. Non-gating during the discovery phase (spec sec 2.1): the run step uses `|| true` and publishes the junitxml as an artifact regardless of outcome. Follows the two-branch caller/reusable split: CI-pg-compat.yml (caller, v3.0) + gh-actions-reusable/ci-pg-compat.yml (reusable, staged here for merge to GH-Actions first, per doc/GH-Actions/README.md's merge-order requirement -- both files call this out at the top). Also fixes a real bug in run-pg-compat.bash traced while wiring this up: the pytest container runs with --rm and had no volume mount, so any --junitxml report written inside it was destroyed on exit before ever reaching the host. run-pg-compat.bash now bind-mounts a host directory (default ${WORKSPACE}/pg-compat-reports, override via PGCOMPAT_REPORT_DIR) to /pg-compat-reports in the container so reports survive; verified end-to-end with a local dry run (throwaway ci-dryrun infra) that the file lands on the host at the exact path the CI artifact step reads, including the root-owned-file chown needed before upload. --- .github/workflows/CI-pg-compat.yml | 47 +++++++ .../gh-actions-reusable/ci-pg-compat.yml | 124 ++++++++++++++++++ .gitignore | 4 + test/pg-compat/README.md | 33 +++++ test/pg-compat/run-pg-compat.bash | 16 +++ 5 files changed, 224 insertions(+) create mode 100644 .github/workflows/CI-pg-compat.yml create mode 100644 .github/workflows/gh-actions-reusable/ci-pg-compat.yml diff --git a/.github/workflows/CI-pg-compat.yml b/.github/workflows/CI-pg-compat.yml new file mode 100644 index 0000000000..64ead1b3cb --- /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 + uses: sysown/proxysql/.github/workflows/ci-pg-compat.yml@GH-Actions + secrets: inherit + 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..0b0c8a4e49 --- /dev/null +++ b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml @@ -0,0 +1,124 @@ +# 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 + + # 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 + + # 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 8084d0a006..5c0847984e 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,7 @@ test-scripts/deps/ 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/ diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index 5a81ea20fc..f2a0b7aaf2 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -35,6 +35,39 @@ 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 +``` + +## 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 diff --git a/test/pg-compat/run-pg-compat.bash b/test/pg-compat/run-pg-compat.bash index 55581412ca..a0e4107717 100755 --- a/test/pg-compat/run-pg-compat.bash +++ b/test/pg-compat/run-pg-compat.bash @@ -37,7 +37,23 @@ while IFS='=' read -r name _; do 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 "$@" From 2085044a5f21551c30f7504b3cae194f0a9eb3f9 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:46:24 +0000 Subject: [PATCH 36/57] fix(pg-compat): native-var restore in differential engine + resource/consistency hardening (final review) - diff.py: snapshot/restore pgsql-use_native_backend_protocol around the target loop in _run() (shared by run_case/run_case_sql) so a native-mode toggle never leaks into later cases once PR #5882 lands the variable; a pure no-op today since the variable is absent. - conftest.py: pin client_encoding=UTF8 on the proxy DSN, matching targets.py and drivers/python/adapter.py. - behaviors/{connect,prepared,session_isolation}.py: wrap bodies in try/finally so connections close even on assert failure; make PsycopgAdapter.close() idempotent since session_isolation.py's finally may close an already-closed connection. - behaviors/transactions.py: fix stale comment pointing at a nonexistent harness/oracle.py; oracle_w lives in tests/test_routing_oracle.py. --- test/pg-compat/behaviors/connect.py | 6 ++- test/pg-compat/behaviors/prepared.py | 10 ++-- test/pg-compat/behaviors/session_isolation.py | 24 +++++++--- test/pg-compat/behaviors/transactions.py | 4 +- test/pg-compat/conftest.py | 9 +++- test/pg-compat/drivers/python/adapter.py | 9 +++- test/pg-compat/harness/diff.py | 46 +++++++++++++------ 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py index 1d10aea54b..4fb36709ef 100644 --- a/test/pg-compat/behaviors/connect.py +++ b/test/pg-compat/behaviors/connect.py @@ -7,5 +7,7 @@ def run(Adapter): a = Adapter() - assert a.exec_simple("SELECT 1")[0][0] == 1 - a.close() + try: + assert a.exec_simple("SELECT 1")[0][0] == 1 + finally: + a.close() diff --git a/test/pg-compat/behaviors/prepared.py b/test/pg-compat/behaviors/prepared.py index 2831d4a21a..43f7e5a0fe 100644 --- a/test/pg-compat/behaviors/prepared.py +++ b/test/pg-compat/behaviors/prepared.py @@ -27,7 +27,9 @@ def run(Adapter): a = Adapter() - for i in range(50): - r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) - assert r[0][0] == i + 1 - a.close() + 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 index a64290b694..895cf459e8 100644 --- a/test/pg-compat/behaviors/session_isolation.py +++ b/test/pg-compat/behaviors/session_isolation.py @@ -36,11 +36,21 @@ def run(Adapter): a = Adapter() - a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") - assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ - a.close() + 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] - b.close() - assert val != DISTINCTIVE_TZ, "session state leaked across connections" + 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 index ad70fac3f9..7525c38ac9 100644 --- a/test/pg-compat/behaviors/transactions.py +++ b/test/pg-compat/behaviors/transactions.py @@ -50,7 +50,7 @@ def run(Adapter): finally: # Leave no state behind, whether or not the assertions above passed, # and use a table name distinct from other behaviors/tests - # (harness/oracle.py's own probe table is "oracle_w") so runs never - # collide. + # (tests/test_routing_oracle.py's own probe table is "oracle_w") so + # runs never collide. a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") a.close() diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py index 8f47c19755..dbc56ee680 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -15,7 +15,14 @@ def 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" + # 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 diff --git a/test/pg-compat/drivers/python/adapter.py b/test/pg-compat/drivers/python/adapter.py index 28ca76a572..6e75d3d951 100644 --- a/test/pg-compat/drivers/python/adapter.py +++ b/test/pg-compat/drivers/python/adapter.py @@ -60,4 +60,11 @@ def rollback(self): self.conn.autocommit = True def close(self): - self.conn.close() + # 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/diff.py b/test/pg-compat/harness/diff.py index 315b7f3a1b..132d5f742e 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -19,10 +19,13 @@ 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. 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. +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 @@ -88,16 +91,31 @@ def _run_on(target, stmts, admin, native_present): def _run(stmts, targets, admin, skip, only): native_present = native_var_present(admin) if admin is not None else False - results = {} - for t in targets: - if not t.available: - continue - if t.name in skip: - continue - if only and t.name not in only: - continue - results[t.name] = _run_on(t, stmts, admin, native_present) - return results + # 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: + results = {} + for t in targets: + if not t.available: + continue + if t.name in skip: + continue + if only and t.name not in only: + continue + 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): From 9f819e6d9a52b9841ea0519a918ce4a4136e1c26 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 13:38:15 +0000 Subject: [PATCH 37/57] =?UTF-8?q?docs(test):=20SP-3=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20driver=20matrix=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-task plan: multi-language runner image (Go builder, Temurin javac + JRE, Node 22) + uniform behavior-CLI/subprocess seam, then full behavior-contract ports for Go/pgx, Java/pgjdbc (prepareThreshold named statements), Node/ node-postgres (named prepared statements), and Prisma (ORM tier, xfail- tolerant findings), closing with docs/spec sync + CI budget evidence. Scope: behaviors-only (user-approved) — the differential engine stays psycopg-based; per-language differential runners deferred to an SP-3b stub. --- .../2026-07-08-pgsql-sp3-driver-matrix.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md 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. From 4bd0f377b54bcb935fda2361702e48f8d41a6a17 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:00:30 +0000 Subject: [PATCH 38/57] test(pg-compat): multi-language runner image + behavior CLI scaffolding (connect x3) --- test/pg-compat/Dockerfile | 37 ++++- test/pg-compat/drivers/go/behaviors.go | 125 +++++++++++++++ test/pg-compat/drivers/go/go.mod | 12 ++ test/pg-compat/drivers/go/go.sum | 28 ++++ test/pg-compat/drivers/java/Behaviors.java | 135 ++++++++++++++++ test/pg-compat/drivers/node/behaviors.js | 126 +++++++++++++++ test/pg-compat/drivers/node/package-lock.json | 149 ++++++++++++++++++ test/pg-compat/drivers/node/package.json | 10 ++ test/pg-compat/tests/_subproc.py | 21 +++ test/pg-compat/tests/test_behaviors_go.py | 9 ++ test/pg-compat/tests/test_behaviors_java.py | 9 ++ test/pg-compat/tests/test_behaviors_node.py | 9 ++ 12 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 test/pg-compat/drivers/go/behaviors.go create mode 100644 test/pg-compat/drivers/go/go.mod create mode 100644 test/pg-compat/drivers/go/go.sum create mode 100644 test/pg-compat/drivers/java/Behaviors.java create mode 100644 test/pg-compat/drivers/node/behaviors.js create mode 100644 test/pg-compat/drivers/node/package-lock.json create mode 100644 test/pg-compat/drivers/node/package.json create mode 100644 test/pg-compat/tests/_subproc.py create mode 100644 test/pg-compat/tests/test_behaviors_go.py create mode 100644 test/pg-compat/tests/test_behaviors_java.py create mode 100644 test/pg-compat/tests/test_behaviors_node.py diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 5441775525..262a2e4f5a 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -1,7 +1,42 @@ +# ---- 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. +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 && rm -rf /var/lib/apt/lists/* +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"] diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go new file mode 100644 index 0000000000..da20387aef --- /dev/null +++ b/test/pg-compat/drivers/go/behaviors.go @@ -0,0 +1,125 @@ +// behaviors-go: Go/pgx behavior CLI stub. +// +// CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-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. +// +// This is the SP3-Task-1 scaffold: only `connect` is implemented end to end +// (open -> SELECT 1 -> assert first col == 1 -> close). The other three +// behaviors are stubbed to exit 2 with "not implemented: " on stderr +// so Tasks 2-4 can fill in the function bodies below without restructuring +// dispatch(). +// +// 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 exactly. +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) + } + return nil +} + +// transactions: filled in by Task 2. Mirrors behaviors/transactions.py. +func transactions() error { + return fmt.Errorf("not implemented: transactions") +} + +// prepared: filled in by Task 2. Mirrors behaviors/prepared.py. +func prepared() error { + return fmt.Errorf("not implemented: prepared") +} + +// sessionIsolation: filled in by Task 2. Mirrors behaviors/session_isolation.py. +func sessionIsolation() error { + return fmt.Errorf("not implemented: session_isolation") +} + +// notImplemented is a sentinel used to distinguish "behavior not yet wired +// up" (exit 2, infra/usage error) from a genuine assertion failure +// (exit 1). Since Tasks 2-4 replace the stub bodies above with real +// implementations that return ordinary errors, dispatch() special-cases +// the not-implemented functions by name rather than by error type. +var notImplementedBehaviors = map[string]func() error{ + "transactions": transactions, + "prepared": prepared, + "session_isolation": sessionIsolation, +} + +func dispatch(behavior string) int { + switch behavior { + case "connect": + if err := connect(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 + case "transactions", "prepared", "session_isolation": + fn := notImplementedBehaviors[behavior] + if err := fn(); err != nil { + fmt.Fprintf(os.Stderr, "not implemented: %s\n", behavior) + return 2 + } + // Once Tasks 2-4 land, a nil error here means pass. + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown behavior: %q\n", behavior) + return 2 + } +} + +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..79ff9fcce1 --- /dev/null +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -0,0 +1,135 @@ +import java.sql.Connection; +import java.sql.DriverManager; +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-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. + * + * This is the SP3-Task-1 scaffold: only {@code connect} is implemented end + * to end (open -> SELECT 1 -> assert first col == 1 -> close). The + * other three behaviors are stubbed to exit 2 with "not implemented: + * <name>" on stderr so Task 3 can fill in the method bodies below + * without restructuring {@code dispatch()}. + * + * 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 exactly. + private static void connect() throws Exception { + try (Connection conn = openConnection(); + Statement st = conn.createStatement(); + 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"); + } + } + } + + // transactions: filled in by Task 3. Mirrors behaviors/transactions.py. + private static void transactions() throws Exception { + throw new UnsupportedOperationException("not implemented: transactions"); + } + + // prepared: filled in by Task 3. Mirrors behaviors/prepared.py. + private static void prepared() throws Exception { + throw new UnsupportedOperationException("not implemented: prepared"); + } + + // sessionIsolation: filled in by Task 3. Mirrors behaviors/session_isolation.py. + private static void sessionIsolation() throws Exception { + throw new UnsupportedOperationException("not implemented: session_isolation"); + } + + 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..43be804bba --- /dev/null +++ b/test/pg-compat/drivers/node/behaviors.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * behaviors-node: node-postgres (pg) behavior CLI stub. + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-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. + * + * This is the SP3-Task-1 scaffold: only `connect` is implemented end to + * end (open -> SELECT 1 -> assert first col == 1 -> close). The other + * three behaviors are stubbed to reject with "not implemented: " so + * Task 4 can fill in the function bodies below without restructuring + * dispatch(). + * + * 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 running `SHOW client_encoding` through this + * exact stub against the ProxySQL PG frontend, which returned `UTF8`. + * 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 exactly. +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`); + } + } finally { + await client.end(); + } +} + +// transactions: filled in by Task 4. Mirrors behaviors/transactions.py. +async function transactions() { + throw new Error('not implemented: transactions'); +} + +// prepared: filled in by Task 4. Mirrors behaviors/prepared.py. +async function prepared() { + throw new Error('not implemented: prepared'); +} + +// sessionIsolation: filled in by Task 4. Mirrors behaviors/session_isolation.py. +async function sessionIsolation() { + throw new Error('not implemented: session_isolation'); +} + +const NOT_IMPLEMENTED = new Set(['transactions', 'prepared', 'session_isolation']); + +async function dispatch(behavior) { + switch (behavior) { + case 'connect': + await connect(); + return 0; + case 'transactions': + case 'prepared': + case 'session_isolation': { + const fn = { transactions, prepared, session_isolation: sessionIsolation }[behavior]; + try { + await fn(); + } catch (err) { + if (NOT_IMPLEMENTED.has(behavior)) { + process.stderr.write(`not implemented: ${behavior}\n`); + return 2; + } + throw err; + } + // Once Task 4 lands, resolving without throwing means pass. + return 0; + } + default: + process.stderr.write(`unknown behavior: ${behavior}\n`); + return 2; + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 1) { + process.stderr.write('usage: behaviors-node \n'); + process.exit(2); + } + let code; + try { + code = await dispatch(args[0]); + } catch (err) { + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + code = 1; + } + process.exit(code); +} + +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/tests/_subproc.py b/test/pg-compat/tests/_subproc.py new file mode 100644 index 0000000000..1f7b961b6c --- /dev/null +++ b/test/pg-compat/tests/_subproc.py @@ -0,0 +1,21 @@ +"""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) 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..5eb1e9af11 --- /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"] # Tasks 2-4 extend per language + +@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..99abcdf1a2 --- /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"] # Tasks 2-4 extend per language + +@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..f4a2863716 --- /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"] # Tasks 2-4 extend per language + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_node(behavior): + run_behavior(PROGRAM, behavior) From feaab3ae7a5c8a321a08978131afebd7ac606a52 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:10:07 +0000 Subject: [PATCH 39/57] fix(pg-compat): sentinel-based not-implemented dispatch + SHOW encoding assertion (Task 1 review) --- test/pg-compat/Dockerfile | 3 + test/pg-compat/drivers/go/behaviors.go | 76 ++++++++++-------- test/pg-compat/drivers/java/Behaviors.java | 33 +++++--- test/pg-compat/drivers/node/behaviors.js | 93 ++++++++++++---------- 4 files changed, 122 insertions(+), 83 deletions(-) diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 262a2e4f5a..5dcb2e341a 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -17,6 +17,9 @@ RUN javac -cp /pgjdbc.jar Behaviors.java -d /out FROM node:22-bookworm-slim AS nodebuild WORKDIR /app COPY drivers/node/package.json drivers/node/package-lock.json* ./ +# The `npm install` fallback must never be reached in normal operation +# (package-lock.json is committed, so `npm ci` succeeds); it exists only +# for first-bootstrap before a lockfile exists. RUN npm ci --omit=dev || npm install --omit=dev COPY drivers/node/behaviors.js . diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index da20387aef..adc079826c 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -12,10 +12,10 @@ // No stdout output is required on pass. // // This is the SP3-Task-1 scaffold: only `connect` is implemented end to end -// (open -> SELECT 1 -> assert first col == 1 -> close). The other three -// behaviors are stubbed to exit 2 with "not implemented: " on stderr -// so Tasks 2-4 can fill in the function bodies below without restructuring -// dispatch(). +// (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding is +// UTF8 -> close). The other three behaviors are stubbed to exit 2 with +// "not implemented: " on stderr so Tasks 2-4 can fill in the function +// bodies below without restructuring dispatch(). // // Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default // "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is @@ -26,12 +26,21 @@ package main import ( "context" + "errors" "fmt" "os" "github.com/jackc/pgx/v5" ) +// errNotImplemented is the sentinel distinguishing "behavior not yet wired +// up" (exit 2, infra/usage error) from a genuine assertion failure +// (exit 1). Stub bodies wrap it with %w; dispatch() checks errors.Is, so +// Task 2 replaces a stub body with a real implementation returning +// ordinary errors and gets exit-1 semantics automatically -- a pure +// body-fill, no dispatch changes. +var errNotImplemented = errors.New("not implemented") + func dsn() string { host := os.Getenv("PGCOMPAT_PROXY_HOST") if host == "" { @@ -49,7 +58,10 @@ func dsn() string { // 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 exactly. +// 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()) @@ -65,55 +77,55 @@ func connect() error { 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 } // transactions: filled in by Task 2. Mirrors behaviors/transactions.py. func transactions() error { - return fmt.Errorf("not implemented: transactions") + return fmt.Errorf("%w: transactions", errNotImplemented) } // prepared: filled in by Task 2. Mirrors behaviors/prepared.py. func prepared() error { - return fmt.Errorf("not implemented: prepared") + return fmt.Errorf("%w: prepared", errNotImplemented) } // sessionIsolation: filled in by Task 2. Mirrors behaviors/session_isolation.py. func sessionIsolation() error { - return fmt.Errorf("not implemented: session_isolation") -} - -// notImplemented is a sentinel used to distinguish "behavior not yet wired -// up" (exit 2, infra/usage error) from a genuine assertion failure -// (exit 1). Since Tasks 2-4 replace the stub bodies above with real -// implementations that return ordinary errors, dispatch() special-cases -// the not-implemented functions by name rather than by error type. -var notImplementedBehaviors = map[string]func() error{ - "transactions": transactions, - "prepared": prepared, - "session_isolation": sessionIsolation, + return fmt.Errorf("%w: session_isolation", errNotImplemented) } func dispatch(behavior string) int { + var fn func() error switch behavior { case "connect": - if err := connect(); err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } - return 0 - case "transactions", "prepared", "session_isolation": - fn := notImplementedBehaviors[behavior] - if err := fn(); err != nil { - fmt.Fprintf(os.Stderr, "not implemented: %s\n", behavior) - return 2 - } - // Once Tasks 2-4 land, a nil error here means pass. - return 0 + 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) + if errors.Is(err, errNotImplemented) { + return 2 + } + return 1 + } + return 0 } func main() { diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java index 79ff9fcce1..b171148f9b 100644 --- a/test/pg-compat/drivers/java/Behaviors.java +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -20,7 +20,8 @@ * No stdout output is required on pass. * * This is the SP3-Task-1 scaffold: only {@code connect} is implemented end - * to end (open -> SELECT 1 -> assert first col == 1 -> close). The + * to end (open -> SELECT 1 -> assert first col == 1 -> assert + * client_encoding is UTF8 -> close). The * other three behaviors are stubbed to exit 2 with "not implemented: * <name>" on stderr so Task 3 can fill in the method bodies below * without restructuring {@code dispatch()}. @@ -64,17 +65,31 @@ private static Connection openConnection() throws Exception { // 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 exactly. + // 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(); - ResultSet rs = st.executeQuery("SELECT 1")) { - if (!rs.next()) { - throw new AssertionError("SELECT 1 returned no rows"); + 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"); + } } - 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)"); + } } } } diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js index 43be804bba..4674a9c7b6 100644 --- a/test/pg-compat/drivers/node/behaviors.js +++ b/test/pg-compat/drivers/node/behaviors.js @@ -12,10 +12,10 @@ * No stdout output is required on pass. * * This is the SP3-Task-1 scaffold: only `connect` is implemented end to - * end (open -> SELECT 1 -> assert first col == 1 -> close). The other - * three behaviors are stubbed to reject with "not implemented: " so - * Task 4 can fill in the function bodies below without restructuring - * dispatch(). + * end (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding + * is UTF8 -> close). The other three behaviors throw NotImplementedError + * (exit 2) so Task 4 can fill in the function bodies below without + * restructuring dispatch(). * * Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default * "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is @@ -26,14 +26,22 @@ * 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 running `SHOW client_encoding` through this - * exact stub against the ProxySQL PG frontend, which returned `UTF8`. - * No post-connect `SET client_encoding` is needed for this driver. + * 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'); +// Sentinel distinguishing "behavior not yet wired up" (exit 2, infra/usage +// error) from a genuine assertion failure (exit 1). Stub bodies throw it; +// dispatch()'s catch checks `instanceof`, so Task 4 replaces a stub body +// with a real implementation throwing ordinary Errors and gets exit-1 +// semantics automatically -- a pure body-fill, no dispatch changes. +class NotImplementedError extends Error {} + function clientConfig() { return { host: process.env.PGCOMPAT_PROXY_HOST || 'proxysql', @@ -48,7 +56,10 @@ function clientConfig() { // 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 exactly. +// 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(); @@ -58,6 +69,11 @@ async function connect() { 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(); } @@ -65,46 +81,46 @@ async function connect() { // transactions: filled in by Task 4. Mirrors behaviors/transactions.py. async function transactions() { - throw new Error('not implemented: transactions'); + throw new NotImplementedError('not implemented: transactions'); } // prepared: filled in by Task 4. Mirrors behaviors/prepared.py. async function prepared() { - throw new Error('not implemented: prepared'); + throw new NotImplementedError('not implemented: prepared'); } // sessionIsolation: filled in by Task 4. Mirrors behaviors/session_isolation.py. async function sessionIsolation() { - throw new Error('not implemented: session_isolation'); + throw new NotImplementedError('not implemented: session_isolation'); } -const NOT_IMPLEMENTED = new Set(['transactions', 'prepared', 'session_isolation']); +const BEHAVIOR_FNS = { + connect, + transactions, + prepared, + session_isolation: sessionIsolation, +}; async function dispatch(behavior) { - switch (behavior) { - case 'connect': - await connect(); - return 0; - case 'transactions': - case 'prepared': - case 'session_isolation': { - const fn = { transactions, prepared, session_isolation: sessionIsolation }[behavior]; - try { - await fn(); - } catch (err) { - if (NOT_IMPLEMENTED.has(behavior)) { - process.stderr.write(`not implemented: ${behavior}\n`); - return 2; - } - throw err; - } - // Once Task 4 lands, resolving without throwing means pass. - return 0; - } - default: - process.stderr.write(`unknown behavior: ${behavior}\n`); + // 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) { + if (err instanceof NotImplementedError) { + process.stderr.write(`${err.message}\n`); return 2; + } + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + return 1; } + return 0; } async function main() { @@ -113,14 +129,7 @@ async function main() { process.stderr.write('usage: behaviors-node \n'); process.exit(2); } - let code; - try { - code = await dispatch(args[0]); - } catch (err) { - process.stderr.write(`${err && err.stack ? err.stack : err}\n`); - code = 1; - } - process.exit(code); + process.exit(await dispatch(args[0])); } main(); From e0cdceadb8615aaadae469d1ab2bd67bcadfe6b2 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:15:35 +0000 Subject: [PATCH 40/57] test(pg-compat): Go/pgx behavior program (full contract) Implement transactions/prepared/session_isolation in the Go pgx-v5 behavior program, mirroring the frozen Python behaviors/*.py contract exactly (RW-split-safe verify-reads in their own tx, TimeZone session probe, close-A-before-B). Extend test_behaviors_go.py's BEHAVIORS to all four. Verified RED (stub -> exit 2) then GREEN x2 (idempotent, no leftover behavior_tx_t_go) against sdd-sp2; full suite 22 passed + 2 skipped, no new xfails needed. --- test/pg-compat/drivers/go/behaviors.go | 188 +++++++++++++++++++++- test/pg-compat/tests/test_behaviors_go.py | 2 +- 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index adc079826c..4f1b19365e 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -88,19 +88,195 @@ func connect() error { return nil } -// transactions: filled in by Task 2. Mirrors behaviors/transactions.py. +// 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 { - return fmt.Errorf("%w: transactions", errNotImplemented) + 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. + defer func() { + 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: filled in by Task 2. Mirrors behaviors/prepared.py. +// 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 +// runs the full extended-protocol Parse/Bind/Describe/Execute sequence for +// every query and additionally caches (by SQL text) the server-side +// prepared statement it created, reusing it (Bind/Execute only, skipping +// re-Parse) on subsequent calls with the same SQL text -- see +// https://github.com/jackc/pgx/wiki/Automatic-Prepared-Statement-Caching. +// That means every iteration of the loop below -- not just the ones past +// some warm-up threshold -- already exercises real extended-protocol +// prepared statements multiplexed by ProxySQL; the 50x loop's job is to +// prove the cached server-side statement keeps resolving correctly across +// many round trips through the proxy, not to cross a warm-up threshold (as +// psycopg3's prepare_threshold requires -- see prepared.py's docstring). +// 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 { - return fmt.Errorf("%w: prepared", errNotImplemented) + 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 } -// sessionIsolation: filled in by Task 2. Mirrors behaviors/session_isolation.py. +// 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 { - return fmt.Errorf("%w: session_isolation", errNotImplemented) + 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 { diff --git a/test/pg-compat/tests/test_behaviors_go.py b/test/pg-compat/tests/test_behaviors_go.py index 5eb1e9af11..405be17968 100644 --- a/test/pg-compat/tests/test_behaviors_go.py +++ b/test/pg-compat/tests/test_behaviors_go.py @@ -2,7 +2,7 @@ from tests._subproc import run_behavior PROGRAM = "/pg-compat/bin/behaviors-go" -BEHAVIORS = ["connect"] # Tasks 2-4 extend per language +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] @pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) def test_behavior_go(behavior): From da7c6a12082e62b6d0c64b3becd85838f9bb88da Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:23:06 +0000 Subject: [PATCH 41/57] test(pg-compat): Java/pgjdbc behavior program (full contract) Implement transactions/prepared/session_isolation in Behaviors.java, mirroring the frozen Python behavior contract. prepared() reuses one PreparedStatement across 50 iterations so pgjdbc promotes it to a server-side named statement past prepareThreshold (default 5) - the classic connection-pooler breaker - and it passes cleanly through ProxySQL's multiplexing on both verification runs. transactions() guards cleanup against masking the real error; session_isolation() closes A before opening B per the TimeZone-probe trap adaptation. Extend test_behaviors_java.py's BEHAVIORS to all four names. Full suite: 25 passed, 2 skipped, no failures. --- test/pg-compat/drivers/java/Behaviors.java | 212 +++++++++++++++++++- test/pg-compat/tests/test_behaviors_java.py | 2 +- 2 files changed, 207 insertions(+), 7 deletions(-) diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java index b171148f9b..071e397906 100644 --- a/test/pg-compat/drivers/java/Behaviors.java +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -1,5 +1,6 @@ import java.sql.Connection; import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.Properties; @@ -94,19 +95,218 @@ private static void connect() throws Exception { } } - // transactions: filled in by Task 3. Mirrors behaviors/transactions.py. + // 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 { - throw new UnsupportedOperationException("not implemented: transactions"); + 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: filled in by Task 3. Mirrors behaviors/prepared.py. + // 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 { - throw new UnsupportedOperationException("not implemented: prepared"); + 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)); + } + } + } + } } - // sessionIsolation: filled in by Task 3. Mirrors behaviors/session_isolation.py. + // 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 { - throw new UnsupportedOperationException("not implemented: session_isolation"); + 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) { diff --git a/test/pg-compat/tests/test_behaviors_java.py b/test/pg-compat/tests/test_behaviors_java.py index 99abcdf1a2..1a84adcaa5 100644 --- a/test/pg-compat/tests/test_behaviors_java.py +++ b/test/pg-compat/tests/test_behaviors_java.py @@ -2,7 +2,7 @@ from tests._subproc import run_behavior PROGRAM = "/pg-compat/bin/behaviors-java" -BEHAVIORS = ["connect"] # Tasks 2-4 extend per language +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] @pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) def test_behavior_java(behavior): From 4cfeaba43edfbaf05773497ea999133644984669 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:28:57 +0000 Subject: [PATCH 42/57] test(pg-compat): Node/node-postgres behavior program (full contract) Implement transactions/prepared/session_isolation for the Node (pg@8.13.1) behavior CLI, mirroring the frozen Python behavior contract exactly. The prepared behavior exercises pg's distinct named-prepared-statement strategy (Parse once, Bind/Execute on reuse of the same query `name`) across 50 iterations through ProxySQL's connection multiplexing -- passed cleanly, no xfail needed. transactions() deliberately compares count(*) as the string node-pg returns for int8 rather than coercing it. --- test/pg-compat/drivers/node/behaviors.js | 186 +++++++++++++++++++- test/pg-compat/tests/test_behaviors_node.py | 2 +- 2 files changed, 181 insertions(+), 7 deletions(-) diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js index 4674a9c7b6..d00bdc9189 100644 --- a/test/pg-compat/drivers/node/behaviors.js +++ b/test/pg-compat/drivers/node/behaviors.js @@ -79,19 +79,193 @@ async function connect() { } } -// transactions: filled in by Task 4. Mirrors behaviors/transactions.py. +// 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() { - throw new NotImplementedError('not implemented: 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: filled in by Task 4. Mirrors behaviors/prepared.py. +// 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() { - throw new NotImplementedError('not implemented: 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(); + } } -// sessionIsolation: filled in by Task 4. Mirrors behaviors/session_isolation.py. +// 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() { - throw new NotImplementedError('not implemented: session_isolation'); + 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; client.end() on an already-ended client + // rejects, so an idempotent-guard flag (aEnded) is used instead of + // relying on end() itself being a safe no-op repeat call. + 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 = { diff --git a/test/pg-compat/tests/test_behaviors_node.py b/test/pg-compat/tests/test_behaviors_node.py index f4a2863716..5c3a3cfd27 100644 --- a/test/pg-compat/tests/test_behaviors_node.py +++ b/test/pg-compat/tests/test_behaviors_node.py @@ -2,7 +2,7 @@ from tests._subproc import run_behavior PROGRAM = "/pg-compat/bin/behaviors-node" -BEHAVIORS = ["connect"] # Tasks 2-4 extend per language +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] @pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) def test_behavior_node(behavior): From 5295364602d70081b9cb4dbf514ac69835b30e03 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:42:07 +0000 Subject: [PATCH 43/57] test(pg-compat): Prisma behavior program (ORM tier, findings catalogued) --- test/pg-compat/.dockerignore | 8 + test/pg-compat/Dockerfile | 28 +- test/pg-compat/drivers/prisma/behaviors.mjs | 281 ++++++++++++++++++ .../drivers/prisma/package-lock.json | 120 ++++++++ test/pg-compat/drivers/prisma/package.json | 14 + test/pg-compat/drivers/prisma/schema.prisma | 45 +++ test/pg-compat/tests/test_behaviors_prisma.py | 9 + 7 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 test/pg-compat/.dockerignore create mode 100644 test/pg-compat/drivers/prisma/behaviors.mjs create mode 100644 test/pg-compat/drivers/prisma/package-lock.json create mode 100644 test/pg-compat/drivers/prisma/package.json create mode 100644 test/pg-compat/drivers/prisma/schema.prisma create mode 100644 test/pg-compat/tests/test_behaviors_prisma.py diff --git a/test/pg-compat/.dockerignore b/test/pg-compat/.dockerignore new file mode 100644 index 0000000000..55091fa614 --- /dev/null +++ b/test/pg-compat/.dockerignore @@ -0,0 +1,8 @@ +# 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 +drivers/prisma/.prisma +pg-compat-reports diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 5dcb2e341a..28e8786287 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -23,10 +23,32 @@ COPY drivers/node/package.json drivers/node/package-lock.json* ./ RUN npm ci --omit=dev || npm install --omit=dev 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. The `npm install` fallback must +# never be reached in normal operation (package-lock.json is committed). +RUN npm ci || npm install +COPY drivers/prisma/schema.prisma drivers/prisma/behaviors.mjs ./ +ENV PGCOMPAT_PRISMA_URL="postgresql://build:build@localhost:5432/build?sslmode=disable" +RUN npx 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 \ + 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 @@ -39,7 +61,11 @@ 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. +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-* ENTRYPOINT ["pytest", "-q"] diff --git a/test/pg-compat/drivers/prisma/behaviors.mjs b/test/pg-compat/drivers/prisma/behaviors.mjs new file mode 100644 index 0000000000..5228428daa --- /dev/null +++ b/test/pg-compat/drivers/prisma/behaviors.mjs @@ -0,0 +1,281 @@ +#!/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'; + +function newClient() { + return new PrismaClient(); +} + +// 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 = 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 = 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 = 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 = 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 = 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/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) From d167731461b96c8fd4901cfad3ccdc0ebfa4590e Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:51:41 +0000 Subject: [PATCH 44/57] fix(pg-compat): pin client_encoding in Prisma URL (Task 5 review) + dockerignore/Dockerfile notes --- test/pg-compat/.dockerignore | 1 - test/pg-compat/Dockerfile | 5 +++ test/pg-compat/drivers/prisma/behaviors.mjs | 35 ++++++++++++++++----- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/test/pg-compat/.dockerignore b/test/pg-compat/.dockerignore index 55091fa614..81d48988e7 100644 --- a/test/pg-compat/.dockerignore +++ b/test/pg-compat/.dockerignore @@ -4,5 +4,4 @@ # copy in via `COPY . .` would bloat the image and risk a wrong-platform # query-engine binary. Report output is host-only too. **/node_modules -drivers/prisma/.prisma pg-compat-reports diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 28e8786287..34b82022e7 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -63,6 +63,11 @@ 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 \ diff --git a/test/pg-compat/drivers/prisma/behaviors.mjs b/test/pg-compat/drivers/prisma/behaviors.mjs index 5228428daa..91256a5c0a 100644 --- a/test/pg-compat/drivers/prisma/behaviors.mjs +++ b/test/pg-compat/drivers/prisma/behaviors.mjs @@ -64,8 +64,29 @@ const TX_TABLE = 'behavior_tx_t_prisma'; // docstring). TimeZone is a tracked/forwarded/reset variable, a valid probe. const DISTINCTIVE_TZ = 'Antarctica/Troll'; -function newClient() { - return new PrismaClient(); +// 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, @@ -80,7 +101,7 @@ function firstVal(rows) { // UTF8 assertion the node port also carries (recorded SP-2 finding: the // SQL_ASCII backend reports UTF8 through ProxySQL). async function connect() { - const prisma = newClient(); + const prisma = await newClient(); try { // int4 literal -> Prisma returns a JS number for `one`; coerce with // Number() defensively and compare to 1. @@ -124,7 +145,7 @@ async function verifyCount(prisma) { // deliberately throws "force-rollback", which we catch; the committing leg // simply returns normally from the callback, so Prisma COMMITs. async function transactions() { - const prisma = newClient(); + const prisma = await newClient(); try { await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${TX_TABLE}`); await prisma.$executeRawUnsafe(`CREATE TABLE ${TX_TABLE} (id int)`); @@ -189,7 +210,7 @@ async function transactions() { // splicing -- which is exactly the prepared-statement path this port exists // to probe through the proxy. async function prepared() { - const prisma = newClient(); + const prisma = await newClient(); try { for (let i = 0; i < 50; i++) { // ::int (int4) result -> Prisma returns a JS number for `sum` @@ -216,7 +237,7 @@ async function prepared() { // connection within A -- otherwise Prisma's internal pool could scatter them // and produce a false negative unrelated to ProxySQL. async function sessionIsolation() { - const a = newClient(); + const a = await newClient(); let b = null; let aClosed = false; try { @@ -229,7 +250,7 @@ async function sessionIsolation() { await a.$disconnect(); aClosed = true; - b = newClient(); + 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)}`); From 45b34d0b8da93f2a85b8f98ffc705c17ad4a71f8 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 14:58:01 +0000 Subject: [PATCH 45/57] docs(pg-compat): SP-3 driver matrix docs + spec sync (+ CI budget evidence) Document the completed SP-3 driver-matrix expansion (Go/pgx, Java/pgjdbc, Node/pg, Node/Prisma alongside Python/psycopg3): add a driver-matrix table, run/CLI-contract/add-a-language guidance and the Prisma raw-vs-ORM caveat to test/pg-compat/README.md, and replace the spec's SP-3 roadmap stub with the as-built summary plus a new SP-3b stub for per-language differential runners. Also soften a carried-over node-postgres comment that incorrectly claimed client.end() rejects on an already-ended client (verified false for pg@8.13.1). CI budget evidence (49.5s clean / 0.8s cached image build, 654MB image, sub-5s full suite) shows the reusable's 120-minute timeout needs no change -- the ProxySQL debug build dominates job wall time. --- ...026-07-08-pgsql-protocol-testing-design.md | 42 ++++++++- test/pg-compat/README.md | 94 +++++++++++++++++++ test/pg-compat/drivers/node/behaviors.js | 8 +- 3 files changed, 138 insertions(+), 6 deletions(-) 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 index 8dbd422ed8..ba7fd9b98b 100644 --- a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -220,9 +220,45 @@ Adapted from pgcat. `harness/oracle.py`: --- -## 6. Roadmap — SP-3 and SP-4 (not in this spec) - -- **SP-3 — Driver matrix expansion.** Add adapters under `test/pg-compat/drivers/`: **Java** (pgjdbc, +HikariCP), **Go** (pgx native), **Node.js** (node-postgres, postgres.js, Prisma). Each runs the existing `behaviors/` set + differential cases. CI fans out one matrix job per language from the cached binary. Prisma/pgjdbc are the highest-value targets (aggressive server-side prepared statements historically break poolers). +## 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. --- diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index f2a0b7aaf2..334be0cf57 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -49,6 +49,100 @@ WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash \ # 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` — full extended-protocol Parse/Bind every call, with the server-side statement cached and reused by SQL text | 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` diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js index d00bdc9189..1b196b8994 100644 --- a/test/pg-compat/drivers/node/behaviors.js +++ b/test/pg-compat/drivers/node/behaviors.js @@ -235,9 +235,11 @@ async function sessionIsolation() { } // 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; client.end() on an already-ended client - // rejects, so an idempotent-guard flag (aEnded) is used instead of - // relying on end() itself being a safe no-op repeat call. + // 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; From acd6f0b60a52d270bd8bb57f3de3d100f37ec746 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 15:05:15 +0000 Subject: [PATCH 46/57] docs(pg-compat): correct pgx Parse-once semantics in table + source comment; retire stale spec matrix line Under pgx v5's default QueryExecModeCacheStatement the statement cache is consulted before anything is sent: Parse goes out only on a cache miss (first occurrence of a SQL text) and every subsequent identical-SQL call is Bind/Execute-only via execPrepared (verified against pgx v5.7.5 conn.go). The README driver-matrix cell and the behaviors.go prepared() doc comment previously claimed a full Parse/Bind on every call; both now state Parse-once/Bind-many. Also update spec section 4.7's CI shape line, which still promised SP-3 per-language matrix jobs, to match the as-built single multi-language runner image described in section 6. --- ...026-07-08-pgsql-protocol-testing-design.md | 2 +- test/pg-compat/README.md | 2 +- test/pg-compat/drivers/go/behaviors.go | 25 +++++++++++-------- 3 files changed, 16 insertions(+), 13 deletions(-) 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 index ba7fd9b98b..cb6a75366c 100644 --- a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -205,7 +205,7 @@ Adapted from pgcat. `harness/oracle.py`: - **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 will fan out per-language matrix jobs from the same cached binary. +- **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. --- diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index 334be0cf57..861b243436 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -63,7 +63,7 @@ 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` — full extended-protocol Parse/Bind every call, with the server-side statement cached and reused by SQL text | DSN param `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 | diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index 4f1b19365e..d4e5bdc172 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -187,17 +187,20 @@ func verifyCount(ctx context.Context, conn *pgx.Conn) (int, error) { // 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 -// runs the full extended-protocol Parse/Bind/Describe/Execute sequence for -// every query and additionally caches (by SQL text) the server-side -// prepared statement it created, reusing it (Bind/Execute only, skipping -// re-Parse) on subsequent calls with the same SQL text -- see -// https://github.com/jackc/pgx/wiki/Automatic-Prepared-Statement-Caching. -// That means every iteration of the loop below -- not just the ones past -// some warm-up threshold -- already exercises real extended-protocol -// prepared statements multiplexed by ProxySQL; the 50x loop's job is to -// prove the cached server-side statement keeps resolving correctly across -// many round trips through the proxy, not to cross a warm-up threshold (as -// psycopg3's prepare_threshold requires -- see prepared.py's docstring). +// 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 { From 4db6cb430cbaa2982eaeffc1177aa58a237c7c2f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 15:10:46 +0000 Subject: [PATCH 47/57] docs(pg-compat): fix stale plan path in three driver headers (final review) --- test/pg-compat/drivers/go/behaviors.go | 2 +- test/pg-compat/drivers/java/Behaviors.java | 2 +- test/pg-compat/drivers/node/behaviors.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index d4e5bdc172..9b342a42f5 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -1,6 +1,6 @@ // behaviors-go: Go/pgx behavior CLI stub. // -// CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-driver-matrix.md, +// 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. // diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java index 071e397906..9d71a6ae4c 100644 --- a/test/pg-compat/drivers/java/Behaviors.java +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -8,7 +8,7 @@ /** * behaviors-java: Java/pgjdbc behavior CLI stub. * - * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-driver-matrix.md, + * 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. diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js index 1b196b8994..f23e5f3b16 100644 --- a/test/pg-compat/drivers/node/behaviors.js +++ b/test/pg-compat/drivers/node/behaviors.js @@ -2,7 +2,7 @@ /** * behaviors-node: node-postgres (pg) behavior CLI stub. * - * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-driver-matrix.md, + * 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 From dde05c6c12da73c4d972a80eec706074ccb54f13 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 16:44:36 +0000 Subject: [PATCH 48/57] fix(tap): link libscram into all pg_lite_client consumers (CI build fix) The three new SCRAM-auth test targets (pgsql-auth_method_matrix-t, pgsql-datatype_matrix-t, pgsql-server_side_cursors-t) link -lscram -lusual -Wl,--allow-multiple-definition, but six pre-existing targets that also compile pg_lite_client.cpp did not: pgsql-extended_query_protocol_test-t pgsql-reg_test_5273_bind_parameter_format-t pgsql-reg_test_5300_threshold_resultset_deadlock-t pgsql-reg_test_5866_result_format-t test_ffto_pgsql_pipeline-t test_ffto_pgsql_stmt_portal-t pg_lite_client.cpp now references libscram symbols (client-side SCRAM auth added on this stack), so linking these six without libscram/libusual fails with "undefined reference to scram_state_init" et al. under CI's full `make build_tap_tests_debug`, which builds every target in this Makefile rather than a hand-picked subset. That link failure aborts the build step, so the CI-builds job never produces the test-binaries handoff artifact, and every downstream job that consumes it (~50 TAP test jobs) fails to even start. Fix: append the same -lscram -lusual -Wl,--allow-multiple-definition flags used by the three new targets to these six recipes. --- test/tap/tests/Makefile | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile index 8edd788d1d..90c40a1cfb 100644 --- a/test/tap/tests/Makefile +++ b/test/tap/tests/Makefile @@ -367,23 +367,24 @@ prepare_statement_err3024_async-t: prepare_statement_err3024-t.cpp $(TAP_LDIR)/l test_wexecvp_syscall_failures-t: test_wexecvp_syscall_failures-t.cpp $(TAP_LDIR)/libtap.so $(CXX) $< $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) -Wl,--wrap=pipe,--wrap=fcntl,--wrap=read,--wrap=poll $(STATIC_LIBS) -o $@ +# -lscram/-lusual: pg_lite_client.cpp uses libscram client SCRAM; --allow-multiple-definition resolves duplicate symbols between libscram/libusual and other vendored static libs (test binaries only). pgsql-extended_query_protocol_test-t: pgsql-extended_query_protocol_test-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -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.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -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.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -o $@ pgsql-reg_test_5866_result_format-t: pgsql-reg_test_5866_result_format-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -o $@ test_ffto_pgsql_pipeline-t: test_ffto_pgsql_pipeline-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -o $@ test_ffto_pgsql_stmt_portal-t: test_ffto_pgsql_stmt_portal-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so - $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@ + $(CXX) $< pg_lite_client.cpp $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -lscram -lusual -Wl,--allow-multiple-definition -o $@ # -lscram/-lusual: pg_lite_client.cpp uses libscram client SCRAM; --allow-multiple-definition resolves duplicate symbols between libscram/libusual and other vendored static libs (test binaries only). pgsql-auth_method_matrix-t: pgsql-auth_method_matrix-t.cpp pg_lite_client.cpp $(TAP_LDIR)/libtap.so From f26787b0d1dd4b303efc57d65fcc6c116cb5518d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 16:48:40 +0000 Subject: [PATCH 49/57] =?UTF-8?q?test(pg-compat):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20Go=20cleanup=20parity,=20connect=20encoding=20asser?= =?UTF-8?q?t,=20xfail=20entry=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/pg-compat/behaviors/connect.py | 5 +++++ test/pg-compat/conftest.py | 16 ++++++++++++++++ test/pg-compat/drivers/go/behaviors.go | 7 ++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py index 4fb36709ef..4109205af4 100644 --- a/test/pg-compat/behaviors/connect.py +++ b/test/pg-compat/behaviors/connect.py @@ -9,5 +9,10 @@ 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/conftest.py b/test/pg-compat/conftest.py index dbc56ee680..4e3701e800 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -1,4 +1,5 @@ import os +import warnings import psycopg import pytest @@ -48,11 +49,26 @@ def proxy_conn(): 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/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index 9b342a42f5..c92b2a9598 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -110,8 +110,13 @@ func transactions() error { defer conn.Close(ctx) // Cleanup runs on success AND on failure (defer), leaving no state - // behind, same as the Python behavior's try/finally. + // 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)) }() From 0be6b4b5e710e610d97d1976b7796eeb779a06e1 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Fri, 10 Jul 2026 01:21:50 +0000 Subject: [PATCH 50/57] fix(pg-compat): compile Java behaviors with --release 17 (runtime compatibility, #5910 review) Gemini review claimed the javabuild stage (eclipse-temurin:21-jdk) compiles Behaviors.java at the default JDK 21 class-file version, while the final image's `default-jre-headless` on a Debian bookworm base is Java 17 -- a mismatch that would raise UnsupportedClassVersionError at runtime. Empirically verified before trusting the claim, since our in-image CI runs were already passing 4/4: - `docker run --rm --entrypoint java proxysql-pg-compat:latest -version` reports OpenJDK 21.0.11 (Debian build), not 17. - `docker run ... --entrypoint cat /etc/os-release` shows the final stage's `python:3.11-slim` base now resolves to Debian 13 (trixie), not bookworm (Debian 12) as the review assumed. Trixie's `default-jre-headless` is OpenJDK 21, which matches the JDK-21-compiled classes exactly -- so the 4/4 pass was real, not a fluke. - `behaviors-java connect` against the live sdd-sp2 backend and the full `tests/test_behaviors_java.py` suite both passed (4 passed) prior to this change too. Verdict: false positive for the CURRENT resolved base image -- the JDK version match is real, just incidental to `python:3.11-slim` having moved on to trixie. The underlying risk the review is pointing at is still legitimate: a floating base tag means the JRE version is not pinned, and a future re-resolution to an older Debian (or a deliberate downgrade) would reintroduce the exact mismatch described. Compiling with `javac --release 17` costs nothing and removes the dependency on which Debian release the base tag happens to resolve to. Rebuilt the image and re-ran tests/test_behaviors_java.py -v: 4 passed. --- test/pg-compat/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 34b82022e7..9840519b4d 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /src 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 +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 From f66297e48bdd289bf5d97834c1ee8bba5d4c4948 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 05:42:59 +0000 Subject: [PATCH 51/57] fix(test/pgsql): harden pg_lite_client auth parsing and the PG TAP assertions Addresses CodeRabbit findings on #5894. pg_lite_client.cpp: the 4-byte authentication sub-type was re-read after readMessage() refilled the buffer without re-validating its length, so a truncated AuthenticationOk read past the end of the vector; the read also used an unaligned reinterpret_cast. Route every read through a single bounds-checked readAuthType(). The ErrorResponse path in the same function hand-walked a raw char* with no bounds check at all -- replaced with the already-bounds-checked extractErrorMessage(). pgsql-datatype_matrix-t: the row labelled "timestamptz" applied AT TIME ZONE 'UTC', which yields timestamp WITHOUT time zone (OID 1114), so timestamptz (1184) was never covered. Use a real timestamptz and pin the session to UTC so its text rendering stays deterministic. The binary half asserted only the OID, so a silent text fallback or a corrupted payload still passed; every case now carries the exact bytes PostgreSQL's *_send() emits and the payload is compared byte-for-byte. pgsql-auth_method_matrix-t: only the first pgsql-authentication_method switch checked its result. A failed SET/LOAD left the previous floor in effect, so later assertions silently tested the wrong method -- a "scram floor" check could actually exercise md5, and a wrong-password rejection could pass for the wrong reason. Every switch is now a checked precondition. pgsql-server_side_cursors-t: BEGIN/DECLARE/MOVE/CLOSE/COMMIT were fire-and-forget PQexec() calls that leaked their PGresult and hid failures; a failed DECLARE surfaced only as "FETCH 3 returns 3 rows" failing. Check and clear each result, and abort when a precondition fails. --- test/tap/tests/pg_lite_client.cpp | 33 +++--- test/tap/tests/pgsql-auth_method_matrix-t.cpp | 33 ++++-- test/tap/tests/pgsql-datatype_matrix-t.cpp | 102 ++++++++++++------ .../tap/tests/pgsql-server_side_cursors-t.cpp | 47 ++++++-- 4 files changed, 152 insertions(+), 63 deletions(-) diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index c901552ed4..926b06a560 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -300,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; @@ -308,8 +319,7 @@ 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; @@ -321,7 +331,7 @@ void PgConnection::handleAuthentication(const std::string& password) { 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; } } @@ -334,7 +344,7 @@ void PgConnection::handleAuthentication(const std::string& password) { 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; } } @@ -347,17 +357,14 @@ void PgConnection::handleAuthentication(const std::string& password) { } } 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"); } } } diff --git a/test/tap/tests/pgsql-auth_method_matrix-t.cpp b/test/tap/tests/pgsql-auth_method_matrix-t.cpp index 5cf4522d5e..7583f460eb 100644 --- a/test/tap/tests/pgsql-auth_method_matrix-t.cpp +++ b/test/tap/tests/pgsql-auth_method_matrix-t.cpp @@ -31,6 +31,17 @@ static bool set_frontend_auth_method(MYSQL* admin, int method) { 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. @@ -62,22 +73,21 @@ int main(int argc, char** argv) { if (!admin) BAIL_OUT("cannot reach admin"); // --- Cleartext floor (method = 1) -> expect challenge type 3 on the wire --- - if (!set_frontend_auth_method(admin, 1)) // affects NEW frontend connections - BAIL_OUT("could not configure cleartext auth floor (admin SET/LOAD failed)"); + 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 --- - set_frontend_auth_method(admin, 2); + 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 --- - set_frontend_auth_method(admin, 3); + 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"); @@ -85,15 +95,20 @@ int main(int argc, char** argv) { // --- Wrong-password failure paths, one per floor (challenge type irrelevant) --- int ignore = 0; - set_frontend_auth_method(admin, 1); + require_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); + require_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); + require_auth_method(admin, 3); ok(!try_frontend_login(cl.pgsql_username, "wrong-pw", ignore), "scram floor: wrong password rejected"); - // restore default before exit - set_frontend_auth_method(admin, 3); + // 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 index 5f714fb373..966c530a39 100644 --- a/test/tap/tests/pgsql-datatype_matrix-t.cpp +++ b/test/tap/tests/pgsql-datatype_matrix-t.cpp @@ -13,26 +13,67 @@ struct Case { 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 }, - { "int4", "SELECT 2147483647::int4", "2147483647", 23 }, - { "int8", "SELECT 9223372036854775807::int8", "9223372036854775807", 20 }, - { "float8", "SELECT 1.5::float8", "1.5", 701 }, - { "numeric", "SELECT 12345.6789::numeric", "12345.6789", 1700 }, - { "text_utf8", "SELECT 'héllo'::text", "héllo", 25 }, - { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17 }, + { "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 }, - { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz AT TIME ZONE 'UTC'", - "2020-01-01 00:00:00", 1114 }, - { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802 }, - { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007 }, - { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, + "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) @@ -74,30 +115,18 @@ int main(int argc, char** argv) { 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 a non-null value must be returned. For the fixed-width int4 case - // we additionally decode the payload end-to-end to prove the bytes are real - // binary (4 bytes, big-endian) rather than a text string mislabeled as binary. + // 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; - std::string extra; - if (std::string(c.label) == "int4") { - bool decode_ok = b.raw_bytes.size() == 4; - int64_t decoded = 0; - if (decode_ok) { - uint32_t u = (uint32_t(b.raw_bytes[0]) << 24) | (uint32_t(b.raw_bytes[1]) << 16) - | (uint32_t(b.raw_bytes[2]) << 8) | uint32_t(b.raw_bytes[3]); - decoded = (int32_t)u; - decode_ok = (decoded == 2147483647); - } - binary_ok = binary_ok && decode_ok; - extra = " [int4 binary payload " + std::to_string(b.raw_bytes.size()) - + " bytes -> " + std::to_string(decoded) + "]"; - } + && 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, %zu payload bytes%s", - c.label, (int)b.col_format, b.oid, b.raw_bytes.size(), extra.c_str()); + "%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(); } @@ -106,6 +135,13 @@ 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 diff --git a/test/tap/tests/pgsql-server_side_cursors-t.cpp b/test/tap/tests/pgsql-server_side_cursors-t.cpp index e65badee17..aad3534005 100644 --- a/test/tap/tests/pgsql-server_side_cursors-t.cpp +++ b/test/tap/tests/pgsql-server_side_cursors-t.cpp @@ -9,6 +9,23 @@ 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 @@ -66,18 +83,32 @@ int main(int argc, char** argv) { 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"); + // 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(PQntuples(r) == 3, "FETCH 3 returns 3 rows"); - PQclear(r); - r = PQexec(c.get(), "MOVE 2 cur"); // skip 2 + 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(PQntuples(r) == 5, "MOVE 2 then FETCH returns remaining 5 rows"); + 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); - PQexec(c.get(), "CLOSE cur"); - PQexec(c.get(), "COMMIT"); + + 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 From 97128b1b5c843d27eef231b3cce9389a146d9d0d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 05:43:10 +0000 Subject: [PATCH 52/57] fix(test/pg-compat): glob target filters, transaction cleanup, subprocess timeout, pytest CVE Addresses CodeRabbit findings on #5903 and #5910. diff.py: only-targets/skip-targets are documented as glob patterns, but were matched with exact set membership, so the documented "only-targets: proxy_native_*" matched no target and silently skipped the entire case -- a vacuous pass. Match with fnmatchcase; a name with no metacharacter still compares exactly. Pinned by a new infra-free unit test. transactions.py: if a statement raised between begin() and commit(), the finally block ran DROP TABLE on a session left in PostgreSQL's aborted- transaction state, so the drop failed, the table leaked, and the cleanup error masked the original failure. Roll back (best-effort) first, and close the connection even if the drop fails. _subproc.py: an uncaught subprocess.TimeoutExpired turned a hung driver into a pytest ERROR during teardown and discarded the partial output that identifies where it hung. Report it as a failure with the captured streams, decoding defensively since TimeoutExpired yields bytes even under text=True. The timeout is now overridable via PGCOMPAT_BEHAVIOR_TIMEOUT. requirements.txt: GHSA-6w46-j5rx-g56g (insecure tmpdir handling) affects every pytest before 9.0.3, so the "8.*" pin could not pick up the fix. The suite uses only stable APIs and the 3.11 base image satisfies pytest 9's Python >=3.10 requirement. --- test/pg-compat/behaviors/transactions.py | 16 ++++++++++-- test/pg-compat/harness/diff.py | 20 ++++++++++++--- test/pg-compat/requirements.txt | 6 ++++- test/pg-compat/tests/_subproc.py | 25 ++++++++++++++++--- .../tests/test_differential_selfcheck.py | 18 +++++++++++++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/test/pg-compat/behaviors/transactions.py b/test/pg-compat/behaviors/transactions.py index 7525c38ac9..17608122db 100644 --- a/test/pg-compat/behaviors/transactions.py +++ b/test/pg-compat/behaviors/transactions.py @@ -52,5 +52,17 @@ def run(Adapter): # 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. - a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") - a.close() + # + # 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/harness/diff.py b/test/pg-compat/harness/diff.py index 132d5f742e..a8e0ad13e7 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -13,7 +13,11 @@ Case metadata (parsed from ``-- key: value`` comment lines): ``-- skip-targets: name1 name2`` targets to exclude - ``-- only-targets: name1 name2`` restrict to exactly these targets + ``-- 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. @@ -28,6 +32,7 @@ ``results`` are checked. """ import re +from fnmatch import fnmatchcase import psycopg @@ -44,6 +49,15 @@ def _parse_meta(sql): return skip, only +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 "--") @@ -107,9 +121,9 @@ def _run(stmts, targets, admin, skip, only): for t in targets: if not t.available: continue - if t.name in skip: + if _matches_any(t.name, skip): continue - if only and t.name not in only: + if only and not _matches_any(t.name, only): continue results[t.name] = _run_on(t, stmts, admin, native_present) return results diff --git a/test/pg-compat/requirements.txt b/test/pg-compat/requirements.txt index 46c18ce4d1..a0c9424da0 100644 --- a/test/pg-compat/requirements.txt +++ b/test/pg-compat/requirements.txt @@ -1,4 +1,8 @@ psycopg[binary]==3.2.* asyncpg==0.30.* -pytest==8.* +# >=9.0.3: GHSA-6w46-j5rx-g56g (insecure tmpdir handling) affects every release +# before 9.0.3, so no 8.x pin can pick up the fix. The suite uses only stable +# APIs (fixtures, parametrize, skip/fail, pytest_collection_modifyitems), and +# pytest 9 requires Python >=3.10 which the 3.11 base image satisfies. +pytest>=9.0.3,<10 tomli==2.* diff --git a/test/pg-compat/tests/_subproc.py b/test/pg-compat/tests/_subproc.py index 1f7b961b6c..16096332f1 100644 --- a/test/pg-compat/tests/_subproc.py +++ b/test/pg-compat/tests/_subproc.py @@ -9,10 +9,27 @@ 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(), - ) + 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}" diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py index dc70c9530f..3d2b2d8804 100644 --- a/test/pg-compat/tests/test_differential_selfcheck.py +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -71,6 +71,24 @@ def test_engine_detects_divergence(admin): 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_file_pipeline_executes_real_statements(admin): """Guard against vacuous passes on the FILE-based path. From 38a13fb387736304be3930a6e0f40587dee3cd55 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 05:43:22 +0000 Subject: [PATCH 53/57] fix(ci,infra): pin pgjdbc by digest, bound the admin wait, drop persisted creds Addresses CodeRabbit and Gemini findings on #5903 and #5910. ci-pg-compat.yml: the job builds and tests but never pushes, yet checkout persisted its write-all token into .git/config where every later step -- including the third-party driver images this suite builds and runs -- could read it. Set persist-credentials: false. docker-proxy-post.bash: the ProxySQL admin wait loop had no timeout. docker-compose-init.bash re-execs itself under `timeout`, but ensure-infras.bash's reconfigure path calls this script directly, where the loop would hang the run instead of failing it; it now gives up after PROXY_WAIT_SECONDS (default 120) and dumps container logs. The SQL template was expanded with eval-echo, running the whole file through the shell, and was read via a cwd-relative path; use envsubst with an explicit variable list and resolve the template relative to SCRIPT_DIR. Verified to produce byte-identical output to the previous eval for the current template. Dockerfile: verify the downloaded pgjdbc jar against a pinned SHA-256, so a substituted artifact fails the build. The recorded digest is that of the jar whose SHA-1 matches Maven Central's published digest (Central does not publish .sha256 for this artifact). go/java/node behaviors: the scaffold comments claimed only `connect` was implemented and the other three exited 2 as "not implemented", contradicting code where all four behaviors are complete. The Go errNotImplemented sentinel and the Node NotImplementedError class were never raised, so their dispatch branches were dead; removed along with the now-unused errors import. --- .../gh-actions-reusable/ci-pg-compat.yml | 6 +++++ .../bin/docker-proxy-post.bash | 26 ++++++++++++++++--- test/pg-compat/Dockerfile | 8 +++++- test/pg-compat/drivers/go/behaviors.go | 20 +++----------- test/pg-compat/drivers/java/Behaviors.java | 9 +++---- test/pg-compat/drivers/node/behaviors.js | 19 +++----------- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml index 0b0c8a4e49..746d9bf660 100644 --- a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml +++ b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml @@ -44,6 +44,12 @@ jobs: 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 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 index 29bb8578c1..0bc8cef04e 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -21,15 +21,33 @@ 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. +# 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. -SQL_TEMPLATE=$(cat ./conf/proxysql/infra-config.sql) -SQL_CONTENT=$(eval "echo \"${SQL_TEMPLATE}\"") +# 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 diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 9840519b4d..17943471d6 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -8,8 +8,14 @@ RUN CGO_ENABLED=0 go build -o /out/behaviors-go . 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 -RUN curl -fsSLo /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" +ARG PGJDBC_SHA256=188976721ead8e8627eb6d8389d500dccc0c9bebd885268a3047180274a6031e +RUN curl -fsSLo /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 diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go index c92b2a9598..366e63b033 100644 --- a/test/pg-compat/drivers/go/behaviors.go +++ b/test/pg-compat/drivers/go/behaviors.go @@ -11,11 +11,9 @@ // // No stdout output is required on pass. // -// This is the SP3-Task-1 scaffold: only `connect` is implemented end to end -// (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding is -// UTF8 -> close). The other three behaviors are stubbed to exit 2 with -// "not implemented: " on stderr so Tasks 2-4 can fill in the function -// bodies below without restructuring dispatch(). +// 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 @@ -26,21 +24,12 @@ package main import ( "context" - "errors" "fmt" "os" "github.com/jackc/pgx/v5" ) -// errNotImplemented is the sentinel distinguishing "behavior not yet wired -// up" (exit 2, infra/usage error) from a genuine assertion failure -// (exit 1). Stub bodies wrap it with %w; dispatch() checks errors.Is, so -// Task 2 replaces a stub body with a real implementation returning -// ordinary errors and gets exit-1 semantics automatically -- a pure -// body-fill, no dispatch changes. -var errNotImplemented = errors.New("not implemented") - func dsn() string { host := os.Getenv("PGCOMPAT_PROXY_HOST") if host == "" { @@ -304,9 +293,6 @@ func dispatch(behavior string) int { } if err := fn(); err != nil { fmt.Fprintln(os.Stderr, err) - if errors.Is(err, errNotImplemented) { - return 2 - } return 1 } return 0 diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java index 9d71a6ae4c..332d43f4e2 100644 --- a/test/pg-compat/drivers/java/Behaviors.java +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -20,12 +20,9 @@ * * No stdout output is required on pass. * - * This is the SP3-Task-1 scaffold: only {@code connect} is implemented end - * to end (open -> SELECT 1 -> assert first col == 1 -> assert - * client_encoding is UTF8 -> close). The - * other three behaviors are stubbed to exit 2 with "not implemented: - * <name>" on stderr so Task 3 can fill in the method bodies below - * without restructuring {@code dispatch()}. + * 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 diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js index f23e5f3b16..da909ed3b4 100644 --- a/test/pg-compat/drivers/node/behaviors.js +++ b/test/pg-compat/drivers/node/behaviors.js @@ -11,11 +11,9 @@ * behavior, missing/invalid env, etc.) * No stdout output is required on pass. * - * This is the SP3-Task-1 scaffold: only `connect` is implemented end to - * end (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding - * is UTF8 -> close). The other three behaviors throw NotImplementedError - * (exit 2) so Task 4 can fill in the function bodies below without - * restructuring dispatch(). + * 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 @@ -35,13 +33,6 @@ const { Client } = require('pg'); -// Sentinel distinguishing "behavior not yet wired up" (exit 2, infra/usage -// error) from a genuine assertion failure (exit 1). Stub bodies throw it; -// dispatch()'s catch checks `instanceof`, so Task 4 replaces a stub body -// with a real implementation throwing ordinary Errors and gets exit-1 -// semantics automatically -- a pure body-fill, no dispatch changes. -class NotImplementedError extends Error {} - function clientConfig() { return { host: process.env.PGCOMPAT_PROXY_HOST || 'proxysql', @@ -289,10 +280,6 @@ async function dispatch(behavior) { try { await fn(); } catch (err) { - if (err instanceof NotImplementedError) { - process.stderr.write(`${err.message}\n`); - return 2; - } process.stderr.write(`${err && err.stack ? err.stack : err}\n`); return 1; } From 310e9c2c91b2036493add25a5561c859993e4c1f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 05:50:41 +0000 Subject: [PATCH 54/57] docs(plans): sync SP-1/SP-2 plans with the implemented contracts Addresses CodeRabbit findings filed against the plan documents on #5894 and #5903. Each of these described behaviour the shipped code either implements differently or had to correct. SP-1 plan: the sketched timestamptz row used AT TIME ZONE 'UTC' (OID 1114, i.e. timestamp WITHOUT time zone) and the binary assertion checked only the OID. Both are corrected to match the implemented test, which uses a real timestamptz and compares the DataRow payload byte-for-byte. SP-2 plan: the env contract named a single PGCOMPAT_BACKEND_PORT, which cannot address the dbdeployer layout where all three nodes share one host and differ only by port. The implementation deliberately publishes a _HOST/_PORT pair per node (harness/targets.py records this explicitly); the plan and its sample snippets now say the same. The diff.py sketch also gains the fnmatch-based target filtering that the documented "only-targets: proxy_native_*" example requires. --- .../2026-07-08-pgsql-sp1-tap-coverage-gaps.md | 40 +++++++++++------- ...026-07-08-pgsql-sp2-polyglot-foundation.md | 42 ++++++++++++------- 2 files changed, 54 insertions(+), 28 deletions(-) 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 index b29a1f7cd5..23b586044d 100644 --- 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 @@ -557,32 +557,44 @@ static const std::vector cases = { { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17 }, { "uuid", "SELECT '00000000-0000-0000-0000-000000000001'::uuid", "00000000-0000-0000-0000-000000000001", 2950 }, - { "timestamptz", "SELECT '2020-01-01 00:00:00+00'::timestamptz AT TIME ZONE 'UTC'", - "2020-01-01 00:00:00", 1114 }, + // 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 }, { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802 }, { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007 }, { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, }; -// Runs one case through pg_lite_client at the given result format (0=text,1=binary). -// Returns true if the RowDescription OID matches; in text format also checks the value. -static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, int32_t& observed_oid); +// 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). + // 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) { - std::string v_text, v_bin; int32_t oid_text = 0, oid_bin = 0; - bool ok_text = run_case(c, 0, v_text, oid_text); - ok(ok_text && oid_text == c.expected_oid && v_text == c.expected_text, - "%s text: oid=%d value='%s'", c.label, oid_text, v_text.c_str()); - - bool ok_bin = run_case(c, 1, v_bin, oid_bin); - ok(ok_bin && oid_bin == c.expected_oid, - "%s binary: oid=%d (format code honored)", c.label, oid_bin); + 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(); } 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 index 98345d2927..412b47a86f 100644 --- a/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md @@ -114,7 +114,7 @@ Build the backend infra chosen in Task 1. Both variants must end at the same con - 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` — `PGCOMPAT_PRIMARY_HOST`, `PGCOMPAT_REPLICA1_HOST`, `PGCOMPAT_REPLICA2_HOST`, `PGCOMPAT_BACKEND_PORT`. (dbdeployer single-container → one host, three ports; native → three hosts, port 5432.) +- 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** @@ -162,10 +162,15 @@ Create `test/tap/groups/pg-compat/env.sh` (group dir) exporting the endpoints an ```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_BACKEND_PORT="5432" +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** @@ -369,8 +374,10 @@ 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_REPLICA1_HOST -e PGCOMPAT_REPLICA2_HOST \ - -e PGCOMPAT_BACKEND_PORT -e PGCOMPAT_TOXI_ADMIN -e PGCOMPAT_TOXI_PRIMARY \ + -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" \ @@ -519,7 +526,7 @@ 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_BACKEND_PORT"]) +def _direct(): return _dsn(os.environ["PGCOMPAT_PRIMARY_HOST"], os.environ["PGCOMPAT_PRIMARY_PORT"]) @dataclass class Target: @@ -545,6 +552,8 @@ def all_targets(admin): `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): @@ -572,8 +581,11 @@ def run_case(case_file, targets, admin=None): stmts, skip, only = _parse(case_file) results = {} for t in targets: - if t.name in skip: continue - if only and t.name not in only: continue + # 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 @@ -677,20 +689,22 @@ def test_select_lands_on_a_reader(proxy_conn): import os import psycopg -def _c(host): +def _c(host, port): return psycopg.connect( - f"host={host} port={os.environ['PGCOMPAT_BACKEND_PORT']} user=testuser password=testuser dbname=testuser sslmode=disable", + 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", - "replica1": "PGCOMPAT_REPLICA1_HOST", - "replica2": "PGCOMPAT_REPLICA2_HOST", + "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 env in _BACKENDS.values(): - with _c(os.environ[env]) as conn, conn.cursor() as cur: + 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): From 8ef2264aadd3c6839cc21b260b846948cb7f9c8b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 06:28:51 +0000 Subject: [PATCH 55/57] fix(pg-compat): resolve SonarCloud security findings on new code The gate failed on "D Security Rating on New Code" (17 vulnerabilities). Sonar analyses only NEW code, which is why patterns shared with existing files surface here for the first time. Dockerfile (12 findings), all real supply-chain hardening: - curl now pins --proto/--proto-redir '=https', so the -L redirect chain cannot be downgraded to plaintext HTTP (S6506). - npm installs drop the `|| npm install` fallback and add --ignore-scripts (S6505, S8543). Both lockfiles are committed, so `npm ci` always succeeds; the fallback could only ever fire when the lockfile was missing -- that is, it resolved unlocked versions exactly when reproducibility mattered most. --ignore-scripts is safe for Prisma because the explicit `prisma generate` below does the work @prisma/client's postinstall would have done. - `npx prisma generate` becomes `npx --no-install ...`: bare npx silently fetches an unpinned package from the registry when the binary is missing locally, defeating the lockfile (S6505, S8543). - pip installs with --only-binary :all: so no package runs a setup.py at install time (S8541); requirements.txt moves from ranges to exact pins (S8544). --require-hashes is deliberately not used -- a full transitive hash lock is a larger change than this PR should carry. - `COPY . .` is replaced by explicit paths (S6470). It had been sweeping the whole build context into the image: docs, host-side scripts, already- compiled driver sources, local __pycache__ and anything untracked. - The image now runs as a non-root user (S6471). This suite exists to execute third-party driver code against a live backend, so running it unprivileged meaningfully bounds a compromised dependency. Suppressions, following conventions already established in this repo: - CI-pg-compat.yml: write-all / @GH-Actions / secrets: inherit carry NOSONAR markers matching CI-set_parser_algorithm_3-g1.yml. All three are repo-wide caller conventions (68 of 69 callers), and the branch ref is required by the documented two-branch caller/reusable model -- a SHA pin would break it. - pg_lite_client.cpp: MD5 carries a NOSONAR in the style already used in lib/DNS_Cache.cpp. PostgreSQL's AuthenticationMD5Password defines the response as an MD5 construction on the wire, so a client exercising that auth path has no alternative digest to choose. Verified by building the image and running it: 35 tests collect cleanly, all four driver wrappers execute as the non-root user and honour the exit-2 CLI contract, the Prisma client and its debian-openssl-3.0.x query engine are generated despite --ignore-scripts, and the pinned packages install from wheels. The first build of the explicit-COPY change caught a real regression (drivers/python is imported in-process by tests/test_behaviors.py and must ship in the final image); that is fixed and re-verified here. --- .github/workflows/CI-pg-compat.yml | 6 +-- test/pg-compat/Dockerfile | 63 ++++++++++++++++++++++++------ test/pg-compat/requirements.txt | 22 +++++++---- test/tap/tests/pg_lite_client.cpp | 7 +++- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/.github/workflows/CI-pg-compat.yml b/.github/workflows/CI-pg-compat.yml index 64ead1b3cb..30475cbd9d 100644 --- a/.github/workflows/CI-pg-compat.yml +++ b/.github/workflows/CI-pg-compat.yml @@ -40,8 +40,8 @@ jobs: # 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 - uses: sysown/proxysql/.github/workflows/ci-pg-compat.yml@GH-Actions - secrets: inherit + 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/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 17943471d6..f8c8aa0d56 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -14,7 +14,10 @@ WORKDIR /src # 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 -RUN curl -fsSLo /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" \ +# --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 @@ -23,10 +26,13 @@ RUN javac --release 17 -cp /pgjdbc.jar Behaviors.java -d /out FROM node:22-bookworm-slim AS nodebuild WORKDIR /app COPY drivers/node/package.json drivers/node/package-lock.json* ./ -# The `npm install` fallback must never be reached in normal operation -# (package-lock.json is committed, so `npm ci` succeeds); it exists only -# for first-bootstrap before a lockfile exists. -RUN npm ci --omit=dev || npm install --omit=dev +# 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) ---- @@ -42,12 +48,19 @@ 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. The `npm install` fallback must -# never be reached in normal operation (package-lock.json is committed). -RUN npm ci || npm install +# 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" -RUN npx prisma generate +# --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 @@ -60,8 +73,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ 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 . . +# --only-binary :all: installs from wheels exclusively, so no package gets to +# run a setup.py at install time; every pin in requirements.txt publishes a +# manylinux/pure-python wheel. --require-hashes is deliberately NOT used: it +# would need a full transitive hash lock (pip-compile), which is a bigger +# change than this PR should carry. +RUN pip install --no-cache-dir --only-binary :all: -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/ @@ -79,4 +112,12 @@ RUN printf '#!/bin/sh\nexec java -cp /pg-compat/bin/java-classes:/pg-compat/bin/ && 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/requirements.txt b/test/pg-compat/requirements.txt index a0c9424da0..e081ed0bfb 100644 --- a/test/pg-compat/requirements.txt +++ b/test/pg-compat/requirements.txt @@ -1,8 +1,14 @@ -psycopg[binary]==3.2.* -asyncpg==0.30.* -# >=9.0.3: GHSA-6w46-j5rx-g56g (insecure tmpdir handling) affects every release -# before 9.0.3, so no 8.x pin can pick up the fix. The suite uses only stable -# APIs (fixtures, parametrize, skip/fail, pytest_collection_modifyitems), and -# pytest 9 requires Python >=3.10 which the 3.11 base image satisfies. -pytest>=9.0.3,<10 -tomli==2.* +# Exact pins, not ranges: the image is a test fixture whose results must be +# reproducible across reruns, and a resolved-version lock is what lets a +# rebuild months from now reproduce a past failure. Versions are the newest +# in each line that was previously specified as a range. +# +# pytest 9.0.3+ is a security floor, not just a bump: 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 (fixtures, +# parametrize, skip/fail, pytest_collection_modifyitems) and pytest 9 +# requires Python >=3.10, which the 3.11 base image satisfies. +psycopg[binary]==3.2.13 +asyncpg==0.30.0 +pytest==9.1.1 +tomli==2.4.1 diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index 926b06a560..f0c39e3140 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -376,9 +376,14 @@ 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); + 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); From 8de370bb42010e87d5c2dc931e4238f5b3d37bcd Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 06:38:07 +0000 Subject: [PATCH 56/57] fix(pg-compat): hash-lock the Python dependency tree Clears the last SonarCloud vulnerability (docker:S8544, "using dependencies without locking resolved versions") on the pip install. requirements.txt now pins the full transitive tree at exact versions, each with the SHA-256 of every wheel pip may legitimately select on linux -- pure-python wheels plus manylinux x86_64 and aarch64 for the compiled packages, so an arm64 build stays reproducible rather than failing the hash check. The Dockerfile consumes it with --require-hashes alongside the existing --only-binary :all:, so the install aborts if any artifact does not match a recorded hash. The header records how to regenerate the file after a version bump, since it is generated rather than hand-edited, and keeps the note that pytest 9.0.3 is a security floor (GHSA-6w46-j5rx-g56g) rather than a routine pin. Verified by rebuilding the final stage from scratch: the hashed install succeeds, the four pinned packages report the intended versions, 35 tests collect, and all four driver wrappers still honour the exit-2 CLI contract. --- test/pg-compat/Dockerfile | 9 ++-- test/pg-compat/requirements.txt | 85 ++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index f8c8aa0d56..233ef704ca 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -74,11 +74,10 @@ 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; every pin in requirements.txt publishes a -# manylinux/pure-python wheel. --require-hashes is deliberately NOT used: it -# would need a full transitive hash lock (pip-compile), which is a bigger -# change than this PR should carry. -RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt +# 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 diff --git a/test/pg-compat/requirements.txt b/test/pg-compat/requirements.txt index e081ed0bfb..57a1cd3ee2 100644 --- a/test/pg-compat/requirements.txt +++ b/test/pg-compat/requirements.txt @@ -1,14 +1,73 @@ -# Exact pins, not ranges: the image is a test fixture whose results must be -# reproducible across reruns, and a resolved-version lock is what lets a -# rebuild months from now reproduce a past failure. Versions are the newest -# in each line that was previously specified as a range. +# 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. # -# pytest 9.0.3+ is a security floor, not just a bump: 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 (fixtures, -# parametrize, skip/fail, pytest_collection_modifyitems) and pytest 9 -# requires Python >=3.10, which the 3.11 base image satisfies. -psycopg[binary]==3.2.13 -asyncpg==0.30.0 -pytest==9.1.1 -tomli==2.4.1 +# 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 From afac51045647d3c47fa5c153a705aed8a3c3271a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 08:02:10 +0000 Subject: [PATCH 57/57] fix: address CodeRabbit, gitar-bot and codex review findings pg_lite_client.cpp: doSASLAuth() still read the 4-byte auth type through `ntohl(*reinterpret_cast(buffer.data()))` in three places, bypassing the readAuthType() helper added precisely to avoid that unaligned/aliasing read. An oversight in the earlier hardening pass -- the SCRAM path is now routed through the helper too, and no such cast remains in the file. The `buffer.size() < 4` guards are kept ahead of the calls so each site still throws its own specific message rather than the helper's generic one. harness/diff.py: making only-targets/skip-targets actually honour globs exposed a second defect. compare() checks every proxy target against its format-matched direct baseline, so a filter naming only proxy targets -- the documented `only-targets: proxy_native_*` names no direct target at all -- removed the baselines and the case then failed with "baseline unavailable" no matter how transparent the proxy was. Before globs worked, that same filter matched nothing and skipped every target, i.e. it passed vacuously; the fix turned a silent non-test into a false failure. Baselines are now pulled back in after filtering, and the pairing rule lives in one shared baseline_name() used by both the filter and the assertion so they cannot drift apart. Pinned by a new infra-free unit test. ci-pg-compat.yml: add the GHCR login/pull step that retags ghcr.io/sysown/proxysql-ci-base:latest as proxysql-ci-base:latest before ensure-infras.bash runs. start-proxysql-isolated.bash runs ProxySQL from that local-only tag, which nothing on a fresh runner provides, so infra startup would have failed before a single pg-compat test executed. Mirrors ci-legacy-g4.yml, retry loop included, since both the login and the pull have been observed to fail transiently. The gap went unnoticed because the job only runs behind the 'pg-compat' label. Plan docs: the SP-1 plan was left internally inconsistent by the previous commit, which updated main() to the Observed/expected_binary_hex contract without updating Case, Observed or run_case. The contract is unified, and the case table is now a short excerpt that points at the shipped test as the source of truth instead of a full duplicate that drifts. The SP-2 plan still showed the _parse() that drops SQL following a metadata comment -- the bug the shipped _statements() documents fixing -- so it is synced. --- .../gh-actions-reusable/ci-pg-compat.yml | 35 ++++++++ .../2026-07-08-pgsql-sp1-tap-coverage-gaps.md | 84 +++++++++++++------ ...026-07-08-pgsql-sp2-polyglot-foundation.md | 10 ++- test/pg-compat/harness/diff.py | 44 +++++++--- .../tests/test_differential_selfcheck.py | 16 ++++ test/tap/tests/pg_lite_client.cpp | 9 +- 6 files changed, 154 insertions(+), 44 deletions(-) diff --git a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml index 746d9bf660..fd73a86ac0 100644 --- a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml +++ b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml @@ -62,6 +62,41 @@ jobs: - 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 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 index 23b586044d..df88551b3c 100644 --- 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 @@ -544,27 +544,41 @@ struct Case { 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 }; -// One representative literal per type; expand freely — adding a row is the unit of work. +// 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 = { - { "bool", "SELECT true", "t", 16 }, - { "int4", "SELECT 2147483647::int4", "2147483647", 23 }, - { "int8", "SELECT 9223372036854775807::int8", "9223372036854775807", 20 }, - { "float8", "SELECT 1.5::float8", "1.5", 701 }, - { "numeric", "SELECT 12345.6789::numeric", "12345.6789", 1700 }, - { "text_utf8", "SELECT 'héllo'::text", "héllo", 25 }, - { "bytea", "SELECT '\\xdeadbeef'::bytea", "\\xdeadbeef", 17 }, - { "uuid", "SELECT '00000000-0000-0000-0000-000000000001'::uuid", - "00000000-0000-0000-0000-000000000001", 2950 }, + { "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 }, - { "jsonb", "SELECT '{\"a\":1}'::jsonb", "{\"a\": 1}", 3802 }, - { "int4_array", "SELECT ARRAY[1,2,3]::int4[]", "{1,2,3}", 1007 }, - { "inet", "SELECT '192.168.0.1'::inet", "192.168.0.1", 869 }, + "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), @@ -605,29 +619,41 @@ int main(int argc, char** argv) { 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, std::string& observed_value, int32_t& observed_oid) { +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); - // Extended protocol: unnamed prepared statement, result format = fmt. + + // 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.bindStatementSingleFormat("", "", {}, 0 /*param fmt n/a*/, { fmt }, 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; - bool got_row = false; 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 - observed_oid = r.readInt32(); // type oid + 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); @@ -635,9 +661,15 @@ static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, in if (ncols >= 1) { int32_t len = r.readInt32(); if (len >= 0) { - auto bytes = r.readBytes(len); - if (fmt == 0) observed_value.assign(bytes.begin(), bytes.end()); - got_row = true; + 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) { @@ -648,7 +680,7 @@ static bool run_case(const Case& c, int16_t fmt, std::string& observed_value, in } } conn.disconnect(); - return got_row; + 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/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md index 412b47a86f..34255736d9 100644 --- a/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md @@ -558,9 +558,17 @@ 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)) - stmts = [s.strip() for s in sql.split(";") if s.strip() and not s.strip().startswith("--")] + # 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): diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py index a8e0ad13e7..cdda331e06 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -49,6 +49,16 @@ def _parse_meta(sql): 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 @@ -117,14 +127,29 @@ def _run(stmts, targets, admin, skip, only): # 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 targets: - if not t.available: - continue - if _matches_any(t.name, skip): - continue - if only and not _matches_any(t.name, only): - continue + for t in selected: results[t.name] = _run_on(t, stmts, admin, native_present) return results finally: @@ -164,14 +189,11 @@ def compare(results): ``results`` are compared, each against its direct baseline (which is always available). Returns ``(ok, detail_text)``. """ - def base(name): - return "direct_binary" if name.endswith("binary") else "direct_text" - diffs = [] for name in sorted(results): if name.startswith("direct"): continue - b_name = base(name) + b_name = baseline_name(name) b = results.get(b_name) if b is None: diffs.append(f"{name}: format-matched baseline {b_name} unavailable") diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py index 3d2b2d8804..fe1d25f6ad 100644 --- a/test/pg-compat/tests/test_differential_selfcheck.py +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -89,6 +89,22 @@ def test_target_filter_supports_globs(): 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. diff --git a/test/tap/tests/pg_lite_client.cpp b/test/tap/tests/pg_lite_client.cpp index f0c39e3140..2be67dedec 100644 --- a/test/tap/tests/pg_lite_client.cpp +++ b/test/tap/tests/pg_lite_client.cpp @@ -462,8 +462,7 @@ void PgConnection::doSASLAuth(const std::string& password, free(client_first); free_scram_state(st); throw PgException("scram: " + extractErrorMessage(buffer)); } - if (type != AUTH_TYPE || buffer.size() < 4 || - ntohl(*reinterpret_cast(buffer.data())) != 11) { + if (type != AUTH_TYPE || buffer.size() < 4 || readAuthType(buffer) != 11) { free(client_first); free_scram_state(st); throw PgException("expected AuthenticationSASLContinue(11)"); } @@ -492,8 +491,7 @@ void PgConnection::doSASLAuth(const std::string& password, free(client_first); free(client_final); free_scram_state(st); throw PgException("scram: " + extractErrorMessage(buffer)); } - if (type != AUTH_TYPE || buffer.size() < 4 || - ntohl(*reinterpret_cast(buffer.data())) != 12) { + 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)"); } @@ -511,8 +509,7 @@ void PgConnection::doSASLAuth(const std::string& password, // 5) Expect AuthenticationOk (0). readMessage(type, buffer); if (type == ERROR_RESPONSE) throw PgException("scram: " + extractErrorMessage(buffer)); - if (type == AUTH_TYPE && buffer.size() >= 4 && - ntohl(*reinterpret_cast(buffer.data())) == 0) return; + if (type == AUTH_TYPE && buffer.size() >= 4 && readAuthType(buffer) == 0) return; throw PgException("scram: no AuthenticationOk after SASLFinal"); }