Skip to content

fix: re-record projections after a rewrite (#876, #887) - #892

Merged
jdatcmd merged 11 commits into
commandprompt:mainfrom
OffgridwithJD:audit/projection-rewrite-rerecord
Sep 9, 2026
Merged

fix: re-record projections after a rewrite (#876, #887)#892
jdatcmd merged 11 commits into
commandprompt:mainfrom
OffgridwithJD:audit/projection-rewrite-rerecord

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Fixes #876. Closes #887.

A rewrite mints a new base storage id; pgcolumnar.projection is keyed by that id; so after
TRUNCATE or a rewriting ALTER TABLE, pgcolumnar.read_projection raised 42704 for a
projection that was still declared over an intact table. 1.0-alpha3 shipped only a HINT naming
pgcolumnar.rebuild_projections(). This re-records them automatically.

Three things I found that change the shape #887 proposed

#887 says "the gap is one function". It is two, and the function it names cannot be one of them.
Instrumented pgcolumnar_relation_set_new_filelocator to log its own relid:

PROBE887 callback relid=16574 relname=op_truncate exists=1 nblocks=6 thresh=2
PROBE887 branch TAKEN relid=16574 oldsid=10000000002 nprojs=2

PROBE887 callback relid=16584 relname=op_altertype  exists=0 nblocks=0 thresh=2
PROBE887 callback relid=16591 relname=pg_temp_16584 exists=0 nblocks=0 thresh=2   <- the ALTER

TRUNCATE reaches the callback as the user's own relation with the old fork attached and both
projection rows in scope
. A rewriting ALTER TABLE reaches it as the transient relation
make_new_heap builds — a different oid, no columnar fork — so the rewrite branch is never taken
and neither the old storage id nor the projection list is ever visible there. @jdatcmd raised this
as a hypothesis and reproduced it independently on a different container and prefix.

A re-record in that callback is also wrong in a second way: PgColumnarStorageId(rel) still
returns the old id after PgColumnarWriteNewMetapage, so it records under the storage the
rewrite just retired. Measured, guarded to real rewrites, on top of this branch:

OPTION_B re-record in callback relid=16567 mintedsid=10000000002 PgColumnarStorageId(rel)=10000000000

Unguarded it does not even survive CREATE TABLE (cache lookup failed for relation N). I had
argued on the issue that it would raise a projection_pkey violation instead; that was a claim I
read off a CREATE UNIQUE INDEX and never ran, and running it refuted it. Corrected there.

The class is four shapes, not two. Swept rather than assumed, on 18.4:

loses the projection does not rewrite refused outright
TRUNCATE, including its multi-table form ADD COLUMN with a constant default core VACUUM FULL
ALTER COLUMN TYPE, covered or uncovered column DROP COLUMN core CLUSTER
ADD COLUMN with a volatile default VACUUM, SET TABLESPACE
a partitioned child rewritten via its parent SET ACCESS METHOD to the same method, no-op type change

The last one is not in #887 and is why the hook walks find_all_inheritors: the statement names
the parent, which is not itself a columnar relation, so a fix reading only the named relation never
fires. The refused pair bounds the class.

A repair attached to someone else's statement must not fail that statement. The re-record
resolves the declaration's column NAMES, and ALTER TABLE ... RENAME COLUMN does not carry a
rename into the declaration (@linuxhikerpm's #888). Before this was handled, the repair raised
column "a" does not exist inside an unrelated ALTER TABLE ... ALTER COLUMN id TYPE bigint and
rolled that type change back — measured, with a control. It now warns and skips:

WARNING:  could not restore projection "pp" on "w" after rewrite
DETAIL:   Its declaration names a column the table no longer has.
HINT:     Correct the declaration, then call pgcolumnar.rebuild_projections('w').

This is independent of whether #888 lands, and #888 removes the cause rather than the symptom — I
am happy to rebase behind it.

What the change is

The repair runs after the statement in pgcolumnar_process_utility, which is where the #778
rename block already repairs catalog state for the same reason: by then the rewrite has committed,
the new storage id is readable, and a statement that errored has left nothing to repair.

materialize_projection is extracted from pgcolumnar_add_projection rather than copied, so
declaring a projection and re-recording one drive a single implementation.
PgColumnarListProjectionDeclarations is new, and sits in columnar_metadata.h with the other
three declaration functions, per that header's own rule about single-consumer declarations.

Re-derived from the declaration, not copied forward. That is what makes ADD COLUMN correct: the
base projection records every live column, so copying the old row would leave projection 0 naming
a stale column set. The arm wants {1,2,3,4} and gets {} without this.

No SQL, no catalog change, so no upgrade script. pgcolumnar--1.0-alpha3.sql is untouched.

Evidence

test/projection_rewrite.sh is new and was written before the fix. Same suite, same fixtures, both
arms through the same build directory so a fingerprint difference is a source difference:

tree .so result
main 9628414 13c939bfe90b 38 passed, 9 failed, 0 unrunnable
+ this branch fbc691659ac4 53 passed, 0 failed, 0 unrunnable

Every arm compares a pgc_set_hash of read_projection against the base table rather than
checking that the call did not raise, so a projection re-recorded EMPTY fails — which matters
because the correct end state after a bare TRUNCATE is an empty projection that answers. Every
arm also asserts what its operation DID and reports UNMET_PRECONDITION rather than a pass when it
did not: an operation that failed or no-opped leaves the storage id unchanged and read_projection
answering, which is indistinguishable from a path that handles projections correctly. Three arms
carry vacuum, vacuum_sorted and cluster, which re-record for themselves, so a future fix
moved into the table-AM callback reddens here instead of double-recording.

One defect was in my own suite. P2 asked "does this storage id have a pgcolumnar.storage row",
and a storage row is written on the first WRITE, not when the storage is created — so right
after a TRUNCATE a correctly re-recorded projection read as retired. It had been passing on main
only because with no projection rows at all, an oracle that cannot tell "no rows" from "correct
rows" reads 0 either way. It now asks whether a row's storage id is the current id of some live
columnar relation, and says in a comment that it is a guard rather than the detector for #876.

Gate

Preflight across every packaged major, warnings are failures here as they are in CI:

OK  PostgreSQL 15.18      0 warning(s)
OK  PostgreSQL 16.14      0 warning(s)
OK  PostgreSQL 17.6       0 warning(s)
OK  PostgreSQL 18.4       0 warning(s)
OK  PostgreSQL 19beta2    0 warning(s)
built 5 of 5

Full suite matrix, test/run_all_versions.sh on 18 and 19, against this exact commit:

== HEAD 2ceb0ad030f936369eb21aaa30d6f2f0d7c65c18 ==
PG18   243 ran, 2 skipped, 0 incomplete, 0 failed
PG19   245 ran, 0 skipped, 0 incomplete, 0 failed
versions run: 2 of 2 configured
ALL VERSIONS PASSED

The two skips are native_repack and pg19_vacuum_options, both PG19-only, and both PASS on the 19
arm. projection_rewrite and docs_style PASS on both arms, and no suite reports FAIL anywhere in
the run. The gate log records the HEAD it ran against and it is this commit, because a green belongs
to a SHA rather than to a branch name.

The derived suite list was taken from the tree rather than guessed: 37 registered suites name the
identifiers this change touches (add_projection, read_projection, projection_declaration,
rebuild_projections, drop_projection, reconstruct_via_projection, RENAME COLUMN,
ALTER COLUMN, TRUNCATE, SET ACCESS METHOD). All 37 are in the matrix above. Nothing in the tree
depends on the old hint wording.

The first gate run was red and it was mine. docs_style failed on both arms: two sentences I
added ran 37 and 41 words against that gate's plain-language limit, and one used a prose
double-hyphen. Both passages are rewritten, and the run above is the re-run after that fix rather
than a re-interpretation of the first one.

Docs

CHANGELOG.md under [Unreleased], docs/sql-reference.md (rebuild_projections is no longer
the routine step after a rewrite, and what still needs it), and docs/limitations.md (the rename
gap is now the surviving limitation). The 42704 hint no longer names a rewrite as the likely
cause; it names the two cases that remain, one of which is the implicit base projection — which is
not readable by name at all, and which the old hint blamed on a rewrite that never happened.

RELEASE_NOTES_1.0-alpha3.md still says #876 "can affect you today". That describes a released
version, so I have left it alone deliberately — @jdatcmd, that wording is yours to decide.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a

@linuxhikerpm linuxhikerpm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocker — TRUNCATE … CASCADE misses implicitly truncated tables.

The post-utility repair at src/columnar_tableam.c:2516-2549 only visits relations explicitly listed in TruncateStmt->relations, plus inheritance descendants from find_all_inheritors(). A table reached through a foreign-key CASCADE is neither, so its rewrite is not repaired.

Reproduced on the exact PR head 2ceb0ad030f936369eb21aaa30d6f2f0d7c65c18, PostgreSQL 18.6:

CREATE TABLE cas_parent(id int PRIMARY KEY);
CREATE TABLE cas_child(
    id int REFERENCES cas_parent(id),
    v int
) USING pgcolumnar;
SELECT pgcolumnar.add_projection(
    'cas_child', 'pv', ARRAY['id','v'], ARRAY['v']);
INSERT INTO cas_parent SELECT g FROM generate_series(1,100) g;
INSERT INTO cas_child SELECT g, g%7 FROM generate_series(1,100) g;
TRUNCATE cas_parent CASCADE;
SELECT count(*) FROM pgcolumnar.read_projection('cas_child','pv');

Observed:

NOTICE:  truncate cascades to table "cas_child"
-- cas_child storage id changed, but current-storage named projection rows = 0
ERROR:  42704: projection "pv" does not exist on "cas_child"

The new 53-check projection_rewrite suite passes on this head, but its multi-table arm names both tables explicitly and therefore does not cover this case. Please collect every relation actually truncated (including FK-cascade additions) and add this as a regression arm.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Confirmed, fixed, and thank you — this is a real gap and your reproduction was exact. I reproduced it
on the head you named before changing anything.

The defect, reproduced on 2ceb0ad

before: sid=10000000000  projrows=2  read_projection=100
TRUNCATE cas_parent CASCADE;
NOTICE:  truncate cascades to table "cas_child"
after:  sid=10000000002  projrows=0
ERROR:  projection "pv" does not exist on "cas_child"

You are right about the mechanism as well as the symptom. The repair walked
TruncateStmt->relations plus find_all_inheritors, and a table reached by a foreign key is in
neither.

What the fix changes, and why it is not "also collect the FK cascade"

My first instinct was to re-derive the cascade set the way core does. I instrumented the failing case
first, and the log said not to:

PROBE892 rewrite branch taken relid=16573 relname=cas_child     <- the callback saw it
PROBE892 post-statement targets=1                                <- the repair did not

The table-AM callback already fires on every relation whose storage is actually replaced. So the
repair now records those relids and drains them after the statement, instead of asking the statement
what it thinks it touched. That is the set we wanted by definition, it needs no knowledge of foreign
keys, and it does not become a second copy of core's discovery logic that drifts between majors.

Three details I want on the record, because each was a way this could have gone wrong:

A transient relation is never recorded. make_new_heap's relation has no columnar fork when the
callback fires, so it does not take the rewrite branch. That matters, because it is dropped before the
list is drained and repairing a dropped relation would raise inside an unrelated statement. This is
the same measurement that put the repair in ProcessUtility rather than in the callback.

The list is cleared three times: when a utility statement starts, after it is drained, and at
transaction end via RegisterXactCallback. Without the first and the last, a statement that ERRORED
between recording and draining would leave a relid for the next statement to act on.

A rewriting ALTER still needs the statement's own name, since nothing is recorded for it. Both
sources are unioned and deduplicated by relid.

The arm, proved both ways

test/projection_rewrite.sh gains your case. Both arms in one tree, so the fingerprints differ by
source alone:

without the fix   57 passed, 1 failed
   FAIL  cas_child P1 the cascaded child keeps its projection: got [QUERY_ERROR.1] want [EMPTY]
with the fix      58 passed, 0 failed

Two things make the arm hard to satisfy accidentally. The parent is a heap table with no
projections of its own, so nothing but the child being repaired can make it pass. And it asserts its
premise first, that the CASCADE really did rewrite the child, reporting UNMET_PRECONDITION rather
than passing if a future change stops the cascade rewriting.

The full gate is re-running now, preflight across five majors plus the 18 and 19 matrix, and I will
push once it is green rather than before.

One last note. You wrote that the 53-check suite passes on that head and that its multi-table arm
names both tables explicitly, so it does not cover this. That is exactly right, and it is the more
useful half of the review: I had swept the class of rewriting statements and missed a route by
which one statement reaches further than it names. Worth having in the tree as a distinct arm for that
reason, rather than folded into the multi-table one.

OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Sep 9, 2026
…very one it names

@linuxhikerpm found this in review of commandprompt#892 and the reproduction is exact: TRUNCATE
... CASCADE reaches a table through a foreign key, and that table is neither listed
in TruncateStmt->relations nor an inheritance descendant of anything listed. The
post-statement repair walked the statement, so it never visited the cascaded table
and the projection stayed absent.

Reproduced on the previous head 2ceb0ad, PG 18.4: a heap parent, a columnar child
referencing it with a declared projection, TRUNCATE parent CASCADE, and then
read_projection raises 42704 on the child.

The fix stops re-deriving the statement's reach. The table-AM callback already fires
on every relation whose storage is actually replaced, so it now records those relids
and the post-statement block drains the list. Instrumented on the failing case, the
callback reported `rewrite branch taken relid=16573 relname=cas_child` while the
post-statement block reported `targets=1` -- it had the right answer all along and
the repair was asking the wrong source.

Recording rather than re-deriving also avoids duplicating core's foreign-key
discovery, which would have been a second copy of logic that changes between majors.

Three details worth stating, because each is a way this could have gone wrong:

A transient relation is never recorded. make_new_heap's relation has no columnar
fork when the callback fires, so it does not take the rewrite branch. That matters
because it is dropped before the list is drained, and repairing a dropped relation
would raise inside an unrelated statement.

The list is cleared when a utility statement starts, drained and cleared when one
finishes, and cleared at transaction end through RegisterXactCallback. Without the
first and last of those, a statement that ERRORED between recording and draining
would leave a relid for the next statement to act on.

A rewriting ALTER still needs the statement's own name, because nothing is recorded
for it. Both sources are unioned, deduplicated by relid.

test/projection_rewrite.sh gains the arm, and it is proved both ways in one tree so
the two fingerprints differ by source alone:

    without the fix   57 passed, 1 failed
    with the fix      58 passed, 0 failed

The arm asserts its premise first, that the CASCADE really did rewrite the child,
and reports UNMET_PRECONDITION rather than passing if it did not. The parent is a
heap table with no projections, so nothing but the child being repaired can make it
pass.

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

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

Reviewed against head 41242e3d10a1. Your rebase and depth fix are not on GitHub — the remote
still has two commits, grep -c "depth\|nesting" src/columnar_tableam.c returns 0, and
pgcolumnar_forget_rewritten() is still at :2550 and :2639. So this review is of the tree
GitHub has, and the lifecycle blocker below is the one you have already fixed locally. Everything
else is new.

Five dimensions, each finding attacked by a skeptic told to default to refuting. 20 verdicts, 19
confirmed, 1 refuted. Most confirmations were reproduced on a live server rather than argued.

Blockers

1. The repair takes ShareLock unconditionally, on every ALTER TABLE.
src/columnar_projection.c:405. I read this one myself rather than relying on the review.

rel = table_open(relid, ShareLock);
...
    if (present)
        continue;          /* <- the skip is AFTER the lock */

The comment justifies it as "the statement that rewrote this relation already holds
AccessExclusiveLock, so this takes nothing new". That is true for a rewriting statement and false
for the ones that reach this code without rewriting. pgcolumnar_process_utility calls the repair
for every AlterTableStmt on a relation with a declared projection, including subcommands that
take only ShareUpdateExclusiveLockSET (fillfactor), VALIDATE CONSTRAINT,
ALTER COLUMN SET STATISTICS. For those, ShareLock is new and blocks concurrent writers. And it
is taken before the already-present check, so even the no-op path pays it.

This is the rule the project already states: justify an exclusive-level lock against a weaker
correct one. Take the lock after deciding there is work, or take a weaker one for the decision.

2. The repair is not gated on "did this statement rewrite this relation".
src/columnar_projection.c:384-437. It fires whenever the projection row is absent, so a
metadata-only ALTER TABLE performs an unbounded projection back-fill — reading every live row —
on a statement that rewrote nothing. Pairs with (1): the wrong lock and the wrong work.

3. The re-record can raise inside the drain and abort a statement that succeeded on main.
src/columnar_projection.c:436-449. The declaration_resolves guard covers exactly one failure —
a name the table no longer has. materialize_projection can raise for other reasons, and anything
it raises propagates into a user statement that had already succeeded. That is the same shape you
found and fixed for the rename case; the guard is narrower than the hazard.

4. test/projection_rewrite.sh:316-325 passes with rebuild_projections gutted. The arm that
appears to cover the recovery path cannot fail. Demonstrated by mutation.

5. On current main the suite reports INCOMPLETE, which fails every major.
test/projection_rewrite.sh:269-281. Merge-blocking independently of everything else, and it is a
consequence of main moving — worth re-checking after you rebase onto e42c80d.

6. The lifecycle one you already have. Confirmed independently, with a causation mutation
rather than only a reproduction: a verifier built its own lane, reproduced 42704 on
TRUNCATE ... CASCADE with three different nested-utility trigger bodies, then added a depth
counter itself and watched all three arms go green while the controls stayed green. Your fix
direction is right.

Majors worth folding into the same push

  • Two rewrite shapes sit outside the gate. REFRESH MATERIALIZED VIEW is a rewrite that is
    neither AlterTableStmt nor TruncateStmt, so the projection is lost and the new errhint
    tells the user the opposite. And a replicated TRUNCATE rewrites the subscriber's table
    without passing through ProcessUtility at all, so nothing ever drains.
  • The recording is ungated while the drain is gated, so the list's stated invariant does not
    hold: relids are recorded that nothing will ever drain.
  • A repaired projection followed by a same-transaction TRUNCATE leaves the projection wrong.
    Raised as a regression; the verifier downgraded it to an incomplete fix rather than a regression,
    because the declaration survives and rebuild_projections() recovers it. Still worth an arm.
  • docs/limitations.md:513-520 documents a limitation main has already fixed, and
    docs/sql-reference.md:473-484 gives the wrong reason for the one case that still needs
    rebuild_projections(). The CHANGELOG WARNING paragraph rests on the same stale premise.
  • The HINT at src/columnar_projection.c:441-446 tells the reader to perform an operation the
    extension does not offer
    , and emits an unqualified, wrongly quoted relation name.
  • Test arms: the TruncateStmt relation-list walk can be deleted with the suite still green;
    the already-present guard has no arm; the warn-and-skip arm asserts message text where a
    SQLSTATE is available; the no-warning negative control passes on no output at all and never
    asserts its premise.

Two process notes

Your 12/12 green is green on the buggy tree. All twelve checks pass on 41242e3d10a1
including both suites legs — and that is the head whose defect you reproduced. Third distinct way a
green tick has misled us today, after held-not-run on the fork PRs and a stale local tag ref.

Rebase onto e42c80d, not 3a4b985. #891 landed and touches pgcolumnar_process_utility too,
so you are now composing three changes in that one function: #888's rename call inside the
inheritor walk, #891's refusal before the statement, and your record/drain around both. Read the
resolved function end to end rather than trusting a clean git rebase.

Provenance

I verified (1) and the record-site asymmetry against the source myself. The rest come from the
review, most of them reproduced on live servers in their own lanes and prefixes; I have not
personally re-run each one and am not claiming to have. Where a verifier downgraded a finding I
have said so rather than keeping the reviewer's severity.

Requesting changes rather than approving — not because the approach is wrong. The design is right,
the ProcessUtility drain is the correct seam, and the four-shape sweep is better than what #887
asked for. It is the lock, the gating and the arms that need another pass.

@jdatcmd

jdatcmd commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Correcting two things in my review above. One claim was too strong, and one suggested fix would
have broken the feature.
@OffgridwithJD caught both; I have verified each against the source
rather than accepting them.

1. "Blocks concurrent writers" is wrong. The waste is real; the blocking is not.

I wrote that the unconditional ShareLock "blocks concurrent writers" for statements like
SET (fillfactor). It does not, and the reason is one line I did not read:

src/columnar_projection.c:455   table_close(rel, ShareLock);

The repair closes with an explicit lock mode rather than NoLock, so the lock is released inside
the repair
rather than held to transaction end. It is a transient acquisition, not a
statement-length one. Measured on the branch and on main with the same fixture: both hold only
ShareUpdateExclusiveLock on the relation, no ShareLock is visible in pg_locks, and a
concurrent INSERT completed in under a second on both arms.

What survives, narrowed to what is true: every AlterTableStmt on a relation with a declared
projection performs an unnecessary lock acquisition and the catalog reads behind it, including the
no-op path, because the lock is taken before the already-present check. Three metadata-only
ALTERs opened the relation three times with nothing to repair, against one for a rewriting
control. That is waste worth fixing, and it is not the writer-blocking regression I called it.

Note also how the measurement was settled: sampling pg_locks two seconds in cannot see a
transient lock
, and that instrument reported nothing on either arm. An elog at the open is what
produced the "three times" count. An instrument that cannot observe the thing it is pointed at
returns a clean result either way.

2. My suggested fix would have disabled half the feature, and my own evidence said so

I proposed gating the repair on membership in the rewritten-relid list. That would have stopped
ALTER TABLE ... ALTER COLUMN ... TYPE from ever being repaired.

I had already proved why, and did not apply it. The PROBE887 run I did myself, and posted on
#887, shows the callback firing on the transient relation for a rewriting ALTER:

PROBE887 callback relid=16584 relname=pg_temp_16575 exists=0 nblocks=0 thresh=2

Nothing is ever recorded for the user's relation on that path, so nothing would ever be in the list
to gate on. I established that, wrote it into #887's correction, and then proposed a fix that
contradicts it two days later.

The approach taken instead is better than what I suggested: ask the cheap question — is any
declared projection missing under the current storage id — under AccessShareLock, which conflicts
with nothing a writer takes, and acquire ShareLock only when there is something to materialise.

3. And my diagnosis of the missing push was wrong

I suggested the rebase push had failed, and offered a mechanism: a git push piped into something
that takes its exit status from the pipeline's last command, the way git rebase | tail had already
misled once. There was no failed push. The branch had not been pushed yet, deliberately, pending
green.

That is three corrections against one review. The findings themselves stand — the waste, the
missing rewrite gate, the fail-soft gap, REFRESH MATERIALIZED VIEW, the replicated TRUNCATE, and
the two vacuous arms — and CHANGES_REQUESTED still reflects where the PR is. But the severity I
attached to the lock was inflated, and the remedy I attached to it was wrong.

OffgridwithJD and others added 11 commits September 9, 2026 03:12
…dprompt#887)

A rewrite mints a new base storage id and pgcolumnar.projection is keyed by
that id, so after TRUNCATE or a rewriting ALTER TABLE, read_projection raised
42704 for a projection that was still declared over an intact table.
1.0-alpha3 shipped only a HINT naming pgcolumnar.rebuild_projections().

The repair runs after the statement in pgcolumnar_process_utility, where the
rewrite has committed, the new storage id is readable, and a statement that
errored has left nothing to repair.

Not in pgcolumnar_relation_set_new_filelocator, which commandprompt#887 proposed. Measured
with that callback logging its own relid: TRUNCATE reaches it as the user's
relation with both projection rows in scope, but a rewriting ALTER TABLE
reaches it as the transient relation make_new_heap builds -- pg_temp_<oid>,
no columnar fork -- so the branch is not taken and neither the old storage id
nor the projection list is ever in scope. A re-record there also records under
the retired id, because PgColumnarStorageId(rel) still returns the old id
after the new metapage is written.

Four shapes lose the projection, not the two commandprompt#887 names: TRUNCATE including
its multi-table form, a type change on a covered or uncovered column, ADD
COLUMN with a volatile default, and a partitioned child rewritten via its
parent -- where the statement names the parent, which is not itself a
columnar relation. Hence find_all_inheritors. Core VACUUM FULL and CLUSTER
are refused on a columnar table, which bounds the class.

materialize_projection is extracted from pgcolumnar_add_projection rather
than copied, so declaring and re-recording drive one implementation. The
projections are re-derived from the declaration, not copied forward: the base
projection records every live column, so copying the old row would leave
projection 0 naming a stale column set after ADD COLUMN.

A repair attached to another statement must not fail it. ALTER TABLE ...
RENAME COLUMN does not carry a rename into the declaration (commandprompt#888), and before
this was handled the repair raised `column "a" does not exist` inside an
unrelated ALTER COLUMN ... TYPE and rolled that type change back. It now
warns and leaves the projection to rebuild_projections().

test/projection_rewrite.sh is new and was written before the fix: 38 passed /
9 failed on main 9628414, 53 passed / 0 failed here, both arms through the
same build directory. The 42704 hint no longer blames a rewrite; it names the
two cases that remain, one of which is the implicit base projection, which is
not readable by name at all.

No SQL and no catalog change, so no upgrade script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…very one it names

@linuxhikerpm found this in review of commandprompt#892 and the reproduction is exact: TRUNCATE
... CASCADE reaches a table through a foreign key, and that table is neither listed
in TruncateStmt->relations nor an inheritance descendant of anything listed. The
post-statement repair walked the statement, so it never visited the cascaded table
and the projection stayed absent.

Reproduced on the previous head 2ceb0ad, PG 18.4: a heap parent, a columnar child
referencing it with a declared projection, TRUNCATE parent CASCADE, and then
read_projection raises 42704 on the child.

The fix stops re-deriving the statement's reach. The table-AM callback already fires
on every relation whose storage is actually replaced, so it now records those relids
and the post-statement block drains the list. Instrumented on the failing case, the
callback reported `rewrite branch taken relid=16573 relname=cas_child` while the
post-statement block reported `targets=1` -- it had the right answer all along and
the repair was asking the wrong source.

Recording rather than re-deriving also avoids duplicating core's foreign-key
discovery, which would have been a second copy of logic that changes between majors.

Three details worth stating, because each is a way this could have gone wrong:

A transient relation is never recorded. make_new_heap's relation has no columnar
fork when the callback fires, so it does not take the rewrite branch. That matters
because it is dropped before the list is drained, and repairing a dropped relation
would raise inside an unrelated statement.

The list is cleared when a utility statement starts, drained and cleared when one
finishes, and cleared at transaction end through RegisterXactCallback. Without the
first and last of those, a statement that ERRORED between recording and draining
would leave a relid for the next statement to act on.

A rewriting ALTER still needs the statement's own name, because nothing is recorded
for it. Both sources are unioned, deduplicated by relid.

test/projection_rewrite.sh gains the arm, and it is proved both ways in one tree so
the two fingerprints differ by source alone:

    without the fix   57 passed, 1 failed
    with the fix      58 passed, 0 failed

The arm asserts its premise first, that the CASCADE really did rewrite the child,
and reports UNMET_PRECONDITION rather than passing if it did not. The parent is a
heap table with no projections, so nothing but the child being repaired can make it
pass.

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

Rebasing onto main, which now carries commandprompt#888, made three of this suite's arms
unrunnable, and the suite said so rather than passing them.

    UNRUN  stale: the statement survives a stale declaration:
           UNMET_PRECONDITION: declaration is {a2,b}, not stale; commandprompt#888 may have landed
    accounting: 52 passed + 0 failed + 3 unrunnable = 55
    projection_rewrite.sh: INCOMPLETE   exit 67

That is the intended behaviour and the reason the premise was asserted separately.
Those arms test that a repair which cannot run degrades to a WARNING instead of
aborting the statement that triggered it. They produced the stale declaration with
ALTER TABLE ... RENAME COLUMN, which commandprompt#888 has now fixed, so the state they need can
no longer be reached that way and the property they test would have gone untested
while three green ticks suggested otherwise.

The property is still worth testing, because the state is still reachable: any
database created before commandprompt#888 carries it, and nothing guarantees a future path cannot
reintroduce it. So the arms now write the stale name directly into
pgcolumnar.projection_declaration, which is what such a database looks like, and the
premise asserts the write took effect.

Two arms are added for the property commandprompt#888 now guarantees, asserted positively here
because this suite is the one that breaks if it regresses: a rename carries into the
declaration, and the repair after a later rewrite still resolves.

60 checks, 0 failed, 0 unrunnable on the rebased tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
@jdatcmd found this reviewing commandprompt#892 and pointed at the right line: the list is
process-global and cleared at the START of every pgcolumnar_process_utility, in a
function that is re-entrant.

ProcessUtility nests. An AFTER TRUNCATE trigger whose function runs any utility
statement calls the hook again from inside the outer statement, after the callback
has recorded the truncated relation. Measured with the callback logging its own
order, before this change:

    entry: clearing, had 0        the outer TRUNCATE starts
    record: relid=16573          the cascaded child is recorded
    entry: clearing, had 1        the trigger's nested utility clears it
    drain: recorded=0            the outer drain has nothing left

A DIRECTLY NAMED table survives that, because the statement's own relation list is a
second source. That is why the defect needed composing to see: a table reached by FK
CASCADE has the recorded list as its only route, so cascade plus nested utility is
what leaves the projection absent and read_projection raising 42704. Either alone
passes, which is exactly why the existing cascade arm did not catch it.

Two changes, because there were two instances of one mistake:

The list is cleared only when the OUTERMOST utility statement begins, tracked by a
depth counter. A nested statement must not discard what its caller is holding.

And a drain now removes the relids it repaired rather than emptying the list. The
blanket clear after draining was the same defect facing the other way: an inner
statement would have discarded a relid an outer one recorded and had not yet drained.

The depth is decremented through PG_CATCH so an ERROR cannot leave it raised, and the
transaction-end callback resets both the list and the depth.

test/projection_rewrite.sh gains the composed arm, proved both ways in one tree:

    without the depth fix   65 passed, 1 failed
    with it                 66 passed, 0 failed

The arm asserts three premises before its verdict: the trigger is installed on the
cascaded child, the projection read before the truncate, and the nested utility
really ran. That last one uses a PERMANENT marker table, because a TEMP one vanishes
with the trigger's session and an earlier version of this probe read 0 and reported a
premise failure I first mistook for the mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Three defects from @jdatcmd's commandprompt#892 review, all in the repair itself.

The ShareLock was unconditional. The repair is reached for every AlterTableStmt
on a relation with a declared projection, not only one that rewrote it, and the
lock was taken before the already-present check. Measured: SET (fillfactor),
ALTER COLUMN SET STATISTICS and SET (autovacuum_enabled) each opened the
relation with ShareLock with nothing to repair. ShareLock conflicts with
RowExclusiveLock, so a metadata-only statement blocked concurrent writers. The
question is now asked under AccessShareLock, which conflicts with nothing a
writer takes, and ShareLock is taken only once a projection is known missing.

materialize_projection could abort a statement that succeeds on main. The
declaration_resolves guard covers exactly one failure, a name the table no
longer has, and the hazard is wider. The call now runs in an internal
subtransaction: a failure is rolled back, reported as a WARNING carrying the
original SQLSTATE and message, and the user statement continues.

REFRESH MATERIALIZED VIEW is a rewrite that is neither AlterTableStmt nor
TruncateStmt, so the projection was lost while the new errhint told the user
the opposite. It now reaches the drain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
@jdatcmd demonstrated by mutation that this arm passed with
rebuild_projections gutted. It truncated and then called the function, but the
repair in this PR already restores a TRUNCATE, so the comparison held whether
or not the function did anything. A test named after a function it does not
exercise is worse than no test, because the name is what a reader trusts.

The arm now deletes the projection rows directly, which is the state a logical
restore leaves: pg_dump carries projection_declaration and cannot carry the
storage. That is the case the function exists for. Three premise checks assert
the projection read before removal, that it is genuinely absent after, and that
the declaration survived. The RETURN VALUE is asserted, not only the end state,
and a second call must report rebuilding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Four more from @jdatcmd's commandprompt#892 review, plus one I found reading the composed
ProcessUtility hook end to end as the review asked.

The RefreshMatViewStmt I added last commit was cast to AlterTableStmt to read
its relation. Both structs happen to place that field at offset 8 -- measured
on PG 15 through 19, because NodeTag is 4 bytes and RefreshMatViewStmt's two
bools fit in its tail padding -- so it read the right field. By coincidence of
layout, not by any rule: one added field in either struct turns it into a wrong
pointer with no diagnostic. It now dispatches on the node type.

The TruncateStmt relation-list walk is deleted. The review said the suite stays
green without it, and that is right, so I instrumented it instead of arguing:
across the 80 checks it fired twice, both times for a NON-columnar parent in a
cascade arm, where the repair is a no-op. TRUNCATE reaches the table-AM
callback for every relation it rewrites, including the ones it never names, so
the recorded list already holds them.

The stale-declaration HINT named an operation this extension does not offer.
Measured: rebuild_projections() re-runs the same stale declaration and raises
the same missing-column error, and drop_projection() refuses with 42704
because the projection row is exactly what is absent. add_projection() with the
same name replaces the declaration and materialises it -- it restored all 200
rows. The HINT now says that.

Both messages carry a schema-qualified relation name. Unqualified, the HINT for
a table in schema "s" said rebuild_projections('t'), and running exactly that
gave ERROR: relation "t" does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
84 checks, from 76.

REFRESH MATERIALIZED VIEW has an arm. Proved load-bearing by removal: with
RefreshMatViewStmt out of the gate it reports ERROR: projection "pp" does not
exist on "mv1", and the new HINT then tells the reader that re-recording is
automatic, which is the opposite of what happened. REFRESH ... CONCURRENTLY
gets no arm because it is not a rewrite -- the storage id is unchanged across
it, measured -- so an arm would be permanently unrunnable rather than green.

The already-present guard has an arm. It runs far more often than the repair
does, because every AlterTableStmt on a relation with a declared projection
reaches it, and it is why the decision is now made under AccessShareLock.

Two arms could not fail, both found by @jdatcmd. The warn-and-skip arm counted
lines matching the message text or the string "rebuild_projections"; rewording
the HINT in this same PR would have silently halved that count. It now asserts
SQLSTATE 42703, which is the contract, and the prose separately. The no-warning
negative control was satisfied by no output at all, so it now asserts that the
statement completed and rewrote before "no warning" is allowed to mean
anything.

And retired_rows() counted a live matview's rows as retired, because it
filtered relkind = 'r' and a matview is 'm'. The helper is global, so that one
mistake failed four later arms that had nothing to do with matviews.

84 passed, 0 failed, on PG 18 and PG 19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
docs/limitations.md documented a limitation main has already fixed: commandprompt#888 landed,
so RENAME COLUMN does carry the rename into the declaration. The reachable case
is now a database created before that fix, and the page says so.

docs/sql-reference.md gave the wrong reason for the one case still needing
rebuild_projections(), and pointed at that function for a case it cannot fix.
Both pages now name add_projection() as the recovery for an unresolvable
declaration, because that is the call measured to work.

Both record the rewrite this extension cannot see. A TRUNCATE replicated to a
subscriber is applied by the logical replication worker calling
ExecuteTruncateGuts directly rather than going through ProcessUtility
(src/backend/replication/logical/worker.c, apply_handle_truncate, read against
PostgreSQL 18.4). I could not complete a live publisher/subscriber
demonstration on the harness cluster -- CREATE SUBSCRIPTION hung -- so this is
sourced from core rather than reproduced, and the wording says which.

CHANGELOG records five lost shapes rather than four, the lock and containment
changes, the recorded-relid design, and the corrected check count of 84.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
The gate caught these, not me. test/docs_style.sh failed on PG 18 with two
over-long sentences in docs/limitations.md and one in docs/sql-reference.md:
35, 28 and 32 words against the 25-word limit the checker enforces from
ISO 24495-1.

Each is split at the join that was carrying the extra clause, so no content is
lost. plain_language_check.py now reports ok for every file it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
@jdatcmd noted the recording is ungated while the drain is gated, so the
list holds relids nothing will drain. That is true. The comment claimed the
list held what the statement rewrote, which is narrower than what it holds.

Narrowing the recording to match the drain was considered and not done.
Nothing observable follows from the asymmetry, so no arm could redden if the
narrowing were wrong, and a change no test can catch is worse than a stated
invariant. The comment now says why it is inert: a relid recorded outside a
utility statement is cleared before the next outermost one records anything,
and one recorded by a nested call is drained by the enclosing statement,
where the repair finds the projection present and returns under
AccessShareLock without doing work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
@OffgridwithJD
OffgridwithJD force-pushed the audit/projection-rewrite-rerecord branch from 41242e3 to 02b1ed4 Compare September 9, 2026 03:13
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Pushed. Rebased onto e42c80d, six commits on top of what you reviewed, and I read the composed pgcolumnar_process_utility end to end as you asked rather than trusting a clean git rebase — which caught a defect of my own, below.

Taking your blockers in order.

Blockers

1. The unconditional ShareLock — fixed, and you were right about the reach. I measured it before changing it. Three metadata-only statements each opened the relation with ShareLock with nothing to repair: ALTER COLUMN SET STATISTICS, SET (autovacuum_enabled), SET (fillfactor). The decision is now made under AccessShareLock, which conflicts with nothing a writer takes, and ShareLock is taken only once a projection is known missing.

2. Not gated on "did this statement rewrite this relation" — fixed by (1). The unbounded back-fill is now behind the same early return: a statement that rewrote nothing does the AccessShareLock read and stops.

3. The re-record could abort a statement that succeeds on main — fixed. materialize_projection runs in an internal subtransaction. A failure is rolled back and reported as a WARNING carrying the original SQLSTATE and message, and the user's statement continues. declaration_resolves stays as the specific, better-diagnosed case.

4. The rebuild_projections arm passed with the function gutted — fixed, and your mutation was correct. The arm truncated and then called the function, but the repair in this PR already restores a TRUNCATE, so the comparison held either way. It now deletes the projection rows directly, which is the state a logical restore leaves — pg_dump carries the declaration and cannot carry the storage — and asserts the return value, plus three premises and a second call that must rebuild nothing.

5. INCOMPLETE on current main — gone after the rebase. 84 checks, 0 failed, 0 unrunnable, on PG 18 and PG 19.

6. The lifecycle fix — thank you for the causation mutation. Building the depth counter yourself and watching three arms go green while the controls stayed green is stronger evidence than my reproduction was, and I would not have got that from re-running my own arm.

Majors

REFRESH MATERIALIZED VIEW — fixed, with an arm, proved load-bearing by removal. With RefreshMatViewStmt out of the gate the arm reports ERROR: projection "pp" does not exist on "mv1", and the new HINT then tells the reader that re-recording is automatic — the opposite of what happened, exactly as you said. REFRESH ... CONCURRENTLY gets no arm: measured, the storage id is unchanged across it, so it is not a rewrite and an arm would be permanently unrunnable rather than green.

Replicated TRUNCATE — documented, and I want to be precise about the evidence. apply_handle_truncate calls ExecuteTruncateGuts directly rather than going through ProcessUtility (src/backend/replication/logical/worker.c, read against PostgreSQL 18.4), so nothing observes the subscriber's rewrite. That is sourced from core, not reproduced: my publisher/subscriber probe hung at CREATE SUBSCRIPTION on the harness cluster and I stopped rather than let it eat the gate. Both doc pages say the subscriber needs rebuild_projections() and neither claims a live demonstration.

The HINT named an operation the extension does not offer — fixed, after measuring what does. All three candidates, on a stale declaration:

call result
rebuild_projections('s.t') ERROR: column "gone" does not exist — re-runs the same declaration
drop_projection('s.t','pp') ERROR: 42704 — the projection row is exactly what is absent
add_projection('s.t','pp',...) succeeds; read_projection returns all 200 rows

So the HINT now names add_projection. And you were right about the unqualified name: for a table in schema s it said rebuild_projections('t'), and running exactly that gave ERROR: relation "t" does not exist. Both messages are schema-qualified now, and I followed the new HINT literally from a search_path that lacks the schema to confirm it recovers.

The TruncateStmt walk — deleted. You said the suite stays green without it, and it does, so I instrumented it instead of arguing about whether a case exists. Across the 80 checks it fired exactly twice, both times for a non-columnar parent in a cascade arm, where the repair is a no-op. TRUNCATE reaches the table-AM callback for every relation it rewrites, including the ones it never names, so the recorded list already holds them. The ALTER branch is the opposite case and is why that code path still exists.

The already-present guard — has an arm now. It runs far more often than the repair does, which is the same fact that makes (1) matter.

Both vacuous arms — fixed. The warn-and-skip arm counted lines matching the message text or the literal rebuild_projections; your point proved itself immediately, because rewording the HINT in this same PR would have silently halved that count. It now asserts SQLSTATE 42703 — the contract — and the prose separately. The no-warning control was satisfied by no output at all, so it now asserts the statement completed and rewrote before "no warning" is allowed to mean anything.

retired_rows() was itself broken, and the matview arm found it. It filtered relkind = 'r', so a live matview's rows counted as retired. The helper is global, so that one mistake failed four later arms that have nothing to do with matviews.

A defect of mine that reading the composed hook caught

I added RefreshMatViewStmt to the gate and read its relation through a cast to AlterTableStmt. It works — offsetof is 8 in both on PG 15, 16, 17, 18 and 19, because NodeTag is 4 bytes and RefreshMatViewStmt's two bools fit in its tail padding — but by coincidence of layout, not by any rule, and one added field in either struct turns it into a wrong pointer with no diagnostic. It dispatches on node type now.

Worth recording that I predicted a segfault there and was wrong. The probe said the branch was reached with a valid pointer, and the offsets said why. The measurement corrected me; the reasoning would not have.

Two of your findings I have not closed

The recording is ungated while the drain is gated. Still true. The list is cleared for the outermost statement, so it does not leak across statements, but the stated invariant is stronger than what holds and I would rather fix that than reword it.

A repaired projection followed by a same-transaction TRUNCATE. This is entangled with something bigger that I filed separately as #896: TRUNCATE then INSERT in one transaction fails with row_group_pkey on any columnar table carrying a projection, and rolls the transaction back. It reproduces on e42c80d with four controls, so it is not from this PR. I would rather fix #896 than add an arm here that passes for the wrong reason.

On your process note

Your 12/12 green is green on the buggy tree.

Taken, and it happened again in miniature today: this PR's own suite was 76/76 green while retired_rows() was miscounting, because nothing in it had a matview until I added one.

Verification, and exactly what it covers

Head is 02b1ed4, rebased onto edd729e.

Full matrix on d95ee11, both majors: ALL VERSIONS PASSED, exit 0. PG 18 ran 245 of 247 (2 skipped), PG 19 ran 247 of 247, zero FAIL.

That matrix ran before the rebase onto edd729e, so here is precisely why I am not claiming a third full run. The rebase changed one file:

 CHANGELOG.md | 23 ++++++++++++++++++++---
 1 file changed, 20 insertions(+), 3 deletions(-)

measured as git diff against the gated tree. No source, test or docs/ file differs. I then derived rather than assumed what a CHANGELOG.md-only change can invalidate: test/docs_style.sh is the only suite in test/ that reads that file, and only for its version-marker check — its plain-language check globs docs/*.md and README.md and never sees it. So I re-ran that suite and mine on both majors:

PG18  docs_style           PASSED  (9 checks)      PG19  docs_style           PASSED  (9 checks)
PG18  projection_rewrite   PASSED  (84/84)         PG19  projection_rewrite   PASSED  (84/84)

One shortcut I tried and threw away, because it bears on how much the above is worth. Before re-gating I tried to prove the two commits after the first matrix were inert — one docs-only, one a comment-only change with 0 non-comment lines altered. Then I compiled both and compared:

columnar_tableam.o at the new head:   bc33731e32139b1f4faa9a5f9c3d173b
columnar_tableam.o at the gated tree: d83b1e5f5bc447ce9225860b928cc9ec

Different. Almost certainly __LINE__ shifting under 17 added comment lines, since ereport bakes the line number in — but "almost certainly" is a story, so I ran the full matrix again instead. A comment-only change is not a no-op at the object level, and I would rather record that than have argued it.

@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 02b1ed4. Every blocker from my review is closed, and I checked each against the
branch rather than against the report. 12/12, and I looked at the breakdown rather than the colour:
{"SUCCESS": 12}, nothing neutral or skipped hiding inside a zero-failure count.

What I verified myself

The lock. src/columnar_projection.c:433 opens under AccessShareLock, decides, closes at
:476, and only then takes ShareLock at :477 when there is something to materialise. The
comment now records the measurement rather than the old justification that was true of a rewriting
statement and false of the metadata-only ones. This is a better resolution than the one I proposed
— gating on the rewritten-relid list would have disabled the ALTER COLUMN TYPE repair entirely,
which my own PROBE887 run had already shown and I failed to apply.

The fail-soft is textbook and I read it line by line, because a naive PG_TRY around
BeginInternalSubTransaction corrupts transaction state and this is the riskiest new code in the
PR. It is correct: CurrentMemoryContext and CurrentResourceOwner saved before the begin;
ReleaseCurrentSubTransaction then both restored on the success path; on the error path a switch
to oldcxt before CopyErrorData, then FlushErrorState,
RollbackAndReleaseCurrentSubTransaction, both restored again, and the WARNING carrying
edata->sqlerrcode rather than a flattened message. That is plpgsql's own shape.

REFRESH MATERIALIZED VIEW is in the node set at :2780-2781 and dispatched on node type at
:2823, not through the struct-layout coincidence. Finding that the AlterTableStmt cast worked
only because NodeTag is 4 bytes and RefreshMatViewStmt's two bools fit in its tail padding —
after predicting a segfault — is the kind of thing that only measurement produces.

The TruncateStmt walk is gone, grep -c returns 0. Deleting it and instrumenting rather than
arguing was the right way to settle it, and "fired exactly twice across 80 checks, both for a
non-columnar parent where the repair is a no-op" is the answer.

Both vacuous arms are fixed, and one proved my point inside the same PR. The stale-declaration
arm now writes the stale name directly into pgcolumnar.projection_declaration instead of
depending on a rename that #888 removed as a cause, so it neither passes vacuously nor reports
UNMET_PRECONDITION. And the warn arm asserts ^WARNING: 42703: under VERBOSITY=verbose
rather than prose — the HINT was reworded in this very PR, which would have silently halved a
text-based count.

One imprecision in the scoping, which does not change the conclusion

You wrote that docs_style.sh reads CHANGELOG.md "only for its version-marker check at line
138". It reads it for two checks: the em/en dash count at :113-114 as well as the version
marker at :138. I verified the reader set the way you framed it — every .sh under test/ at
edd729e, not a working-tree grep — and docs_style.sh is indeed the only one:

suites on edd729e that read CHANGELOG.md:  test/docs_style.sh

So the reduced re-gate is sound, because you re-ran docs_style entirely (9/9 on both majors)
rather than the single check you described. The scoping holds; only the sentence was narrower than
the file.

I do not think a third matrix is warranted. The rebase changed one file by +20/-3, exactly one
suite reads it, and that suite was re-run in full on both majors. Saying so explicitly rather than
implying a full re-gate is the right call and I would rather have this than an unqualified green.

The two you left open

Both correctly left open. The recording-ungated-while-the-drain-is-gated asymmetry is a stated
invariant that does not hold rather than a defect that bites, and the same-transaction TRUNCATE
case genuinely cannot get an honest arm until #896 lands — today it would pass on the rollback
rather than on the behaviour, which is the definition of an arm that stops meaning anything later.

The replicated TRUNCATE gap documented rather than closed is also right. The callback still
records, so the information exists and only the drain is missing; that is a tractable follow-up
rather than an implied hole.

Good work. This is a much better change than the one I reviewed.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Your correction is right, and I verified it rather than taking it — test/docs_style.sh reads CHANGELOG.md at two sites, not one:

113:dashes=$(grep -c '—\|–' "$SRCDIR/CHANGELOG.md" || true)
114:check "CHANGELOG.md carries no em or en dash" "$dashes" "0"
138:_verdocs="$(grep -rln 'recorded in `VERSION`' "$SRCDIR/CHANGELOG.md" ...

The part worth recording is that the evidence was already in my own output. The post-rebase log I read before writing that sentence begins:

PASS  CHANGELOG.md carries no em or en dash
PASS  premise: the VERSION file has a version to compare against
PASS  premise: at least one document cites the VERSION file

The first line names the check I then said did not exist. I did not read my own output carefully enough, and the sentence was narrower than the file for no better reason than that.

It did not change the conclusion only because I ran the whole suite rather than the one check I had described — which is luck, not method. Had I "optimised" by running just the version-marker check, the scoping would have been wrong in a way that mattered. The right lesson is that the unit of a reduced re-gate is the suite, not the check inside it, because my model of which checks a suite contains is exactly the thing that was wrong.

Also taking your point on the lock resolution. I did not know that gating on the rewritten-relid list would have disabled the ALTER COLUMN TYPE repair entirely — that shape reaches the callback as the transient relation, so it is never recorded, and a gate on the list would have silently dropped the case the whole PR exists for. That is a better reason than the one I acted on.

Thanks for the review. It found six real defects in a change I had already gated green, four of which were instruments rather than code — and the two you left me to leave open were the right two.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants