Skip to content

Local-development distribution for PGX (CLI, installer, Docker, tests) - #1

Merged
alexshapalov merged 24 commits into
mainfrom
pgx-local-dev
Oct 18, 2026
Merged

alexshapalov merged 24 commits into
mainfrom
pgx-local-dev

Conversation

@alexshapalov

Copy link
Copy Markdown
Contributor

Local-development distribution for PGX

Makes PGX installable and usable locally with no system PostgreSQL: a self-contained, relocatable PostgreSQL-18-compatible runtime plus a pgx CLI, an installer, a Docker image, and an extensive automated test suite.

What lands

  • crates/bin/pgx — the pgx CLI: init / start / stop / restart / status / create / drop / clone / ls / url / doctor / serve / version.
  • dist/ — relocatable distribution (assemble.sh), one-line installer (install.sh), Docker image + pgx serve entrypoint, and the CI/release workflows.
  • tests/pgx, tests/apps, tests/compat — black-box SQL, crash recovery, transaction isolation, storage/clone, concurrency and stress suites; real app fixtures (psql/node/python/go/rails); the ported compatibility suite.
  • fix: libxml2 soname probe (b2ac5558e8) — the one engine change: XML functions on hosts with libxml2 ≥ 2.14.
  • Docs under docs/pgx/local-dev/.

Verification (already run locally)

  • macOS arm64: 95 Rust tests + all Python suites + 5 app fixtures + installer + compat, green.
  • Linux arm64 (Docker): lifecycle / SQL / clone / restart / isolation / crash, green.
  • Durability: full = 0 of 21,436 commits lost; ephemeral loses only a clean prefix tail (documented).

Not covered here (tracked separately)

  • Docker image publish, Linux x86-64, and ZFS validation are blocked on infra and are not claimed as passing.

Release-branch model

main is the public development + GitHub-Release branch. The stable engine PGRun deploys lives on prod (pinned at 913630e11d = pgx-v0.1-beta.1). See the stacked PR for the CI gating that enforces "public artifacts from main only".

🤖 Generated with Claude Code

alexshapalov and others added 20 commits October 12, 2026 13:03
…so.16, report the probe

PGRun beta finding: the row-count idiom xpath(..., query_to_xml(...)) failed
on a PGX host with "could not dlopen libxml2.so.2" while every other check
matched PostgreSQL, and the compatibility gate had no way to see it.

Root cause: libxml2 2.14 renamed its SONAME from libxml2.so.2 to
libxml2.so.16. Ubuntu 25.10+ (26.04 LTS) ship only .so.16, and the bare
libxml2.so symlink belongs to the -dev package a runtime host does not
install. The loader probed .so.2 / .so / .2.dylib only, so a host that had
libxml2 still failed every xpath, xmltable, xml_is_well_formed and xml
input call. The failure surfaced as a bare backend panic reported as an
internal error.

- adt_xml::libxml: probe libxml2.so.16 before .so.2 and .so (macOS: the
  system libxml2.2.dylib, then libxml2.16.dylib and the Homebrew keg);
  record which name loaded and libxml2's xmlParserVersion.
- A host with no loadable libxml2 now raises C's NO_XML_SUPPORT shape:
  SQLSTATE 0A000 "unsupported XML feature" with the same DETAIL, plus a
  HINT carrying the probe failure and the package to install. The session
  survives; non-XML statements are unaffected.
- pgrust_runtime_status() gains "libraries": libxml2 {available, library,
  version, error} and icu {available, unicode_version}, so pgx-agent's
  Status() can exclude a misconfigured host before it serves a branch.
- docs: compatibility-gate.md records the finding as host rule
  host_library_missing (a host rule, not a source rule); pgrun-interface.md
  documents the field; README names the run-time libraries and packages.

