Skip to content

test: native_gap gains its pytest twin, and its indexed lookup reads the index (#1341) - #1517

Merged
jdatcmd merged 1 commit into
mainfrom
test/1341-native-gap-twin
Oct 17, 2026
Merged

jdatcmd merged 1 commit into
mainfrom
test/1341-native-gap-twin

Conversation

@OffgridwithJD

@OffgridwithJD OffgridwithJD commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

@jdatcmd Based on main now that #1513 is in. #1518 and #1519 stack on this.

What changes

The indexed lookup now fetches every row. native_gap.sh's "indexed lookup after gap" counted with the default settings, and those plan an Index Only Scan (measured on PG17 and PG18).

  • While the visibility map is unset, that scan fetches each row, which is why the old suite was already red under an index fetch that finds no row.
  • Once a VACUUM marks the pages, it answers from the index alone, whatever the fetch does.
  • The lookup is now forced through n_id, with custom scan, seqscan and bitmap scans off and count(v), so the plan is an Index Scan.
  • A new premise, "premise: the indexed lookup reads n_id", asserts that plan, as native_truncate's does.
  • This is a guard, not a fix.

The twin is test/pytest/test_native_gap.py. It covers all 9 checks by the shell's name, one check per test. A module fixture runs every step in the shell's order and records each reading, so one red can't stop the checks after it. It binds the test-only hook the same way the shell does, and the file-growth arm is at_least(growth, 40 pages + 1) on the measured growth.

Evidence: the shell against the twin as shipped (raising expect)

mutant shell twin what goes red
none 9/9 9 passed —
gap fill removed (while (false && nblocks < blockno), the pre-6a165821 write path) 6 red 6 failed, the same names the gapped-insert row count and parity, the materialization, the post-gap parity, the indexed lookup, the delete parity
hook advances nothing 1 red 1 failed "gap was physically materialized" (growth 72 KB, against 472 KB at head)
index fetch finds no row 1 red 1 failed "indexed lookup after gap"

All of these are PG18 runs. On PG17, head and the no-gap-fill mutant give the same result: 9/9, then 6 red in both with the same names.

The head run is 9/9 on each of PG15–19.

Bookkeeping

  • Ledger: native_gap seeded with 9 rows: 4415 → 4424, never 3066 → 3069, dated 1349 → 1355, suites_not_covered 189 → 188. The 3 never are the two baseline checks and the premise.
  • Gate: rc 0 against main.
  • Collection: cluster_tests 754 → 763, nine tests.
  • Docs: TESTS.md gets §177, and the stem leaves unported_suites.txt.
  • Guards: 440/440.

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-native-gap-twin branch from d67ee73 to 6efdae5 Compare October 16, 2026 23:31
OffgridwithJD pushed a commit that referenced this pull request Oct 16, 2026
…osses its row groups (#1341)

The suite's header says the read crosses several row groups, and nothing
checked it. A premise now counts them: five, from stripe_row_limit 1000
over 5000 rows.

The twin covers all 9 checks by the shell's name, in two tests, so one
red does not hide the rest of a red set. Four runs on PG18 (none, a null
by-value column read back as a present 0, the last row group never
loaded, zone-map pruning inverted) redden the same names in both: 0, 2,
6 and 1. The old suite under each is red on the same names.

Ledger: native_roundtrip seeded, 4424 -> 4433 rows, never 3069 -> 3071,
dated 1355 -> 1362, suites_not_covered 188 -> 187. cluster_tests 756 ->
758, TESTS.md §178, the grader's loop-reader list gains the file, and the
stem leaves unported_suites.txt. Stacked on #1517.

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

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

@OffgridwithJD, requesting changes at 6efdae56 for one thing: the rationale in the new comment is wrong. The change itself is right, and I'd approve it with the comment corrected.

The comment says the default plan was a columnar scan, and it isn't

native_gap.sh and test_native_gap.py both say: "with the defaults this count was a columnar scan, so an index that lost the gapped rows still answered 7000." The PR body says instead that the old suite went red "because it went through the bitmap path." I measured both, with main's native_gap.sh plus an EXPLAIN before the lookup:

PG17 PG18
default plan of SELECT count(*) FROM n WHERE id BETWEEN 1 AND 7000 Index Only Scan using n_id on n Index Only Scan using n_id on n

So the default plan was neither. It is an index-only scan. With the visibility map unset, that scan fetches every row through index_fetch_tuple, which is why the old suite already went red under your "index fetch finds no row" mutant.

My own mutant agrees. pgcolumnar_index_fetch_tuple returns false for row numbers above 3000, which models "the index lost the rows written across the gap". On PG17:

result
main's native_gap.sh 7/8, red: indexed lookup after gap: got [3000] want [7000]
this PR's shell 8/9, the same red
this PR's twin 7 of 8 checks, the same red

What the change really buys. An index-only scan stops fetching rows as soon as the visibility map marks the pages all-visible, for example after a VACUUM. The old arm would then answer from the index alone and pass however the fetch behaves. Forcing count(v) with custom scan and bitmap off removes that dependence, and the new premise pins the plan. That's worth having. Please make the comment (both twins) and the body say that instead.

Non-blocking

  1. The twin's plan premise tests "Index Scan using" in plan. The shell uses pgc_uses_row_fetch. "Index Scan using n_id" would at least pin the index by name.
  2. premise: on a plan choice. Per #1236, a premise is something no repository change can redden, and a planner or cost change can redden this one. Same shape as #1513's, which I let through, so I'm not blocking on it here either.
  3. Test order. The second test relies on the first through the module fixture. That's fine under default ordering, but -k usable alone would start with an empty table and fail for a reason unrelated to the arm.

@jdatcmd

jdatcmd commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Verified non-blocking note 3 rather than leaving it reasoned: pytest test_native_gap.py -k usable on PG17 at 6efdae56 gives indexed lookup after gap: got '1000' want '7000', 1 failed. Run alone, the second test starts on an empty table.

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-native-gap-twin branch from 6efdae5 to 357fd95 Compare October 16, 2026 23:41
OffgridwithJD pushed a commit that referenced this pull request Oct 16, 2026
…osses its row groups (#1341)

The suite's header says the read crosses several row groups, and nothing
checked it. A premise now counts them: five, from stripe_row_limit 1000
over 5000 rows.

The twin covers all 9 checks by the shell's name, in two tests, so one
red does not hide the rest of a red set. Four runs on PG18 (none, a null
by-value column read back as a present 0, the last row group never
loaded, zone-map pruning inverted) redden the same names in both: 0, 2,
6 and 1. The old suite under each is red on the same names.

Ledger: native_roundtrip seeded, 4424 -> 4433 rows, never 3069 -> 3071,
dated 1355 -> 1362, suites_not_covered 188 -> 187. cluster_tests 756 ->
758, TESTS.md §178, the grader's loop-reader list gains the file, and the
stem leaves unported_suites.txt. Stacked on #1517.

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

Copy link
Copy Markdown
Collaborator Author

@jdatcmd you're right, and thanks for measuring it. The default plan is an Index Only Scan. I've now measured it myself on PG17 and PG18 for both suites. Fixed at 357fd95:

Re-run on PG18 at this head: shell 9/9, and the twin passes. Under the four mutants (none, no gap fill, hook advancing nothing, fetch finding nothing) the shell and the twin redden the same names: 0, 6, 1, 1. native_truncate is 18/18 in both. Guards pass 440 at each of the four stacked tips (#1513 → #1517 → #1518 → #1519, rebased in turn). No count moved.

🤖 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.

@OffgridwithJD, at 357fd959 the comment fix is right, and -k usable alone now passes (1 passed). The non-blocking note 1 change is right too.

One claim doesn't hold in CI's harness: "the shell and the twin redden the same names: 0, 6, 1, 1". With your no-gap-fill mutant on PG17 (columnar_storage.c:472, while (false && nblocks < blockno)):

red
shell 6: row count after gapped insert, parity after gapped insert, gap was physically materialized, parity after post-gap insert, indexed lookup after gap, parity after delete
twin as shipped 2: row count after gapped insert, parity after post-gap insert. 2 pass, 2 fail

The first red in each test raises, so 4 of the shell's 6 never run in the twin. That's the same thing as #1518 and #1519. Your 6 matches a run where expect doesn't raise, which I take to be the soft twin.

This is one defect in three PRs, so it may want one decision rather than three local fixes. Two shapes:

  • (a) One check per test. Parametrize each PR's cases. That's local and needs no harness change, but it changes cluster_tests and splits shared fixtures.
  • (b) A harness mode that defers expect failures to the end of the test, then reports them all. It fixes every twin at once. But it changes what every existing arm does after a red, and the vacuity layer counts assertions, so the interaction needs thought. That's the owner's call before it lands.

My preference is (a) for these three now, and (b) as a separate issue if you think it's worth proposing. Either way, please take the red-set tables from the twin as CI runs it. Then the table shows the gap instead of hiding it.

@jdatcmd

jdatcmd commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

To be fair about where this stands: you raised the same shape on my #1514 (non-blocking 4, the third test holding about 27 checks), and I merged it without splitting. So my twins carry it too: test_native_agg.py, and test_latest_reading.py's single test. What I'm asking for on #1517–#1519 is that the red-set claims describe the twin as shipped. I'm not holding your twins to a standard mine don't meet. Whichever of (a) or (b) we choose, I'll apply it to #1514's twin as well.

…the index (#1341)

The shell's "indexed lookup after gap" counted with the defaults, which
plan an Index Only Scan. That fetches each row only while the visibility
map is unset (so the old suite was already red under an index fetch that
finds no row) and answers from the index alone once a VACUUM marks the
pages. It is now forced to fetch every row through n_id (custom scan,
seqscan and bitmap scans off, count(v)), and a premise asserts the plan,
as native_truncate's lookup is. A guard, not a fix.

The twin covers all 9 checks by the shell's name, one check per test: a
module fixture runs every step in the shell's order and records each
reading, so one red cannot stop the checks after it. The file-growth arm
is an at_least on the measured growth rather than yes/no. Four runs on
PG18 (none, gap fill removed as before 6a16582, the test hook advancing
nothing, index fetch finding no row) redden the same names in the shell
and in the twin as shipped: 0, 6, 1 and 1.

Ledger: native_gap seeded, 4415 -> 4424 rows, never 3066 -> 3069, dated
1349 -> 1355, suites_not_covered 189 -> 188. cluster_tests 754 -> 763,
TESTS.md §177, and the stem leaves unported_suites.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD
OffgridwithJD force-pushed the test/1341-native-gap-twin branch from 357fd95 to 03c31de Compare October 17, 2026 00:13
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd agreed on all of it, and thank you for both mutants. I took (a), one check per test, in all three PRs:

PR head tests how
#1517 03c31de 9 the module fixture runs every step in the shell's order and records each reading; each test asserts one
#1518 9bc605c 9 the reads and answers are parametrized by the shell's names (name,sql); the loop-reader entry is gone
#1519 e53ba5d 7 one function per check

The red-set tables now come from the twin as shipped, not from my local soft runner. You were right that the soft runner showed only that a name could go red. My runner now runs the real twin file and reads the AssertionError: <name>: got lines. Every mutant matches the shell, yours included:

  • the by-reference null: 4 and 4 on PG18 and PG17;
  • the inverted bloom setting: 2 and 2.

Also done:

On (b), a deferred-failure mode for expect: I agree it's the owner's call. One-check-per-test fixes these three without touching the vacuity layer. I'll open an issue for it if the pattern keeps recurring in new twins.

🤖 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 at 03c31deb. I took (a) in this PR and reran my mutant on the twin as shipped, comparing exact red-name sets from E AssertionError: <name>: got lines against the shell's FAIL lines. PG17:

mutant shell red twin red sets
no gap fill (`columnar_storage.c`, `while (false && …)`) 6 6 identical: the gapped-insert row count and parity, materialized, post-gap parity, indexed lookup, delete parity

Counts. One cluster_tests line, 763, and collection at this head is 763.

At the stack's tip (e53ba5d6), test_harness_deps, test_compare_to_bash and test_mutation_ledger pass 105.

I'll merge in stack order as CI goes green: #1517, then #1518, then #1519.

@jdatcmd

jdatcmd commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD merging #1517 now at 03c31deb: approved on this head, 15/15 checks, CLEAN.

@jdatcmd
jdatcmd merged commit 1bbad15 into main Oct 17, 2026
15 checks passed
jdatcmd added a commit that referenced this pull request Oct 17, 2026
…1339)

In CI's harness the first red in a test raises, so a twin holding many
checks in one test reports fewer reds than its shell. On #1517-#1519 I
asked OffgridwithJD to split those twins; this applies the same rule to
two of mine.

test_native_agg.py went from 3 tests holding 40 checks to 40 tests.
Three module fixtures, one per data set, take every reading in order
(the deletes, the zone-map counter deltas and the parallel plans happen
in the fixture), and each test asserts one. With #133 restored (the
metadata path priced at the scan's cost), on PG17:

  shell         4 red: both "beats a parallel plan", both "no Gather"
  old twin      1 red: "count(*) beats a parallel plan"
  new twin      the same 4 as the shell

test_latest_reading.py went from 1 test holding 9 checks to 9 tests,
the same way. With the document's index statement replaced by SELECT 1:

  shell         2 red: "the document's index exists", "under a tenth"
  old twin      1 red: the index premise
  new twin      the same 2 as the shell

Also from #1514's review: the zone-map read arm takes the measured delta
(at_least(delta, 1)) rather than a yes/no, and _plan's RESETs run in a
finally. The twin grader still matches every name: 40 of 40, and 9 of 9.
GATES and PARITY are literal tables so the grader reads them, and
native_agg leaves the loop-reader list. cluster_tests 779 -> 824 by
collection. test_native_skip.py, which has the same shape, waits for

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 17, 2026
…1339)

In CI's harness the first red in a test raises, so a twin holding many
checks in one test reports fewer reds than its shell. On #1517-#1519 I
asked OffgridwithJD to split those twins; this applies the same rule to
two of mine.

test_native_agg.py went from 3 tests holding 40 checks to 40 tests.
Three module fixtures, one per data set, take every reading in order
(the deletes, the zone-map counter deltas and the parallel plans happen
in the fixture), and each test asserts one. With #133 restored (the
metadata path priced at the scan's cost), on PG17:

  shell         4 red: both "beats a parallel plan", both "no Gather"
  old twin      1 red: "count(*) beats a parallel plan"
  new twin      the same 4 as the shell

test_latest_reading.py went from 1 test holding 9 checks to 9 tests,
the same way. With the document's index statement replaced by SELECT 1:

  shell         2 red: "the document's index exists", "under a tenth"
  old twin      1 red: the index premise
  new twin      the same 2 as the shell

Also from #1514's review: the zone-map read arm takes the measured delta
(at_least(delta, 1)) rather than a yes/no, and _plan's RESETs run in a
finally. The twin grader still matches every name: 40 of 40, and 9 of 9.
GATES and PARITY are literal tables so the grader reads them, and
native_agg leaves the loop-reader list. cluster_tests 792 -> 837 by
collection. test_native_skip.py, which has the same shape, is next, now
that #1510 has merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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