Skip to content

test: rewrite_storage_oid covers every other rewriting form, and SELECT INTO (#1339) - #1751

Merged
jdatcmd merged 9 commits into
mainfrom
fix/1339-rewrite-forms
Oct 26, 2026
Merged

jdatcmd merged 9 commits into
mainfrom
fix/1339-rewrite-forms

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD, this closes one #1339 finding: test_rewrite_storage_oid.py:46 [weak], "missing coverage: other rewriting AlterTableStmt forms and SELECT INTO".

What changed

The suite rewrote with one ALTER form only (ALTER COLUMN TYPE bigint), plus a matview, CREATE TABLE AS and REFRESH. The repair gate takes any AlterTableStmt's relation, but no check said so. A gate narrowed to type changes passed every check.

Nine new checks, one per form, with identical names in test/rewrite_storage_oid.sh and test_rewrite_storage_oid.py. Each check runs the form on a fresh table and writes one more row. It then reads two values: whether pgcolumnar.storage.relation_oid equals the table's OID, and the row count. For TRUNCATE, the count is 1, read after the write that creates the new storage row.

SET UNLOGGED, VACUUM FULL and CLUSTER are refused today. Their checks hold that the refusal leaves the table as it was.

Per form

All mutations are in src/columnar_tableam.c and were run on PG17. "Main" means main's two test files run against the mutated tree.

finding / form mutation main shell / main twin fixed shell / fixed twin
ADD COLUMN with a volatile default M1: the gate takes the ALTER's relation only when its first subcommand is ALTER COLUMN TYPE 18/18 / 18/18 red by name / red by name
SET ACCESS METHOD heap and back M1 18/18 / 18/18 red by name / red by name
ALTER COLUMN TYPE varchar(20) M2: the gate takes no relation from any ALTER red on main's own type-rewrite checks (main already catches M2) red by name / red by name
VACUUM FULL M3: copy_for_cluster warns instead of refusing 18/18 / 18/18 red by name (1|1) / red by name
CLUSTER USING an index M3 18/18 / 18/18 red by name (1|1) / red by name
SELECT INTO M6: finish_bulk_insert forgets its buffered rows red on main's count checks (main already catches M6) red by name / red by name
SET UNLOGGED never. M4 (the unlogged refusal at set_new_filelocator becomes a WARNING) leaves it green, because the rewrite then goes through the same ALTER repair. M4 with M2 reddens it. 18/18 / 18/18 green / green
SET UNLOGGED then SET LOGGED never, same as SET UNLOGGED 18/18 / 18/18 green / green
TRUNCATE then a write never. M5 (set_new_filelocator reuses the old storage id) leaves it green. The new storage row is written under the live OID by the next write. 18/18 / 18/18 green / green

M2 and M6 also redden checks that main already has. Only M1 and M3 close a gap that main's own checks leave open. No mutation separates SELECT INTO from CREATE TABLE AS, because pgcolumnar has no code that reads is_select_into.

Clean runs and gates

  • The unmutated tree passes 27/27 in the shell and in the twin, on each of PG 15, 16, 17, 18 and 19.
  • compare_to_bash.py: "VERDICT: every bash property is covered" (23 literal matches, 1 template match).
  • Guards: 804 passed, accounting: 2045 pass + 0 fail.
  • Ledger gate: rc 0, "census stated 4140, ledger holds 4140: they agree", and the coverage ceiling stays at 75.
  • Registries: 9 ledger rows (6 dated, 3 never); census 4137 -> 4140 never, 2798 -> 2804 dated, 6944 rows; cluster_tests 3222 -> 3231, counted by collection; two new rows in TESTS.md section 82.

Re-checked by me before pushing: M1 (the repair gate keeps the relation only when the first subcommand is AT_AlterColumnType): main's shell 18/18 and twin 3 passed; this shell 25 + 2 failed, "a rewrite by ADD COLUMN with a volatile default …: got [0|701]" and "SET ACCESS METHOD heap and back …: got [0|701] want [1|701]"; the twin 2 failed. The binary under test matched the source (digest printed in the log).

🤖 Generated with Claude Code

@OffgridwithJD OffgridwithJD 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.

@jdatcmd Changes requested on #1751 at d7ccb655.

M1 and M3 reproduce exactly as the PR says, and the bookkeeping is right. But the new checks read only two things, the storage row's OID and the row count, after the statements have run with their errors ignored. Three mutants that main's tests also pass leave every new check green.

All runs are on PG17. Each mutant is built in my own worktree. "Main" means main's two test files run against the mutated tree.

Reproduced

mutant main shell / twin head shell / twin
head (no mutant) — 27/27 / 27/27
M1 (the gate takes the ALTER's relation only when the first subcommand is ALTER COLUMN TYPE) 18/18 / 18/18 2 red by name in both: ADD COLUMN and SET ACCESS METHOD, got [0|701] / got '0|651'
M3 (copy_for_cluster warns instead of refusing) 18/18 / 18/18 2 red by name in both: VACUUM FULL and CLUSTER, got [1|1] / got '1|1'

Blocking

1. A form that never runs passes as one that kept its row. form_rows and the twin's loop run each statement and print its error, but nothing reads the outcome. Mutant Mrefadd: pgcolumnar's utility hook refuses any AT_AddColumn with its own ERROR, so the ADD COLUMN rewrite never happens. Head stays at 27/27 in the shell and 27/27 in the twin. "a rewrite by ADD COLUMN with a volatile default keeps the storage row on the live table and every row" passes on a table that was never rewritten. The same holds for the type change and for SET ACCESS METHOD, if either is refused.

Fix: each statement's outcome becomes part of the reading. Forms that must rewrite must succeed and must show they rewrote: the table's storage id changed (or its relfilenode), the new column exists, and relam is pgcolumnar again after the round trip. For forms that must be refused, see 2.

2. A refused form passes when the backend crashes at the refusal. Mutant Mabort: abort() at the top of pgcolumnar_relation_copy_for_cluster.

  • Head's shell, as committed: VACUUM FULL and CLUSTER go red, got [|], but only because the follow-up INSERT runs while the server is still in recovery.
  • The same shell with up after the statement loop (it waits for the server, as a slower crash or a faster recovery would): 27/27, both PASS.
  • The twin: goes red, but not by name. Both tests end with psycopg.OperationalError: the connection is closed (2 failed, 2 errors), so they record nothing, and accounting reads 25 pass + 0 fail = 25.

abort() at the unlogged refusal (set_new_filelocator) behaves the same for SET UNLOGGED and SET UNLOGGED then SET LOGGED.

Fix: as in the rest of the corpus, a refusal needs its own SQLSTATE and message: 0A000 with "columnar: CLUSTER and VACUUM FULL are not supported yet" (REPACK on 19), and "unlogged columnar tables are not supported". Survival is the server log's "terminated by signal" count from the arm's start, not a reconnect. If you want these checks to keep meaning "the day it's allowed, the rewrite keeps the row", let the reading accept either outcome, the exact refusal or success with the row kept, but never a crash and never another error.

3. The values aren't read, only the count. Mutant Mval: in pgcolumnar_tuple_insert, inserts flagged TABLE_INSERT_SKIP_FSM (ATRewriteTable's and CTAS's) null the second column. Every rewrite then loses all of note but keeps every row. Head 27/27 / 27/27, and main 18/18 / 18/18. A rewrite that loses data is the failure these checks exist for.

Fix: read a content digest beside the count, e.g. md5(string_agg(id || ':' || coalesce(note, '∅'), ',' ORDER BY id)). Compare it with the same expression over the generator (generate_series plus the extra row), not with the table read back. For ADD COLUMN with random(), digest the original columns only.

Fine as is

  • The never rows (SET UNLOGGED, SET UNLOGGED then SET LOGGED, TRUNCATE then a write): no one-line pgcolumnar mutant reddens them. Your M4 + M2 is a two-line combination, so never without a premise: label is honest. TRUNCATE's expected 1 is the follow-up write's row, which is right.
  • Dating: ADD COLUMN and SET ACCESS METHOD by M1, VACUUM FULL and CLUSTER by M3, varchar(20) by M2, SELECT INTO by M6. All are product mutations, per the rules.
  • Bookkeeping:
    • 9 rows, 6 dated and 3 never, and the ledger is C-sorted.
    • The budget reads 6935 + 9 = 6944, 4137 + 3 = 4140, 2798 + 6 = 2804, which agrees with the tsv.
    • cluster_tests goes 3222 → 3231.
    • It merges cleanly with main 4adb2e7a.

Mutants and logs: rv1751/mut.py, rv1751/run.sh and rv1751/*.log in my scratch.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd force-pushed the fix/1339-rewrite-forms branch from d7ccb65 to 15b6e43 Compare October 22, 2026 18:42
@jdatcmd

jdatcmd commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 15b6e43 (rebased onto main 9ab6a4c), addressing all three blockers.

What each form check now reads, in order: survived (the server answers and the log's "terminated by signal" count has not moved; crashes()/survived() as in avro_manifest), outcome (ok or SQLSTATE message per statement), rewrote (where the form must rewrite), storage row and count, a content digest (md5(string_agg(id || ':' || coalesce(note,'∅'), ',' ORDER BY id)) against the same over generate_series plus the extra row), and what the form changed (ADD COLUMN: double precision 701; the type change: character varying(20); the AM round trip: relam).

Refused forms (SET UNLOGGED, SET UNLOGGED then SET LOGGED, VACUUM FULL, CLUSTER) accept exactly the 0A000 refusal with its message (PG19's own wording included) or success, both read as refused or ok. Anything else reads as itself.

Your mutants, PG17, old head -> new head:

mutant shell before twin before shell after twin after
Mrefadd 27 + 0 27 + 0 26 + 1 26 + 1, same name
Mabort (both sites) 23 + 4, got [|] 23 pass + 0 fail = 23, 4 errors 23 + 4, no ([1], 1 backend crashes) 23 pass + 4 fail = 27, same four names
Mval 27 + 0 27 + 0 23 + 4 23 + 4, same names

MabortU and MabortC alone each redden their own two checks by name in both. M1, M2, M3 and M6 still date their rows.

Ledger: SET UNLOGGED and SET UNLOGGED then SET LOGGED are now dated (MabortU reddens them by name); TRUNCATE stays never. Census 4138 never, 6944 rows; gate rc 0.

Clean: 27/27 shell and twin on PG15-19. Guards 804 passed. compare_to_bash: every bash property covered.

@OffgridwithJD OffgridwithJD 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.

@jdatcmd Approved at 15b6e430. All three of my blockers are closed. Sorry this took until today: my watch missed your push on the 22nd.

I re-ran my own mutants from the d7ccb65 review against this head, on PG17, in my own prefix copy:

mutant shell twin
clean 27/27 27/27
Mrefadd (pgcolumnar refuses ADD COLUMN) 26 + 1: a rewrite by ADD COLUMN …: got [1|0A000 mutant: ADD COLUMN refused|0|…] 26 + 1, same name
Mabort (abort() at the VACUUM FULL / CLUSTER refusal) 25 + 2: VACUUM FULL and CLUSTER, no ([1], 1 backend crashes) 25 + 2, same names
Mabortunlog (abort() at the unlogged refusal) 25 + 2: SET UNLOGGED and SET UNLOGGED then SET LOGGED, 1 backend crashes 25 + 2, same names
Mval (rewrite inserts null the second column) 23 + 4: ADD COLUMN, varchar(20), SET ACCESS METHOD and SELECT INTO, digest 0 23 + 4, same names
your M1 25 + 2: ADD COLUMN, SET ACCESS METHOD same
your M3 25 + 2: VACUUM FULL, CLUSTER same

Clean on PG15 and PG19 too: 27/27 in both harnesses.

Ledger: the tsv is sorted, and its census (6944 rows, 4138 never) matches the budget. Dating SET UNLOGGED and SET LOGGED with MabortU is right now that the crash reddens them by name.

Restate needed: this conflicts with current main 4c511f38, since my chain merged after your rebase. That only needs a census restate, and I'll re-approve one whose content is unchanged.

🤖 Generated with Claude Code

jdatcmd and others added 9 commits October 26, 2026 17:53
…ostgreSQL's casts (#1706)

Item 2 of #1706, decided there as option (a). The reader skipped the
TimestampType's isAdjustedToUTC flag and returned the raw microseconds for
either target type. In a New York session a naive (wall-time) column read
into timestamptz came back as 08:00-04 instead of 12:00-04, and an adjusted
column read into timestamp kept its UTC wall time instead of 08:00.

The reader now keeps the flag (the legacy TIMESTAMP_* converted types are
defined as adjusted) and, in those two mixed cases only, converts as
timestamp_timestamptz and timestamptz_timestamp do, in the session zone. It
is written from timestamp2tm, DetermineTimeZoneOffset and tm2timestamp, so it
is the same on every major: PG19 renamed the exported helper, and PG15-18
export none for timestamptz -> timestamp. Same-kind reads, and any read in a
UTC session, are unchanged.

A wall time is not monotonic in its instant across a spring-forward gap: in
New York on 2024-03-10, 02:00, 02:30 and 03:00 become 07:00Z, 07:30Z and
07:00Z. Converted row-group statistics need not bound the converted rows, so
the foreign table no longer skips row groups of a column read this way.
Measured: with skipping left on, `t > '07:15Z'` over such a group returned 0
rows instead of 1.

The Iceberg foreign table's year(), month(), day() and hour() pruning
compared a constant with partition cells the writer computed in the source
field's own kind, while the reader now converts a mixed-kind column's rows.
PgColumnarIcebergTemporalMap now leaves out a field whose Iceberg source type
is the other timestamp kind than its column, so nothing prunes on it.
Measured on PG17 before the guard, in New York: byhour declared timestamptz
with `ts >= '2021-03-01 06:00+00'` returned no rows where iceberg_scan
returned 2,3, and byhour_tz declared timestamp with
`tt <= '2021-02-28 20:00'` returned none where it returned 1,2.

#1707 (merged) is a prerequisite: with the reader honouring the flag and the
old exporter marking timestamp adjusted, a timestamp exported and re-imported
in a New York session came back 08:00 (measured). An arm pins that round trip.

New suite test/parquet_timestamp_zone.sh and its twin, 16 checks, including
the naive file read in a second zone (Tokyo), the repeated hour of the
2024-11-03 fall-back, and test/fixtures/parquet/legacy_timestamp_micros.parquet,
written by main's exporter before #1707 (0c5e924) with only the
TIMESTAMP_MICROS converted type. Two arms in test/iceberg_fdw.sh and its twin.
docs/user-guide.md states the rule; CHANGELOG under Changed.

Measured on PG17:
- Red, the head before the Iceberg guard: both new iceberg_fdw checks, in
  shell and twin ("got [] want [2,3]", "got [] want [1,2]").
- Mutants, the same names in shell and twin: the flag ignored (6), no
  instant-to-wall shift (2), skipping on converted stats (1), main's exporter
  before #1707 (the round trip, 1), the conversion pinned to New York (the
  Tokyo pair, 2), legacy converted types read as naive (the legacy pair, 2),
  the zone offset taken at the wall time read as UTC (the fall-back hour and
  the two gap checks, 3), no Iceberg kind guard (2).
- Green: parquet_timestamp_zone 16/16 and iceberg_fdw 76/76, shell; 16/16 and
  80/80, twin; on each of PG15 to PG19.
- Without pyarrow the twin declines by the shell's skip name.
- docs_style 53/53, harness_selftest 1248/1248, shellcheck clean.

Ledger: 6951 rows, 2811 dated by the mutations and 4140 never.
suites_not_covered 75 -> 75 (a new suite, registered and covered together;
iceberg_fdw is not yet seeded, so its two new checks add no rows),
cluster_tests 3222 -> 3240, TESTS.md section 296. The ledger gate, guards
(804 passed), name grading and the self-test pass over 9ab6a4c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e operator family (#1712)

The foreign table's group-exclusion check refused any constant whose type
was not the column's. A bare integer literal is int4, so the most ordinary
filter on a bigint column, `WHERE id > 150000`, read every row group; only
`150000::int8` skipped. Answers were correct; reads were not saved.

The check now compares the statistics with the constant through the
operator family's comparison function for (column type, constant type), as
the native zone-map path already did (OffgridwithJD's measurement on
#1712). A family with no such function means no skip. Two details the old
code would have got wrong for a cross-type constant: the NaN check read the
constant as the column's float type (it now reads it by its own type), and
equality called the comparison with the constant first (the column's value
is now always the first argument).

New suite test/parquet_skip_cross_type.sh and its twin, 9 checks, over a
200,000-row file in 4 row groups: int4 constants on an int8 column (>, =,
and on the left), a negative float4 constant on a float8 column, the
answers against the same-type query, and a float4 NaN that must still find
the NaN row its group's statistics leave out.

parquet_export_stats measured the old refusal in six arms ("a cross-type
constant skips nothing", and five like it). Those assertions are now wrong
by design: each is renamed for what it asserts, the skip its same-type twin
gets, keeps its ledger row, and is re-dated by the pre-#1712 reader, which
fails exactly those six in both harnesses. Three soundness arms are added
for the changed predicates that had none ([i8 < 5::int], [i8 < 5],
[f4 < 0.4]); every oracle arm passes. docs/limitations.md states the new
rule.

Measured on PG17:
- Red: the pre-#1712 reader fails the four cross-type skip arms of the new
  suite, in shell and twin.
- Green: 9/9 shell and twin on each of PG15 to PG19; parquet_export_stats
  165/165 shell and twin on each of PG15 to PG19.
- Mutants, the same names in shell and twin: the comparison called with the
  constant first (4, the same-type control among them); the column type's
  own function used for a constant of another type (the negative float4
  arm); the NaN constant read as the column's type (the NaN arm).
- 47 parquet, iceberg and arrow suites on PG17: only parquet_export_stats's
  six refusal arms failed, before they were rewritten; 1044 checks.
- docs_style passes. Guards 476 passed. cluster_tests 2763 -> 2775
  (collected 2775). Ledger 6180 + 12 = 6192 rows, 3886 -> 3892 never,
  2294 -> 2300 dated; the gate passes, ceiling 102.
- Rebased onto #1736 at a93f14b, the registry re-derived there:
  cluster_tests 3240 -> 3252 (collected 3252). Ledger 6951 + 12 = 6963
  rows, 4140 -> 4146 never, 2811 -> 2817 dated. The gate is run on the
  commit after this one.

Stacked on #1736 (and #1707), which touch the same function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…amptz constant (#1712)

OffgridwithJD's review of #1737 measured a wrong answer. For a timestamp
column and a timestamptz constant, timestamp_cmp_timestamptz turns each
statistic, a wall time, into an instant in the session zone, and that is
not monotonic across a spring-forward gap. In New York a row group holding
2024-03-10 02:00, 02:30 and 03:00 has a converted max of 07:00Z but holds
02:30, which is 07:30Z. So `ts > '2024-03-10 07:15+00'::timestamptz`
skipped the group and returned no row; a heap copy returns row 1. The same
held for >= 07:15Z and = 07:30Z.

The group-exclusion check now refuses that pair before choosing the
comparison function. A timestamp constant on a timestamp column still
skips, and a timestamptz column with a timestamp constant is unchanged.
docs/limitations.md and the CHANGELOG entry say so.

parquet_skip_cross_type and its twin gain 7 checks over a naive timestamp
file in 2 row groups, every session in New York: the three predicates
against the heap copy, a same-type control that still skips 1 group, its
answer against the heap, and two premises.

Measured:
- Red: on the rebased head without the guard, PG17, the >, >= and = arms
  fail by the same names in shell (13 passed + 3 failed = 16) and twin
  (3 failed, 13 passed), each "got none want 1".
- Mutant: deleting the guard line reddens the same three names in both
  harnesses, which dates their ledger rows.
- Green: parquet_skip_cross_type 16/16 and parquet_export_stats 165/165,
  shell and twin, on each of PG15 to PG19. parquet_timestamp_zone 16/16
  shell and twin on PG17.
- Guards 804 passed. docs_style passes. cluster_tests 3252 -> 3259
  (collected 3259). Ledger 6963 + 7 = 6970 rows, 4146 -> 4150 never,
  2817 -> 2820 dated; the gate passes against origin/main, ceiling 75.
  compare_to_bash: every bash property is covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er collide (#1742)

A twin that starts its own S3 or HTTP fixture server takes the port from
pick_port(slot) in a module fixture. Under -n, a module's tests are split across
workers, so every worker runs that fixture. The port depended on the slot only, so
two workers probed the same free port at the same moment and the second bind failed
with "Address already in use" (#1635, #1714, #1721). CI runs the cluster files
serially, so CI stayed green; the guard job and developers run -n.

pick_port now starts each worker in its own block of 64 ports (WORKER_STRIDE), with
the slot as an offset inside it. The worker comes from PYTEST_XDIST_WORKER, so no
fixture changes. Slot 0 is the worker's own cluster, which make_cluster now asks for
through the same function. A slot that does not fit in a block is refused. The walk
on a busy port, the AUX band and the ephemeral-floor contract are unchanged.

test_runshape.py gains test_two_workers_taking_a_fixture_port_do_not_collide: an
inner -n 2 run where each worker takes pick_port(27), waits until the other has a
port, then binds it. On main it is red: "the two workers were given two different
ports: got 1 want 2", and the inner bind fails with [Errno 98] Address already in
use. With the fix it is green.

Measured on PG17 (a copy of the prefix):
- test_iceberg_objstore.py under -n 2: main 3 of 3 red ("premise: sigv4 fixture
  server is up: got '0' want '1'"; "Connection refused"); fixed 16 passed 3 of 3,
  and 16 passed serially.
- test_objstore_listing.py from #1714 under -n 2: as is, 13 failed ("the private
  cluster did not start"); with this pgc_cluster.py, 15 passed 3 of 3 and serially.
- test_connection.py and test_hardening.py under -n 2: 20 passed.

guard_tests 476 -> 477, by collection over NO_CLUSTER; TESTS.md section 10 gains a
row. The guards (477), test_harness_deps.py and test_capability_decline.py with
--pg-config pass over 21182d0.

Review (OffgridwithJD): a 64-port block holds slots 0 to 31, and main's twins
already ask for 32 to 35 (the stack for up to 41), so slot 32 raised at fixture
time: test_iceberg_rest.py "14 errors", ValueError "slot 32 does not fit in a
worker's block of 64 ports". WORKER_STRIDE is now 96 (slots 0 to 47; the AUX band's
2000 ports still give 20 workers). Two guards keep the stride and the corpus from
drifting apart, red first on the 64 stride: every literal pick_port slot fits a
worker's block (it named the 32-35 files), and the corpus's highest slot (35) gets a
port in the band. Green: test_runshape.py 18 passed; the five twins that errored
pass serially (14, 11, 29, 10, 8); iceberg_rest + iceberg_objstore under -n 2, 30
passed; guards 807 passed on main 9ab6a4c (guard_tests 804 -> 807).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le, pinned reader, and a crash no longer passes as a refusal (#1341)

test/pytest/test_parquet_export.py ports all 14 checks of
test/parquet_export.sh by name. Five kinds of shell check could pass with
nothing measured, or vanish; all are fixed in both twins.

- "reject non-columnar table" and "reject unsupported type" passed on any
  error. With the columnar check removed, exporting a heap failed on
  "unsupported columnar format version" and passed; with the type
  refusal formatting the typmod, it failed on "cache lookup failed" and
  passed. Each refusal now needs its own message.
- "parquet values match heap oracle" and "extended values match heap
  oracle" agreed on two empty readings: with the generator's rows never
  inserted, both passed. They now need the generator's ids on both sides
  and the file's row count.
- "duckdb non-null count(b)" compared DuckDB's count with t_col, the
  table under test: with half of t_col deleted before the export, it
  passed. It is now held to the generator's 80002, by check_num.
- Without the duckdb CLI the shell left its four DuckDB checks out
  without a word (10 = 10). It now skips them by name (10 passed + 4
  skipped = 14). A skip, not check_unrunnable: the suites job installs no
  DuckDB CLI, and an INCOMPLETE suite fails its major there.

DuckDB in the twin, as decided on #1341 (option 1 with option 2's
mechanism): the duckdb Python package is pinned at 1.5.6 in
requirements-test.txt; duckdb joins pyarrow in DECLINABLE_CAPABILITIES,
held by four new tests in test_capability_decline.py, written red first;
and the cluster job sets PGC_REQUIRE=pyarrow,duckdb. The twin reaches
duckdb only through require_module, under the shell's check names.

Measured, PG17, shell and twin:
- duckdb absent: the four DuckDB tests decline by name and the run is
  green (10 pass + 4 declined); with PGC_REQUIRE=duckdb the same four fail.
- the columnar check removed, and the type refusal formatting the
  typmod: the refusal red in both; main's shell passed 14 of 14.
- the generator's rows never inserted: 8 red in both; main's shell 5.
- half of t_col deleted: 4 red in both; main's shell 3.
- the type check removed (the backend aborts): the refusal red in both,
  with the same 5 collateral reds in both.
- nine more mutations of src/columnar_parquet.c: each red set identical
  in both twins and in main's shell. DECIMAL written little-endian and a
  negative int4 written as null are red in both readers, pyarrow and
  DuckDB; row-group num_rows one high is red in DuckDB only.
- clean: 14 of 14 on PG15 to PG19 in both twins.

Ledger: 14 rows, 14 dated by the 11 mutations and 0 never.
suites_not_covered 75 -> 74, cluster_tests 3222 -> 3236, guard_tests 804 -> 808, TESTS.md
section 296. The ledger gate, guards (808), name grading and the self-test
pass over 4adb2e7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ails no longer reads as a fast one (#1341)

Ports test/planner_choice_quality.sh to test/pytest/test_planner_choice_quality.py,
all 11 checks by the shell's name, one test per check, on a private database with
serial planning as the shell's cluster runs. Each arm's EXPLAIN, warming run and timed
run share one session, so the plan named is the plan timed.

Two holes in the shell, each measured on PG17 against main and fixed in both twins:

- psql prints "Time:" after a statement that errored, and run_plan only looked for a
  timeout. So a chosen plan that raised an error was read as a fast plan. With
  PgColumnarBeginCustomScan raising at execution, or PgColumnarBeginGroupAggScan,
  main's shell PASSED every check (the grouped query "chose Custom Scan=1.911ms",
  0.01x). run_plan now reads psql's exit status under ON_ERROR_STOP, and tells the
  statement-timeout cancel apart by its own message. A new check, "[$label] the chosen
  plan ran to completion", fails by name; a forced alternative that fails fails the
  ratio check as "nothing to compare" instead of standing in as the fastest plan (main
  read it as 15.94x over the bound).
- The ratio check's name carried the bound and the node that won the comparison, so
  it was "... alternative (Index Scan)" when green and "... alternative (Custom Scan
  (PgColumnarScan))" when red: a red no green run had named. The names now carry only
  the query's label, and the values are in the display line.

"forcing produced a different plan" loses its premise prefix (#1339): with
pgcolumnar.enable_custom_scan ignored by the planner, it is the check that goes red.

PGC_SKIP_TIMING: CI's suites job sets it, and run_all_versions.sh does not run the
suite at all under it (is_timing_suite), which is why CI reports it skipped. Run
directly under the flag, the shell builds 20,000 rows, runs the plan-shape checks and
records the two measured checks per query as SKIP. The twin reads the same flag: it
builds the same fixture, and declares the same two checks per query unrunnable under
the shell's names, so the session ends INCOMPLETE (67), never green.

Five mutations on PG17, each reddening the same names in shell and twin:
scan100x (refined scan cost x100, the planner picks the index scan; 2 ratio checks,
23.3x-25.4x over three shell runs and 23.6x-36.7x over three twin runs), scanerrors 2,
fetcherrors 2, gucignored 2, groupaggerrors 2. Clean, the ratio is 0.02x-0.05x on
every run. 11/11 in shell and twin on each of 15-19.

Ledger: 11 rows, 6 dated by the 5 mutations and 5 never.
suites_not_covered 75 -> 74, cluster_tests 3222 -> 3233, TESTS.md
section 296. The ledger gate, guards (804), name grading and the self-test
pass over 4adb2e7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s left open (#1743)

A worker of parallel_export_parquet learns its multipart upload id only
from the answer to CreateMultipartUpload (POST ?uploads), and the store
creates the upload when the request arrives. A worker that ended while it
waited for that answer left an upload open that nothing could abort.
#1727's exit callback (in this base) aborts an upload a terminated worker
holds the id of; it cannot help a worker that never got the id.

The dispatcher's cleanup now lists the uploads under the export's prefix
(ListMultipartUploads) and aborts each one whose key is exactly one of the
export's part names, part-0000.parquet to part-(nkeys-1).parquet. Any other
key under the prefix is left alone.

- On a cancel or other error the aborts run inside #1744's
  PgColumnarCleanupAndRethrow, in the cleanup callback after
  pexport_cleanup. The helper has copied the handled error out and emptied
  the stack, so a second cancel that lands in a part DELETE cuts short only
  that DELETE: the aborts still run and the first cancel is re-thrown. They
  also run after a worker failed (for example, the workers were terminated).
- Interrupts are held only around bounded waits: 5 s on a timed latch for
  the terminated workers to stop, then 10 s for the listing and aborts.
- New module entries list_uploads and abort_upload, which take a timeout
  that sets a deadline on the handle; ABI 5 -> 6.
- os_wait: this builds on #1727's WL_TIMEOUT (4dc63aa, in this base through
  #1744's stack, not on main). It keeps WL_TIMEOUT for every wait and cuts
  the slice to the time left when a handle has a deadline. The first
  version's comment "os_wait passes no WL_TIMEOUT" is gone; this hunk
  depends on #1727 landing.
- The fixture server already wrote __open_uploads__ (#1721) and had
  __delete_delay__ (#1744). One copy of each is kept; the __open_uploads__
  writer is the module function _write_open_uploads, which --seed-upload
  also uses.

Review of the first version (80ee33e): a second cancel during
pexport_remove_outputs' part DELETEs emptied the error stack (#1741), the
first version's outer PG_CATCH then raised "errstart was not called", and
the aborts never ran. That outer PG_TRY, its copy-flush-rethrow helper and
pexport_spawn_and_wait are removed.

Test: new suite objstore_export_open_uploads and its twin, 30 checks each,
identical names. The fixture server gains ListMultipartUploads, delay hooks
on POST ?uploads, part PUTs and the listing, and --seed-upload. Five arms:
cancel while parts are sent; cancel while every CreateMultipartUpload is
held; the workers terminated there; cancel there, then cancel again once
the first part DELETE has arrived (__delete_delay__ 3 s); and cancel there
while the listing is held. The double-cancel arm was red first on the
rebased first version, PG17, shell and twin alike: "XX000: errstart was not
called", one more errstart line in the server log, and 4 uploads left open.
With the rework: 30/30 on PG15-19, shell and twin.

Ledger: 30 rows, 12 dated by four mutations that redden the same names in
both twins on PG17 (no listing; every listed key aborted; the part match
off by one; the cleanup run with the handled error on the stack), 18 never.
"parts: no upload of this export is left open" is never: on this base
#1727 also closes it, so no single mutation reddens it. never 4488 -> 4506,
dated 3260 -> 3272, rows 7748 -> 7778; suites_not_covered stays 34;
cluster_tests 4027 -> 4057; TESTS.md section 338.

Restated onto main 4500efd, stacked on #1752. main's #1745 added a test
named test_double_cancel_reports_the_first_cancel to test_parallel_flush_optin.py,
so the corpus defined that name twice and two guards in
test_docs_cover_the_corpus.py failed. This twin's test is renamed
test_double_cancel_reports_the_exports_own_cancel, with its TESTS.md row; the
check name it records is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e able to fail (#1339)

Each finding was checked red-first on PG17: the mutation was applied to the
tree at main, and main's shell suite and pytest twin both stayed green. After
the fix, the same mutation reddens the named check in both twins.

#1339 test_native_index_fetch_stripe_cost.py:91 (inaccurate name). "the
index-fetch cost responds to the per-table stripe_row_limit (c1 is a number)"
read one cost, so it could not see a response. Renamed in both twins to "the
index-fetch cost is a number of at least 1". The shell twin now asks for >= 1,
as the pytest twin did; it asked only for digits. Mutation: the penalised
path's total cost set to 0 (columnar_customscan.c). Main: shell green ("0.00"
is digits), twin red. Fixed: both red on the new name.

#1339 test_native_reclaim_frag.py:102 (weak). "never larger" stayed green with
the split on reuse switched off (`if (pgcolumnar_reclaim_coalesce)` around the
remnant insert made `if (false)`, columnar_metadata.c): the sizes stayed 262144
and 286720. New check in both twins: "coalesce on: reuse splits an oversized
freed range, so no byte between ranges is lost". It lays out the live groups
and the free ranges by offset, and asks for no gaps. Main: green in both.
Fixed: red in both, with "1 gaps of 122520 bytes".

#1339 test_objstore_endpoint_userinfo.py:101 (weak). The check looked for the
word "endpoint", which is in the message's own text. Mutation: errmsg quotes
`url` instead of `ep`. Main: 5 passed. Fixed: the check asks for the quoted
endpoint and no s3:// URL, and goes red for both endpoints.

#1339 test_objstore_endpoint_userinfo.py:120 (weak). "no region is
configured" was assumed. Mutation: AWS_REGION set in the server environment,
and the endpoint guard moved below the region demand. Main: 5 passed. New
check: "no region is configured: a clean endpoint here is refused for want of
one". Fixed: red on that name. No shell twin.

#1339 test_parallel_scan_cost.py:73 (twin mismatch). The pytest twin took any
custom scan; the shell twin took only PgColumnarScan. The pytest twin now
checks "Custom Plan Provider". Mutation: the scan's CustomScanMethods
CustomName changed to PgColumnarScanX. Main: shell red, twin green. Fixed:
both red on "the serial plan is a columnar scan". The twin stops at that first
failure, so "the parallel plan is a columnar scan" and "the leader-on plan is
still a parallel columnar scan" go red by name in the shell twin only.

#1339 test_parallel_scan_cost.py:83 (mislabelled). Five checks lose their
`premise: ` prefix in both twins: the serial, parallel and leader-on
"columnar scan" checks, "has Gather" and "uses two workers". A one-line
change reddens each one: the CustomName above; the partial path planned with
parallel_workers = 1 ("has Gather" red in both twins); and with
workers + 1 ("uses two workers" red in both, got 3).

#1339 test_partition_lifecycle.py:77 (weak, NULL halves only). No arm called
partition_create_ahead with a NULL width or lead. Mutation: the guard without
`width IS NULL` and `ahead IS NULL` (pgcolumnar--1.0-alpha5.sql). Main: green
in both twins. New check in both twins: "a NULL width or a NULL lead is
refused before any DDL". Fixed: red in both, got "ok: 0|ok: 0|0".

#1339 test_pgxn_metadata.py:117 (weak). The check accepted any shipped
script. Mutation: META.json names pgcolumnar--1.0-alpha4--1.0-alpha5.sql.
Main: green, and green in docs_style.sh too. New check in the pytest file and
in docs_style.sh: "META.json names the base install script of the version
VERSION holds". Fixed: red in both.

Counts: check_ledger.tsv rows 6149 + 1 = 6150, never 3878 - 5 = 3873, dated
2271 + 6 = 2277. native_index_fetch_stripe_cost, native_reclaim_frag and
docs_style have no ledger rows. Test collection does not change, so
expected_tests.txt does not change.

Gates: the four touched shell suites and six pytest files are clean on PG15,
16, 17, 18 and 19 (50 runs). docs_style.sh: PASSED. Guards: 476 passed,
1660 pass + 0 fail. compare_to_bash: "every bash property is covered" for all
four pairs. Ledger gate (PG16 and PG18 logs, each suite): census 3873 agrees,
ceiling 103 -> 103.

Restated onto main 9ab6a4c. docs_style.sh gained a pytest twin (#1341) after this
commit was written, so the twin now carries the same check by the same name:
test_meta_json_names_the_base_script_of_the_version. META.json naming
pgcolumnar--1.0-alpha4--1.0-alpha5.sql reddens it (1 failed, 53 passed); the tree
passes (54 passed). guard_tests 804 -> 805; TESTS.md lists the test.

Restated again onto main c7f0de4, with the ledger row OffgridwithJD asked for:
docs_style "META.json names the base install script of the version VERSION holds",
15;16;17;18;19, dated 2026-10-22 by META.json naming the alpha4 -> alpha5 upgrade
script. Census on this base: rows 7144 + 2 = 7146, never 4192 - 5 = 4187, dated
2952 + 7 = 2959. guard_tests 806 -> 807 (main already counts the two #1735 added).
Guards: 807 passed. parallel_scan_cost 13/13, partition_lifecycle 65/65 and
docs_style 54/54 on PG16 and PG18; the ledger gate over those six logs exits 0,
census 4187 agreeing, ceiling 69 -> 69.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CT INTO (#1339)

#1339 test_rewrite_storage_oid.py:46 [weak]: the suite rewrote with one ALTER form
only (ALTER COLUMN TYPE bigint), plus a matview, CREATE TABLE AS and REFRESH. The
repair gate in pgcolumnar_process_utility takes any AlterTableStmt's relation, but no
check said so: a gate narrowed to type changes passed every check.

Nine new checks, one per form, with the same names in test/rewrite_storage_oid.sh and
test_rewrite_storage_oid.py: ADD COLUMN with a volatile default, ALTER COLUMN TYPE
varchar(20), SET ACCESS METHOD heap and back, SET UNLOGGED, SET UNLOGGED then SET
LOGGED, VACUUM FULL, CLUSTER USING an index, TRUNCATE, and SELECT INTO with
default_table_access_method = pgcolumnar. Each runs the form on a fresh table, writes
one more row, and reads one value:

- survived: the server answers and its log gained no "terminated by signal" line
  since the arm started (crashes()/survived() as in avro_manifest.sh and its twin);
- each statement's outcome, "ok" or "SQLSTATE message";
- the storage row against the table's OID, and the row count;
- md5(string_agg(id || ':' || coalesce(note, '∅'), ',' ORDER BY id)) against the same
  expression over generate_series plus the extra row, so a lost value is red.

A form that must rewrite must succeed, change the table's storage id, and show what
it changed: the new column filled for every row, the type varchar(20), relam
pgcolumnar after the round trip. A form refused today (SET UNLOGGED, both of its
forms, VACUUM FULL, CLUSTER) accepts two outcomes per statement: the exact refusal
(0A000 with its message; on 19, "REPACK, CLUSTER and VACUUM FULL"), or success.
Either way the table must keep every row and value; a crash or another error is red.

The twin reads everything on fresh connections in a module fixture, with a failed
read as a query_error(), so a backend that dies is recorded by name, not lost to
OperationalError.

Mutations in src/columnar_tableam.c on PG17. "Before" is the first version of these
checks (head d7ccb65, rebased); "after" is this commit:

- Mrefadd, the utility hook refuses AT_AddColumn: before 27/27 in shell and twin;
  after, ADD COLUMN fails by name in both.
- Mabort, abort() at the top of copy_for_cluster and at the unlogged refusal: before,
  the shell failed the four refused forms only because the next INSERT met a
  recovering server, and the twin ended in OperationalError (23 pass + 0 fail,
  4 errors); after, all four fail by name in both, "no ([1], 1 backend crashes)".
  Each site alone (MabortU, MabortC) fails its own two forms by name in both.
- Mval, inserts flagged TABLE_INSERT_SKIP_FSM null the second column: before 27/27
  in both; after, ADD COLUMN, varchar(20), SET ACCESS METHOD and SELECT INTO fail
  by name in both (digest 0).
- M1 (the gate takes the ALTER's relation only for a first ALTER COLUMN TYPE), M2
  (the gate takes no ALTER relation), M3 (copy_for_cluster warns instead of
  refusing) and M6 (finish_bulk_insert forgets its buffered rows) still fail the
  same new checks by name in both, as before.

One check stays never: TRUNCATE. set_new_filelocator gives the live table a new
storage id and the next write creates the row under the live OID; no one-line
mutation tried reddens it. SET UNLOGGED and SET UNLOGGED then SET LOGGED, never in
the first version, are now dated by MabortU.

The tree is 27/27 in shell and twin on each of 15-19. compare_to_bash: every bash
property is covered. Guards 804 passed. Ledger gate rc 0.

Ledger: 9 rows, 8 dated, 1 never. Census 4137 -> 4138 never, 2798 -> 2806 dated,
6944 rows; cluster_tests 3222 -> 3231 by collection; TESTS.md section 82.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd
jdatcmd force-pushed the fix/1339-rewrite-forms branch from 15b6e43 to e0f515b Compare October 26, 2026 17:55
@jdatcmd

jdatcmd commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Restated onto main 4500efd as e0f515b, in one linear stack (this PR sits on #1679), so these eight merge without voiding each other. Every non-registry file carries the same change as before; the registries are re-derived on the new base, and each census agrees with a recount. Guards, the PR's own shell suites on PG16 and PG18, its twins on PG17, compare_to_bash and the ledger gate all pass at this head. Re-approval needed.

@OffgridwithJD OffgridwithJD 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.

@jdatcmd Re-approved at e0f515ba, restated onto main 4500efdd in the linear stack. Outside the ledger, the budget, expected_tests and TESTS.md, the commit's diff matches the approved 15b6e430. The tsv is sorted, and its census (8081/4567/3514 rows/never/dated) matches the budget.

🤖 Generated with Claude Code

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