Verified on Ubuntu 26.04 arm64 (libxml2 2.15.2, libxml2.so.16 only, no
.so.2): xpath/xmltable/xml input/query_to_xml all work and the status
reports library "libxml2.so.16", version "21502". With the library hidden
the same statements return 0A000 with the hint, the session continues, and
the status reports available=false with the probe message. macOS loads the
system libxml2.2.dylib as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dist/assemble.sh builds pgx-<version>-<os>-<arch>/ (bin/pgx, bin/postgres,
share/{timezone,timezonesets,tsearch_data,extension}, share/pgx/base-cluster
from a stock PostgreSQL 18 initdb, dist.json, licenses) plus a tarball and
its sha256. dist/test/assemble_test.sh checks the layout in dist/layout.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New workspace crate crates/bin/pgx: argument parsing with the stable exit
codes (0 ok, 1 failure, 2 usage, 3 not ready, 4 conflict, 5 not found,
6 in use, 7 corrupt install), installation layout discovery relative to the
binary, $PGX_HOME resolution, and config.json (0600) round-tripping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Startup over the Unix socket with trust auth, simple queries with text
results, ErrorResponse decoding, identifier and literal quoting. Tested
against a fake duplex stream; the live path is covered by the black-box
suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pgx init copies the bundled base cluster into $PGX_HOME/data (0700), writes
postgresql.conf / pgx.conf / pg_hba.conf (socket trust, loopback
scram-sha-256), picks a free port, probes block cloning to choose
file_copy_method, starts the engine socket-only to set a random superuser
password, stops it, and records config.json (0600). Refuses long socket
paths (PGX_RUN_DIR escape hatch), busy ports, and a second init without
--force. pgx version reports the distribution's dist.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
start spawns the engine detached in its own session with the stack limit
raised, regenerates pgx.conf from config.json, clears stale postmaster.pid
files (pid dead, or alive but not our engine), refuses a busy port with
exit 4, and serialises concurrent starts with a lock file so exactly one
engine runs. stop is a fast shutdown (SIGINT) with --force escalating to
SIGKILL; status prints the state in text or JSON and exits 3 when stopped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Database names are validated (^[A-Za-z_][A-Za-z0-9_-]{0,62}$, system
databases refused as targets) and always quoted. drop maps 55006 to exit 6
with a --force hint and WITH (FORCE) when asked; clone is CREATE DATABASE
TEMPLATE with timing and the copy method; ls and url have JSON forms and
the URL is verified to work with psql over TCP (scram) and the socket.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rust black-box tests for pgx clone (isolation both ways, in-use source is
exit 6, missing source exit 5, existing target exit 4, failed clones leave
no base/<oid> directory, 8 concurrent clones, 20 clone/drop cycles leak no
files). tests/pgx/lib: a standard-library wire client (trust, md5, SCRAM,
extended protocol, COPY) and a harness around the pgx CLI.
tests/pgx/clone_consistency.py clones a checksummed source ten times under
a concurrent writer and verifies each clone is identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Checks the installation layout, dist.json, that the engine runs, the data
directory and config.json (mode), socket path length, runtime state and
stale pid files, port ownership, free disk, the hard stack limit, the
filesystem and block cloning, libxml2/ICU when running, and the log for
FATAL/PANIC. Exit 1 when any check fails; runs before init too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/pgx/test_sql.py runs 19 content-checked cases against a pgx-managed
runtime over real connections: DDL/DML, joins and windows, constraint
SQLSTATEs, index use, JSONB, arrays, sequences/identity, views, plpgsql,
prepared statements over the extended protocol, COPY in/out, error
recovery, isolation levels, row locks, ten concurrent writers, scram over
TCP and both URL forms. tests/pgx/run.sh runs the Python suites and keeps
logs under tests/results/<timestamp>/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/compat: PGRun's behavioural compatibility suite (minitest + pg),
run.sh starts a throw-away PGX and a PostgreSQL 18 control and compares
rows and SQLSTATEs step by step (22 runs, 53 assertions, 0 failures).
Regression baseline on 1fa2878 with the PGX profile: 223/231 byte-exact,
6 plan-only, 2 profile-induced (synchronous_commit off, no parallel
workers); artifacts under docs/pgx/local-dev/validation/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…han and invalid-db handling

Found and fixed by adversarial testing:
- pgx clone now issues CREATE DATABASE ... STRATEGY = FILE_COPY so the engine's
  file_copy_method applies: a 281 MB clone consumes ~0 MB (copy-on-write via
  clonefile/reflink) at ~42 ms, instead of a full 280 MB WAL_LOG copy.
- pgx start retries its port probe for up to 5 s so a socket lingering from a
  just-killed engine is not mistaken for a real conflict (fixes intermittent
  exit 4 after SIGKILL); the engine binds with SO_REUSEADDR and never hit it.
- databases left invalid by an interrupted DROP (datconnlimit = -2) are flagged
  by pgx ls and are droppable; pgx doctor gains read-only orphan base/<oid>
  detection (conservative: runtime up, numeric names, >2 min old) and never
  deletes anything.

