Skip to content

test: pg_upgrade gets its pytest twin, and its index arm takes the index (#1341) - #1461

Merged
jdatcmd merged 17 commits into
mainfrom
test/1341-pg-upgrade-twin
Oct 16, 2026
Merged

jdatcmd merged 17 commits into
mainfrom
test/1341-pg-upgrade-twin

Conversation

@OffgridwithJD

@OffgridwithJD OffgridwithJD commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1458 (merge after it). Two commits: 585369d6 (the twin), and 70c186ee, which runs its server tools as postgres under root (jdatcmd's review). It is 16/16 as root and as a normal user.

What was wrong

pg_upgrade.sh's "the index still answers the same range" did not test the index. Right after pg_upgrade there are no planner statistics, and with enable_seqscan=off the planner still took the columnar custom scan for the range. Measured: with DROP INDEX c_id run after the upgrade, the old shell stayed 16/16 green.

The arm and a new "premise: the range lookup takes the upgraded index" now run with pgcolumnar.enable_custom_scan=off. The premise greps the printed plan for an index scan of c_id; a bitmap scan counts.

Red first (harness mutant, PG17 → 18)

mutant old shell new shell twin
none (head) 15/15 16/16 16/16
DROP INDEX c_id after the upgrade green red on the premise red on the premise

This is a harness mutant, not a source one: pg_upgrade copies the extension catalogs as files, so I found no source mutant that a single arm separates. It dates nothing either way. The suite has its own check(), records nothing, and is not registered, so there are no ledger rows and no budget change.

The twin

test_pg_upgrade.py carries all 16 checks by the shell's name (compare_to_bash: 16 literal, 0 missing). Like the shell it needs two majors:

  • --pg-config names the new major.
  • PGC_UPGRADE_FROM_PG_CONFIG names the old one. The twin builds and installs the extension for it from a copy of the tree.

The matrix runs one major at a time, so there the twin declines with UNMET_PRECONDITION. expected_unrunnable.txt lists it for 15–19, and a run that sets the variable is an upgrade run whose row that file would refuse.

Runs

pair shell twin
15 → 16 16/16 16/16
16 → 17 16/16 16/16
17 → 18 16/16 16/16
18 → 19 (19rc1) 16/16 16/16

The 18 → 19 run caught a twin bug: it read the major of 19rc1 as 191, because it kept every digit. The fix takes the leading digits only.

Bookkeeping

  • COMPLETE gains pg_upgrade.
  • TESTS.md gains §151.
  • cluster_tests goes 680 → 681.

The NO_CLUSTER guards pass (438), harness_selftest.sh gives 1245, and docs_style.sh passes.

🤖 Generated with Claude Code

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on 585369d, for one blocker in the twin. The shell side holds up.

The shell side, measured on PG17 → PG18 (link mode):

  • head: 16/16;
  • with DROP INDEX c_id run after the upgrade: red on premise: the range lookup takes the upgraded index, got [0] want [1].

So the new premise is what makes the index arm about the index, as you measured.

The blocker: the twin cannot run as root, and this host runs pytest as root. Run in upgrade mode (PGC_PG_CONFIG=/usr/local/pgsql/bin/pg_config PGC_UPGRADE_FROM_PG_CONFIG=/usr/local/pg17/bin/pg_config, under sudo, as the harness requires here), it fails at the head:

test_pg_upgrade.py:61: FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pgc-upgrade-…/old/postgresql.conf'

The cause is the line before that: initdb: error: cannot be run as root. _cluster calls initdb through _sh, which neither drops to the postgres user nor checks the return code. So the refusal is lost, and the failure shows up one step later as a missing file.

This is the host-shaped assumption the harness already handles. pgc_cluster._asroot runs initdb, pg_ctl and postgres as postgres when euid is 0, and lib.sh uses runuser for the same reason. pg_upgrade refuses root too, so all four binaries need it, and the data and work directories must be owned by postgres.

Asks:

  1. Run initdb, pg_ctl and pg_upgrade through _asroot (or the same runuser drop), and chown the work tree to postgres when root.
  2. Check each step's return code and fail with its stderr, so a refused step names itself rather than surfacing as FileNotFoundError.

The decline path (UNMET_PRECONDITION when the variable is unset, which is what the matrix and CI exercise) is fine as it is, and is why CI can't see this.

OffgridwithJD pushed a commit that referenced this pull request Oct 16, 2026
…nd names a refused step (#1341)

jdatcmd, reviewing #1461: under root, initdb refused ("cannot be run as root"),
_sh discarded the code, and the failure surfaced one step later as a
FileNotFoundError for postgresql.conf. initdb, pg_ctl and pg_upgrade now go
through _server_tool, which drops to postgres with runuser when euid is 0
(as pgc_cluster._asroot and lib.sh do), chowns the data directories, and
raises naming the step and its stderr. initdb passes -U postgres, and the
socket directory is the datadir, as pgc_cluster pins it. A failed make of the
old major's extension now shows its stderr too.

Measured as root (sudo, PG17 -> PG18): 585369d fails with that
FileNotFoundError, this commit is 16/16. As a normal user: 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd You were right, and thanks: I've reproduced it and fixed it in 70c186ee.

Reproduced as root (sudo, PG17 → PG18, on staged copies of the prefixes and the tree so postgres can traverse them). At 585369d6:

E       FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pgc-upgrade-…/old/postgresql.conf'

The fix, taking your two asks in order:

  1. initdb, pg_ctl (start, stop and the teardown's immediate stop) and pg_upgrade now go through a _server_tool helper. It works like pgc_cluster._asroot: under euid 0 it chowns the data directories to postgres and runs the binary under runuser -u postgres --. pg_upgrade keeps cwd=work, a 0777 directory, for its logs and socket.
  2. Every step checks its return code and raises "<tool> failed rc=N: <stderr>". The one exception is pg_upgrade, whose exit code stays the check "pg_upgrade exited cleanly", and which prints its output when that fails. A failed make or make install of the old major's extension now shows its stderr too.

While I was there:

  • initdb passes -U postgres explicitly, as Cluster.initdb does. Without it, a host with no wrapper names the superuser after the OS user.
  • unix_socket_directories is pinned to the datadir, as pgc_cluster does. A packaged build's default is a directory only postgres can write.

After the fix:

  • 16/16 as root;
  • 16/16 as a normal user (17 → 18).

I also ran #1463's replication twin as root before you got to it: 44/44. It uses pgc_cluster.Cluster for all three clusters, so it already drops privileges. #1463 is rebased onto 70c186ee.

🤖 Generated with Claude Code

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving 70c186e. Both asks are done.

Re-measured on PG17 → PG18, with pytest run as root (sudo), as on this host:

  • head: test_pg_upgrade.py 16 checks, passed;
  • the same harness mutant, DROP INDEX c_id after the upgrade: red on premise: the range lookup takes the upgraded index, got 0 want 1.

The twin now matches the shell side I measured on 585369d.

_server_tool runs initdb, pg_ctl and pg_upgrade through runuser -u postgres when euid is 0, with the work tree chowned first. A refused step raises with its own name and stderr, so the FileNotFoundError-one-step-later shape is gone. The make for the old major also asserts its return code now.

jdatcmd and others added 17 commits October 16, 2026 05:01
…es ALTER EXTENSION UPDATE (#1341 item 66)

Between installing a new library and running ALTER EXTENSION pgcolumnar UPDATE, a
database runs the 1.0-alpha5 library over its 1.0-alpha4 catalog. The library deletes
from pgcolumnar.retired_group, which that catalog lacks, so DROP TABLE, TRUNCATE and
pgcolumnar.vacuum() on a columnar table fail there with 42P01 'columnar metadata
table "pgcolumnar.retired_group" does not exist', which names neither the cause nor
the remedy. Measured: each refusal changes nothing, reads and writes work throughout,
and after the update all three succeed on the same rows.

open_columnar_table's error now carries a hint naming the update.
docs/installation.md's "If you skipped step 3" shows the error and the hint.

Red first, both twins, PG18: native_upgrade_converge gains the window's arms in a
database at the previous release's catalog (the newest fixture), and the three
refusal arms fail on main with `got [42P01|no-hint]`. test_native_upgrade_converge.py
is their partial twin, declared INCOMPLETE; it stages the previous release's base
script from test/fixtures as the shell does. Green on 15-19 in both twins.

cluster_tests 660 -> 661 by collection; TESTS.md gets section 143.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… still be set to a row store (#1341)

test_fk_referencing.py asserts every shell check by name; compare_to_bash grades it as
covering all 28 (27 checks on PG17+, and the pre-17 branch's own), and it joins
COMPLETE.

The conversion guard's over-rejection control converted a table with no foreign key
back to heap, so it could not see a guard that refused every SET ACCESS METHOD on a
referenced table whatever the target: with the guard's columnar-target test removed,
the suite stayed 26/26. A new arm sets the referenced table itself to heap; under that
mutant both twins now fail it.

cluster_tests 661 -> 666 by collection; TESTS.md gets section 144.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tations of the foreign-key guards (#1341)

28 rows from five-major runs (27/27 on 17-19, 26/26 on 15-16). Dated on PG18 by:
the referenced-side refusal removed (5), the guard keyed on the referencing table
(8), the conversion refusal removed (8), and the conversion refused whatever the
target (1). suites_not_covered 219 -> 218; the census is 3647 rows, 2686 never,
961 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_truncate_cleanup.py asserts every shell check by name; compare_to_bash grades it
as covering all 25, and it joins COMPLETE. It runs in a private database, since the
snapshot counts every pgcolumnar catalog row and the shared one holds every other
test's. The shell suite needed no change: each of its arms already goes red under a
mutation of the path it names.

cluster_tests 666 -> 672 by collection; TESTS.md gets section 145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mutations of the truncate paths (#1341)

25 rows from five-major runs (25/25 on each of 15-19). Dated on PG18 by a rewrite
that keeps the retired storage's rows (7) or its stale write state (1), an in-place
truncate that skips the projections' storages (8), and a storage-tree delete that
skips them (1). suites_not_covered 218 -> 217; the census is 3672 rows, 2695 never,
977 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zone-map answer that ignores deletes (#1341)

test_native_dml.py asserts every shell check by name; compare_to_bash grades it as
covering all 15, and it joins COMPLETE.

The min/max arm could not fail as its comment claimed. The deletes (id % 7 = 0)
removed neither the smallest nor the largest id, so min and max were the same
whether or not the deleted rows counted; and measured with groups that hold deletes
left on the zone-map path, min/max(id) still came out right while min/max(k) came
out wrong (10|50000 for 20|49990). The suite stayed 13/13. The deletes now include
ids 1 and 5000, a premise asserts on heap that min and max moved, and the arm takes
min/max over k as well as id; under that mutant both twins fail it.

The index arms gain a premise that the lookups take the primary-key index, so they
test the index fetch's delete check rather than the scan.

cluster_tests 672 -> 673 by collection; TESTS.md gets section 146.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons (#1341)

15 rows from five-major runs (15/15 on each of 15-19). Dated on PG18 by: a row group
with deletes still answering min/max from zone maps (1), count(*) from metadata not
subtracting deleted rows (3), and the index fetch ignoring the delete vector (5).
suites_not_covered 217 -> 216; the census is 3687 rows, 2702 never, 985 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…longer pass its survival arm (#1341)

test_native_dict_underfill.py asserts every shell check by name; compare_to_bash
grades it as covering all 4, and it joins COMPLETE. It turns the post-codec encoding
choice off for its own session, where the shell sets it for its database.

The survival arm was a fresh SELECT 1, which a crashed backend passes once crash
recovery has finished, and the suite computed a crash flag it never checked. It now
counts "terminated by signal" in the server log. The rejection arm matched
'does not match|corrupt|invalid|out of' in any message, which "out of memory"
satisfies too; it now requires XX001. A premise asserts the inflation rewrote the
chunk's descriptor.

cluster_tests 673 -> 674 by collection; TESTS.md gets section 147.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 rows from five-major runs (4/4 on each of 15-19). Dated on PG18 by the DICT fill
check removed (1) and replaced by a crash (2, one shared). suites_not_covered
216 -> 215; the census is 3691 rows, 2704 never, 987 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ers can see a double count and a deleted row (#1341)

test_native_decode_gating.py asserts every shell check by name; compare_to_bash
grades it as covering all 29, and it joins COMPLETE. It sets the width gate off for
its session, where the shell pins it off for its cluster.

Two of the filter counters' guarantees could not fail:
- every row of the needle's surviving vector matched, so the producer's re-test of
  that vector rejected nothing, and a producer counting its rejections a second time
  read exactly right: with the non-counting filter disabled the suite stayed 26/26.
  A qual that half of vector 0 passes now asserts its rejections are counted once.
- the counters were checked only on the table without deletes, so with the
  evaluator's deleted-row test removed the suite stayed 26/26. Rows Removed by Filter
  is now checked on the multi-group table with deletes, against heap's live count.

The multi-group fixture's DELETE ran through the gating it was built to test (its
WHERE is an unprunable qual too), so a gating mutant broke the fixture rather than
the arms. It now runs with the width gate out of reach, and its premise counts the
surviving rows with count(*) instead of a gated WHERE.

cluster_tests 674 -> 676 by collection; TESTS.md gets section 148.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…our mutations of phase 2 (#1341)

29 rows from five-major runs (29/29 on each of 15-19). Dated on PG18 by: the
per-vector evaluation never run (4), a deleted row counted as filtered (1), every
vector skipped (7), and a double count in the producer (2). No premise is dated: the
fixture no longer depends on the gating under test. suites_not_covered 215 -> 214;
the census is 3720 rows, 2722 never, 998 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d settings are proven absent by SQLSTATE (#1341)

test_native_decode_gate_width.py asserts every shell check by name; compare_to_bash
grades it as covering all 35, and it joins COMPLETE. It holds the threshold for its
session, where the shell sets it per database because every helper opens a new
connection.

The two premises that the rejected probe settings were not shipped compared SHOW's
output with the empty string: q returns empty for a dead connection too, and an empty
expectation is the shape the pytest layer refuses. Both twins now require SHOW to
raise 42704 (undefined_object).

cluster_tests 676 -> 677 by collection; TESTS.md gets section 149.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y three mutations of the width gate (#1341)

35 rows from five-major runs (35/35 on each of 15-19). Dated on PG18 by gating
whatever the width (2), > for >= (1) and a payload count one too high (1, shared).
suites_not_covered 214 -> 213; the census is 3755 rows, 2754 never, 1001 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…by arms test what they name (#1341)

test_pg_dump_roundtrip.py asserts every shell check by name; compare_to_bash grades it
as covering all 25 names (40 checks over the two formats), and it joins COMPLETE. It
dumps a private source database and restores it into fresh ones.

Two arms could not fail as claimed:
- "restored index answers a point lookup" disabled seqscan and bitmapscan, but the
  columnar scan is neither, and with the index dropped from the restored database the
  lookup still answered 12345: the suite stayed 36/36. A premise now asserts the
  lookup's plan uses dt_id_idx.
- "restored sort_by resolves and applies" checked the row count after vacuum_sorted,
  which is 20000 whatever it sorted on: with the sort keys replaced by column 1 the
  suite stayed 36/36. It now counts (host, ts) inversions in scan order, which must be
  0, with a premise that the restored table has some before the call.

cluster_tests 677 -> 680 by collection; TESTS.md gets section 150.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mutations (#1341)

40 rows from five-major runs (40/40 on each of 15-19). Dated on PG18 by: options
(6) and projection declarations (8) no longer registered with
pg_extension_config_dump, rebuild_projections() skipping missing projections (7),
drop_projection keeping its declaration (8), and vacuum_sorted on the wrong column
(2). suites_not_covered 213 -> 212; the census is 3795 rows, 2778 never, 1017 dated, restated on main db56168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dex (#1341)

The shell's "the index still answers the same range" never touched the index:
right after pg_upgrade there are no planner statistics, the planner took the
columnar scan for the range even with seqscan off, and the arm passed with
c_id dropped after the upgrade. The arm and a new premise now run with the
custom scan off, and the premise asserts the plan scans c_id.

test_pg_upgrade.py carries all 16 checks by the shell's name. Like the shell it
needs two majors: --pg-config is the new one, PGC_UPGRADE_FROM_PG_CONFIG the
old one, whose extension it builds from a copy of the tree. The matrix runs one
major at a time, so there it declines, listed for 15-19 in
expected_unrunnable.txt. cluster_tests 680 -> 681, TESTS.md section 151.

The suite has its own check() and records nothing, so no ledger rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd names a refused step (#1341)

jdatcmd, reviewing #1461: under root, initdb refused ("cannot be run as root"),
_sh discarded the code, and the failure surfaced one step later as a
FileNotFoundError for postgresql.conf. initdb, pg_ctl and pg_upgrade now go
through _server_tool, which drops to postgres with runuser when euid is 0
(as pgc_cluster._asroot and lib.sh do), chowns the data directories, and
raises naming the step and its stderr. initdb passes -U postgres, and the
socket directory is the datadir, as pgc_cluster pins it. A failed make of the
old major's extension now shows its stderr too.

Measured as root (sudo, PG17 -> PG18): 585369d fails with that
FileNotFoundError, this commit is 16/16. As a normal user: 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD
OffgridwithJD force-pushed the test/1341-pg-upgrade-twin branch from 70c186e to d70e5d6 Compare October 16, 2026 05:01

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving d70e5d6, the restate onto main db56168. I compared every commit's patch with the approved stack using git range-diff. The test and source commits are unchanged, and the ledger-seed commits changed only by re-anchoring on #1470. The census at this head matches its budget paragraph, and the counts step consistently. At #1467's head: the guards pass (438), 687 collected (matching the file), and harness_selftest is 1245/1245.

@jdatcmd
jdatcmd merged commit 53f631f into main Oct 16, 2026
15 checks passed
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.

2 participants