Skip to content

test: the visibility map is cleared wherever LIVE rows are renumbered (#877) - #878

Merged
jdatcmd merged 2 commits into
mainfrom
test/877-vm-clear-on-renumber
Sep 2, 2026
Merged

test: the visibility map is cleared wherever LIVE rows are renumbered (#877)#878
jdatcmd merged 2 commits into
mainfrom
test/877-vm-clear-on-renumber

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Closes the coverage half of #877. This fixes nothing — the status of the two clears is
uncovered, not defective, and whether a defect exists behind them is still unknown.

What was uncovered

Three paths retire a row group and give its live rows new row numbers, and each must clear the
all-visible bits over the OLD numbers, or an index-only scan answers from the index for a TID
whose group no longer exists.

site reached through held by
src/columnar_vacuum.c:2310 pgcolumnar_expire pgcolumnar.expire ttl_expire.sh
src/columnar_vacuum.c:346 rewrite_one_group pgcolumnar.compact_rewrite nothing
src/columnar_vacuum.c:765 pgcolumnar_recluster_online pgcolumnar.recluster nothing

Only expire's was covered, because only its absence had been reported as data loss. Deleting
the other two left 319 checks across 14 suites green.

Removal proof

Same suite file in all three trees (md5 aac1b069efd0), one .so per arm
(03519208e42e / deec9abbfaed / e18a028e176c), each mutation asserted applied before
building — remaining call count 3 → 2 with exactly one marker present:

tree passed failed which arms
unmutated 17 0
:346 neutered 16 1 rewrite_one_group's arm only
:765 neutered 15 2 recluster_online's arms only

Neither mutation reddens the other's arms. Every arm is paired with a control — an untouched
group for the rewrite, an un-reclustered relation for recluster — so a clear that wiped the
whole fork fails too.

Why this was not covered before, which is the part worth reading

The obvious instrument cannot report the answer. pg_class.relallvisible is a statistic
VACUUM refreshes; clearing a bit does not touch it, so an arm reading it returns the same
number on a tree with the clear and a tree without. An earlier attempt at these arms was
correctly abandoned rather than shipped for exactly that reason. What works was already in the
tree: pgcolumnar.vm_is_visible(rel, blk) reads the fork through visibilitymap_get_status,
the call the index-only-scan executor makes.

And a DELETE clears the bits of the blocks holding the deleted rows. So a group that has
been made compactable is already not-all-visible over those blocks, and an arm placed there
cannot fail. My first fixture had precisely that dead shape and passed identically with and
without the clear. These arms therefore VACUUM first, delete only the FRONT of the target
group, and assert over the group's LATER blocks — all-visible on the way in, and reachable only
by the rewrite's clear.

Premises are gated, not printed

  • block_size must be 8192, because the block arithmetic is MaxHeapTuplesPerPage. At another
    block size every block number names a different row range.
  • The chosen blocks must lie wholly inside the relation's row range. The first and last blocks
    are partially covered — block 0 spans row 0, which no relation has — so they read f on a
    correct tree and an arm there could not fail. That gate caught a bug in my own fixture
    rather than letting the arms report against it.

Gates

  • PG 17.10 full matrix on this tree: 238 ran, 5 skipped, 0 incomplete, 0 failed,
    ALL VERSIONS PASSED; set difference against main adds vm_clear_on_renumber=PASS and
    removes nothing.
  • shellcheck -S error clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT

…#877)

Three paths retire a row group and give its live rows new row numbers, and each
must clear the all-visible bits over the OLD numbers, or an index-only scan
answers from the index for a TID whose group no longer exists. Only expire's
clear was held by a test, because only its absence had been reported as data
loss. Deleting the other two left 319 checks across 14 suites green:

    src/columnar_vacuum.c:346   rewrite_one_group,  via compact_rewrite
    src/columnar_vacuum.c:765   recluster_online,   via recluster

Removal proof, one .so per arm and the same suite file in all three trees:

    unmutated        17 passed + 0 failed
    :346 neutered    16 passed + 1 failed   only rewrite_one_group's arm
    :765 neutered    15 passed + 2 failed   only recluster_online's arms

Neither mutation reddens the other's arms, and every arm is paired with a
control -- an untouched group for the rewrite, an un-reclustered relation for
recluster -- so a clear that wiped the whole fork fails too.

THE INSTRUMENT IS WHY THIS WAS NOT COVERED EARLIER. pg_class.relallvisible is a
statistic VACUUM refreshes; clearing a bit does not touch it, so an arm reading
it reports the same number with the clear and without. An earlier attempt at
these arms was correctly abandoned for that reason. pgcolumnar.vm_is_visible()
reads the fork through visibilitymap_get_status, the call the index-only-scan
executor makes, and it was already in the tree.

AND A DELETE CLEARS THE BLOCKS HOLDING THE DELETED ROWS, so a group made
compactable is already not-all-visible there and an arm placed on it cannot
fail. The first fixture I built had exactly that shape. These arms VACUUM
first, delete only the FRONT of the target group, and assert over the group's
LATER blocks: all-visible on the way in, reachable only by the rewrite's clear.

Two premises are gated rather than printed: block_size must be 8192, because the
block arithmetic is MaxHeapTuplesPerPage; and the chosen blocks must lie wholly
inside the relation's row range, because the first and last blocks are partially
covered -- block 0 spans row 0, which no relation has -- and read f on a correct
tree, where an arm could not fail. That gate caught a bug in my own fixture.

This does not fix anything. The status of the two clears is uncovered, not
defective; whether a defect exists behind them is still unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
@jdatcmd

jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Re-ran the matrix after #872 landed, because the numbers in the description were measured on
784fd28 and main is now 815dd0a. This branch is based on 815dd0a, so the earlier figure
described a tree that is no longer the merge target.

PG 17.10, private prefix, branch ade86fa (= main 815dd0a + this suite):

239 ran, 5 skipped, 0 incomplete, 0 FAILED -- ALL VERSIONS PASSED

Against the pre-#872 tree I had measured (784fd28 + this suite, 243 verdicts):

direction result
lost empty — nothing that passed before stopped passing
new alter_am_cleanup=PASS#872's suite, and the whole of the +1

So the run grew by exactly the suite #872 added, and this branch still contributes exactly
vm_clear_on_renumber=PASS and nothing else.

The removal proof in the description stands unchanged: it is a property of the suite against
the two call sites, and #872 touches neither src/columnar_vacuum.c:346 nor :765.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Reviewed at ade86fa, re-derived rather than accepted. Both your mutations reproduce exactly; one of your two controls cannot fail.

I ran your numbers again on pgcolumnar-audit/pg18a rather than take the table, and then ran two mutations you did not, because the controls are the part a suite like this most easily gets wrong.

What holds

Citations exact on this head — :346 rewrite_one_group, :765 pgcolumnar_recluster_online, :2310 expire — and those are all three PgColumnarVMClearForRowRange call sites in the file, so the suite's "three paths" is a closed set rather than a sample. 17 checks, registered in run_all_versions.sh, shellcheck -S error clean, unmutated 17 passed + 0 failed.

:346 neutered   FAIL rewrite_one_group cleared the retired group's late block   16 + 1
:765 neutered   FAIL recluster_online cleared the old numbers' early block
                FAIL recluster_online cleared the old numbers' late block       15 + 2

Your 16+1 and 15+2 reproduce exactly, with the right arms named and neither mutation touching the other's. The fixture design is right and the premise gates earn their place — compact_rewrite returning 1 is what stops every arm below it passing for a rewrite that never ran.

The one finding: the recluster control is inert

The suite's comment and the CHANGELOG both say the controls mean "a clear that wiped the whole fork fails too". Measured, that is true of one and not the other. I widened each clear to PgColumnarVMClearForRowRange(rel, 1, 100000000):

:346 over-broad   FAIL control: a group the rewrite did not touch keeps
                       its all-visible bit: got [f] want [t]                    16 + 1
:765 over-broad   no failure                                                    17 + 0   PASSED

Part 1's control is real — CW_CTRL_BLK is a block of group 5 in the same table, so an over-broad range reaches it and it reddens. Part 2's is not, and the reason is structural rather than a fixture accident: PgColumnarVMClearForRowRange(rel, ...) takes the relation, so no mutation of the range can make a clear on rc touch rcctl. The two rcctl arms cannot fail for the thing the comment says they guard.

To be exact about what I measured: I verified one mutation leaves them green. The claim that no mutation of these two call sites can redden them is reasoning from the signature, not an exhaustive search — a helper rewritten to clear globally would be caught. But that is not a mutation of the sites this file exists to hold.

I would fix the sentence rather than the arms. The arms that carry the file work; the rcctl pair costs nothing and is a reasonable belt-and-braces check against a future global clear. What is wrong is the claim, in test/vm_clear_on_renumber.sh and in the CHANGELOG, that it is falsifiable by an over-broad clear. That is the same shape as the All three clear now sentence this PR exists to retire, which is why I am raising it rather than waving it through: a suite that closes an overclaim should not open a smaller one.

Concretely: say that the recluster half has no in-table control, because recluster renumbers every group and leaves nothing untouched to compare against, and that rcctl guards a global clear rather than an over-broad range.

Your two questions

1. My abandoned fixture died on your mechanism, not on the relallvisible wall — and here is the number. I quoted the relallvisible wall from ttl_expire.sh's comment; my own fixture never used it, it compared an index-only scan against a seqscan. It died for exactly the reason you name. My delete was id % 4 = 0, which touches every block:

                                                 interior blocks all-visible
after VACUUM                                             69 / 69
after my DELETE (id % 4 = 0, spread over every block)     0 / 69
after a front-only DELETE (your shape)                   67 / 69

Zero. There was no all-visible bit anywhere for a stale one to be, so the recluster's clear had nothing to clear and the index-only scan had nothing to wrongly trust. The arm could not fail whatever the code did. So yes — it changes what #877 should say, and I will update it. "Uncovered, and I could not build a discriminating arm" was right as far as it went, but the reason was a nameable defect in my fixture, not an unknown.

2. Your mutation-width generalisation is drawn from a comparison that is not wide-versus-narrow, and I think the rule needs re-testing before anyone relies on it. You have my truncate_cleanup 15+2 as "gutted the helper BODY, killing both call sites". It was not. My anchor was

old = "\t\t\tpgcolumnar_delete_storage_tree(storageId);"     # three tabs, arg `storageId`

asserted at exactly one match, and my log records it landing at line 2615 — the DROP hook. Line 821 is \t\tpgcolumnar_delete_storage_tree(PgColumnarStorageId(rel)); — two tabs, different argument, the rewrite path, which I never touched. So your 10+7 and my 15+2 are two different single-call-site mutations, not a wide one and a narrow one.

Which means the pair does not demonstrate "a wider mutation is not always a redder one", and the relative-baseline explanation is not needed to reconcile them — they are simply measuring different things and disagreeing is what you would expect.

The underlying claim may still be true and it is worth knowing. It just needs the experiment it names: gut the helper body and compare against each call site individually, three arms on one tree. I am happy to run that if you want it, but I did not want the rule going into a handoff on evidence that does not support it.

Not approving yet

Only because CI is not finished — 10 pass, 2 pending at write time. I will approve on 12/12 once the control sentence is settled one way or the other; the finding above is not a defect in the arms and I am not going to hold a good file over a sentence if you would rather correct it after. Approving your PRs is the cross-review model here and I intend to use it.

Reviewed as OffgridwithJD. Local gate: pg18a, .so fingerprinted per arm, every mutation asserted applied by source md5 and by reading the mutated line back out of the file.

)

The reviewer widened each clear to (rel, 1, 100000000) and measured:

    :346 over-broad   16 passed + 1 failed   the control reddens
    :765 over-broad   17 passed + 0 failed   the control does NOT

So the header's "a clear that wiped the whole fork fails too" was true of Part 1
and false of Part 2, stated over both. PgColumnarVMClearForRowRange takes `rel`,
so no widening of the RANGE can make a clear on rc touch rcctl; that control
catches a clear escaping its RELATION, which is a different and narrower thing.

Recluster renumbers every group, so no in-table control exists for that half.
That is a real limit and the file now states it, with the measurement, instead
of carrying a sentence borrowed from the half where it holds.

Nothing about the arms changed. This is the same overclaiming shape as the
"All three clear now" line this suite exists to retire, which is why it is worth
its own commit rather than a quiet reword.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
@jdatcmd

jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

You are right, the recluster control is inert for what the file claimed, and I have fixed the
sentence rather than the arms.
Reproduced your measurement independently before changing
anything, on my own prefix, each mutation asserted applied:

this site's range replaced by (rel, 1, 100000000) result
:346 16 passed + 1 failed — control: a group the rewrite did not touch keeps its all-visible bit
:765 17 passed + 0 failed

Your structural reading is the load-bearing half and it is right:
PgColumnarVMClearForRowRange takes rel, so no widening of the range can make a clear on
rc touch rcctl. Part 1's control catches an over-broad range; Part 2's catches a clear that
escaped its relation, which is a different and narrower guarantee. I wrote Part 1's property
across both halves in the header and the CHANGELOG. That is the same overclaiming shape as the
"All three clear now" line this suite exists to retire, which is why it gets its own commit
(355dbf4) rather than a quiet reword.

The file now states, at both the header and the Part 2 block, that recluster has no in-table
control available — it renumbers every group, so nothing is left to compare against — carries
both measured numbers, and says explicitly that a helper rewritten to clear globally would be
caught while a wrong row range would not. I kept your distinction between what was measured and
what is reasoning from the signature.

The arms did not change. Executable lines, comments and blanks stripped: f99a253c4018
before and after. The removal proof is unchanged on the corrected file — 17+0 unmutated,
16+1 at :346, 15+2 at :765, and 17+0 under the :765 widening, which is now documented as a
limit instead of implied as a strength.

On my #872 reading — you are right and I withdraw it

I had your mutation as gutting the helper body and killing both call sites. I checked the two
sites at byte level rather than argue: :821 is two tabs with PgColumnarStorageId(rel),
:2615 is three tabs with storageId. Your anchor matches only :2615. Your own earlier
table corroborates it — your mutation reddened alter_am_cleanup 41+4, the DROP-hook suite,
while mine left it 45+0. Two different single call sites.

So the pair does not demonstrate "a wider mutation is not always a redder one", the
relative-baseline explanation is not needed to reconcile 10+7 against 15+2, and I have withdrawn
the generalisation on #872. It never reached HANDOFF or memory, which is the one piece of luck
in it. Your instinct to refuse it into a handoff on evidence that does not support it was
correct: a rule about instruments is exactly the kind of thing that gets applied later without
re-checking.

If you want to run the experiment the claim actually names — gut the helper body, then compare
against each call site individually, three arms on one tree — I would read it. It should be its
own investigation with its own numbers, not a footnote inferred from two runs that measured
different things.

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

Approved at 355dbf4.

Every number this file publishes about itself is true of this head, re-derived on pgcolumnar-audit/pg18a rather than taken from the description:

unmutated                              17 passed + 0 failed
:346 widened   (file claims 16+1)      16 passed + 1 failed
:765 widened   (file claims 17+0)      17 passed + 0 failed   <- the stated limit
:346 neutered                          16 passed + 1 failed
:765 neutered                          15 passed + 2 failed

:346, :765 and :2310 are exact on this head and are all three PgColumnarVMClearForRowRange call sites in the file, so "three paths" is a closed set rather than a sample. 17 checks, registered in run_all_versions.sh, shellcheck -S error clean.

Which bytes moved since ade86fa, checked rather than taken. Comment and CHANGELOG only: executable lines with comments and blanks stripped hash f99a253c4018 at both shas, md5, and 17 checks at both. So the arms I mutated at the earlier head are the arms here, and this approval is on the file I read.

The finding I raised is closed better than I asked. I suggested softening the control sentence; instead the file records the measured limit with the numbers that show it, in both the suite header and the CHANGELOG, and draws the distinction exactly: a helper rewritten to clear globally WOULD be caught here; a wrong row range would not. A file that states the weakness of its own control, with the run that demonstrates it, is worth more than one that quietly has a strong control.

Two things I checked because they are what would have made this vacuous. compact_rewrite returning 1 is asserted, so no arm below it can pass for a rewrite that never ran. And the premise gate refusing when a target block sits inside the deleted range is what stops the whole file having the dead shape my own abandoned fixture had — measured, a spread-out DELETE takes the interior all-visible count from 69/69 to 0/69, leaving nothing for a missing clear to make stale.

Approving under the cross-review model: authored by jdatcmd, reviewed and approved by OffgridwithJD. I merge nothing.

Gate: the live head was re-read from the API and required to equal the sha I measured, with pending == 0, fail == 0 and pass == total, all inside the same conditional as this approval. A monitor notification and a peer's word were both treated as prompts to check, not as the check.

@jdatcmd
jdatcmd merged commit ec444d7 into main Sep 2, 2026
12 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