Suites (macOS ARM64, against the assembled distribution):
- test_crash.py: SIGTERM/INT/KILL at rest and during insert/commit/create/
  clone/drop/shutdown, storage exhaustion, permission and bad-config starts,
  stale pid/socket. 5 runs ephemeral + 1 full, randomized order. full keeps
  every acknowledged commit (0 lost of 21436); ephemeral loses only a clean
  prefix tail under synchronous_commit=off (documented), never a partial batch.
  Disk-full raises 53100 then PANICs the runtime (one crash domain); recovers
  on restart with data intact.
- test_isolation.py: RC/RR/serializable, write skew (40001), deadlock (40P01),
  lock/statement/idle timeouts, advisory locks, drop --force (57P01),
  concurrent create/drop of one name. 12 tests.
- test_storage.py: 150+ clone/drop cycles leak nothing, clone disk cost matches
  the backend, clone isolation, permissions, many-database restart, low-disk
  PANIC+recovery. storage_zfs.sh covers ZFS on a host that has it (COVERAGE GAP
  otherwise; never mocked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_concurrency.py: 10 and 100 concurrent clients, 16 parallel creates, 20
concurrent CLI reads, connect/disconnect churn; every invariant checked after
the storm. stress.py: the acceptance workloads (1000 create/drop, 1000
clone/drop, N clients for a fixed duration, start/stop cycles, optional soak)
while sampling the engine's RSS, file-descriptor and thread counts, and fails
on fd/thread growth that signals a leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each fixture drives a real client against a database on the assembled PGX
distribution and checks results, not just exit codes: psql (DDL/DML/\copy/
rollback/JSONB), node-postgres (pool, prepared, 23505, JSONB), psycopg 3
(executemany, COPY, server-side cursor, 42P07), jackc/pgx v5 (CopyFrom,
batch, rollback, unique violation), ActiveRecord 8.1 (migrations up/down,
seed, savepoint rollback, RecordNotUnique, InvalidForeignKey, JSONB, 8
parallel threads, schema dump, cleanup). run_all.sh creates a database per
app with pgx, runs it, drops it; missing drivers are reported as coverage
gaps, never silent passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dist/install.sh: one-line installer. Detects OS/arch, downloads the release
  tarball and .sha256, verifies the checksum before installing, installs
  atomically into PGX_INSTALL_DIR, links pgx into PGX_BIN_DIR, verifies the
  binary runs, no root. dist/test/install_test.sh proves download, verify,
  relocate and refusal of a tampered tarball end-to-end over local HTTP.
- pgx serve: run the engine in the foreground (init if needed), the Docker and
  systemd entrypoint. init now reads PGX_PASSWORD, PGX_PORT, PGX_DURABILITY and
  PGX_LISTEN (widening pg_hba with scram for non-loopback addresses).
- Dockerfile (multi-stage) + entrypoint: ubuntu:24.04 runtime with libxml2 and
  ICU, PGX_HOME on a volume; dist/test/docker_test.sh builds, serves over TCP
  and checks persistence across a restart (COVERAGE GAP when no daemon).
- .github/workflows: ci.yml (fmt/clippy, unit + black-box, installer, Docker),
  release.yml (linux-amd64/linux-arm64/darwin-arm64 artifacts + checksums as a
  DRAFT, lifecycle suite against the packaged artifact), nightly.yml (crash,
  isolation, concurrency, storage, stress, compat, regression, app fixtures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…veness)

Found by running the assembled distribution on real Linux arm64 (Ubuntu 26.04):
- dist/assemble.sh: Debian/Ubuntu build PostgreSQL --with-system-tzdata, so the
  share dir has no timezone/ tree. Add --tzdir with a /usr/share/zoneinfo
  fallback (recorded as tzdata_source in dist.json). macOS/Homebrew unchanged.
- A PGX runtime detaches the engine on 'pgx start'; if its parent is gone and
  PID 1 does not reap (a bare container), the engine becomes a zombie on exit.
  kill(pid,0) reports a zombie as alive, so stop/status hung and restart was
  blocked by the engine's own socket lock. Fixes: pid_alive() treats a Linux
  zombie (/proc/<pid>/stat state Z) as not alive; pgx start clears a socket
  lock whose PID is dead (zombie-aware); the Python harness filters zombies
  from engine_pids(). Deployments with an init (systemd, docker run --init) or
  the image's 'pgx serve' (engine is PID 1) never hit this.
- test_crash: ephemeral async-commit loss is reported, not bounded by an
  arbitrary time window (documented synchronous_commit=off behaviour; the
  prefix and no-partial-batch invariants still assert no corruption).

Linux arm64 results against the assembled dist: lifecycle, 19 SQL tests,
clone, restart-persistence, 12 isolation tests, and the 10 crash tests all
pass. macOS ARM64 unchanged: 90 cargo tests, fmt and clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README Quickstart now leads with the install one-liner and the pgx CLI
(build-from-source kept below). docs/pgx/local-dev/ adds install, CLI
reference, integrations (Rails/Node/Python/Go/psql), storage (COW vs
portable), troubleshooting, architecture and benchmark methodology, plus
validation-report.md: the full suite matrix, per-test regression
differences with causes, the durability and crash-recovery results, the
macOS/Linux/ZFS/Docker coverage breakdown, risks, and the
GO / NO-GO / BLOCKED decision (GO for macOS and Linux arm64 local dev;
Docker, Linux x86-64 and ZFS BLOCKED on infrastructure, gated by CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…L encoding, install safety)

Fixes from the final fresh-context review:
- conf.rs: validate every PGX_LISTEN entry (IP / CIDR / special token) and
  reject whitespace, quotes and '#', closing a pg_hba injection (a templated
  listen value could have written a 'trust' rule) and the malformed-CIDR
  breakage; pg_hba now emits a CIDR as-is instead of appending /32.
- process.rs: is_our_engine matches the '-D <data>' launch flag, not the data
  path as a bare substring, so a backup/editor touching ~/.pgx cannot be taken
  for the engine; init --force refuses while the recorded PID is alive at all,
  so a mis-identified live cluster is never deleted.
- cmd/db.rs: the password in a connection URL is percent-encoded as userinfo
  (escaping / ? # among others), so a PGX_PASSWORD with special characters no
  longer yields a malformed postgres:// URL.
- dist/install.sh: move an existing install aside before renaming the new one
  in, so an interruption can never leave no install (brief: avoid partial
  installs).
Deferred (Minor): the wire-protocol parser can panic on a malformed message
from the local trusted engine (clean abort, not an out-of-bounds read; never
fires in normal operation).

Full suite: 95 pgx tests, fmt and clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lockfile entries for the new crates/bin/pgx workspace member (libc, serde_json
and their existing transitive deps; no new external dependency trees).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alexshapalov and others added 4 commits October 17, 2026 21:38
The 'Unit tests' step ran `cargo test -p pgx --release` with PGX_DIST="",
which also pulls in the integration suites in tests/ (clone/lifecycle/…) that
require an assembled distribution — so they panicked 'PGX_DIST= lacks
bin/postgres' and failed the job before the black-box step could run with
PGX_DIST set. Scope that step to `--bin pgx` (the crate's own unit tests).

Also drop `docker build -q` in docker_test.sh so a build failure shows the
builder's output (e.g. assemble.sh's own error) instead of only buildkit's
summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two latent failures that only surface now that the Unit tests step is scoped
correctly (the integration suites never actually ran in CI before):

- CI assembled the dist with --version ci-<sha>, so `pgx version` reported a
  non-0. version and version_prints_engine_and_compat failed its
  starts_with("0.") check. Assemble with 0.0.0-ci.<sha> (a valid-looking
  prerelease) and match it in the Export PGX_DIST glob.
- free_port() used bind(:0), so under parallel tests two harnesses could be
  handed the same recently-closed ephemeral port and race to bind it in
  `pgx init` (exit 4). Hand out a distinct, pid-offset, bind-verified port per
  call instead.

Neither assertion is weakened. clone/lifecycle/databases/doctor pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Docker image build failed with 'initdb: error: cannot be run as root':
  assemble.sh bakes the base cluster with initdb, which refuses root, and the
  builder stage runs as root. Create a 'builder' user and run assemble as it.
- The harness execs a freshly-copied pgx binary; under CI parallelism the
  spawn can transiently fail (EAGAIN / ETXTBSY). Retry a spawn *failure* (not a
  non-zero exit, which is a real result) a few times.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The container ran as root, and the PGX engine (like PostgreSQL) refuses
"root execution of the PostgreSQL server" — so `pgx serve` exited status 1 and
the container never accepted TCP. Add an unprivileged `pgx` user, own the
data/run dirs, COPY the distribution --chown=pgx (so the bundled base cluster,
mode 700, is readable), and USER pgx. The named volume inherits pgx ownership.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexshapalov
alexshapalov merged commit c82481e into main Oct 18, 2026
6 checks passed
@alexshapalov
alexshapalov deleted the pgx-local-dev branch October 18, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant