fix: retire old storage catalog rows on TRUNCATE - #867
Conversation
The premise is real — reproducedI did not take the leak from the description. Measured on pg18a, unpatched main,
So the retired storage survives the TRUNCATE, a second id appears alongside it on Worth putting these numbers in the suite header: they are what makes the fix The one thing I would want pinned
oldsrel = RelationGetSmgr(rel);
if (smgrexists(oldsrel, MAIN_FORKNUM) &&
smgrnblocks(oldsrel, MAIN_FORKNUM) >= 2)
pgcolumnar_delete_storage_tree(PgColumnarStorageId(rel));Reading PostgreSQL's callers, the rewrite paths I would worry about — But that is me reading call sites, not running them. The suite asserts TRUNCATE. CREATE TABLE r (id int, v text) USING pgcolumnar;
INSERT INTO r SELECT g, 'x'||g FROM generate_series(1,5000) g;
ALTER TABLE r ALTER COLUMN v TYPE varchar(64); -- full rewrite
-- must still be 5000, and the rows must still be readableIf the guard ever misfires on a rewrite, the failure mode is silent data loss Two smaller notes:
Reviewed as OffgridwithJD. Not approving — same account as the author. |
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed adversarially. Requesting changes: this PR turns a loud failure into silent loss of committed data. I ran it on both trees rather than reasoning about it.
The regression, measured
Same script, same pg18_assert build, through the project's own harness so the extension is preloaded properly:
BEGIN; INSERT 11..20; TRUNCATE t; INSERT 21..30; COMMIT;
PR #867 the transaction COMMITTED rows after commit: 0 ids: <none>
main the transaction FAILED rows after commit: 10 ids: 1,2,...,10
On main the sequence errors and rolls back: the user is told, and the ten rows that were there before the transaction survive. On this branch the same sequence reports success and leaves an empty table. The rows inserted after the TRUNCATE are accepted, committed and discarded.
The mechanism is the one the finding names: deleting the retired storage's catalog rows removes the unique-key collision that was the only thing preventing a stale cached write state — still holding the old storage id across the rewrite — from committing into storage nothing reads. The fix removes the guardrail that was accidentally protecting the data.
A fix for a catalog leak must not be able to lose a committed row. This is the whole of my objection and everything below is secondary.
The suite cannot see it, and its headline arm is green with the fix reverted
test/truncate_cleanup.sh:69-77 is billed as the unbounded-leak coverage. All four statements go through one psql -c, so the server runs them in a single implicit transaction, and TRUNCATE on a relation created in the current transaction takes core's in-place path — table_relation_nontransactional_truncate — which never reaches the code this PR adds. The arm passes on unmodified main.
Two more arms cannot be reddened by any defect in what they name:
DROP after TRUNCATE leaves nothingandDROP of the truncated projected table leaves nothingeach run immediately after a check that has already assertedsnapshot == base, against a freshly truncated table with zero rows in all seven counted catalogs.DROPis asked to remove nothing, so the comparison is base against base. Deleting the entire cleanup thatDROPperforms leaves both green.TRUNCATE takes the projection's retired storage with itis satisfied byTRUNCATEdestroying the projection, and goes red if the product is corrected.$baseis captured before the projection exists, so "back to baseline" literally requirespgcolumnar.projectionto be empty.
Two further product problems
Only one of the two TRUNCATE paths is fixed. ExecuteTruncateGuts routes through heap_truncate_one_rel whenever the relation was created or given a new filelocator in the current subtransaction, so BEGIN; CREATE TABLE ... USING pgcolumnar; INSERT; TRUNCATE; COMMIT; never reaches the patched code at all.
TRUNCATE now destroys every declared projection. pgcolumnar_delete_storage_tree was factored out of the DROP hook and is run on a rewrite, but a rewrite must re-record the projections under the new storage id — which is exactly what the repo's own rewrite paths do at columnar_vacuum.c:1295-1317 and :1476-1497, deleting the old rows and re-inserting them under newStorageId. This one deletes and never re-records, so after an ordinary TRUNCATE the table has no projection rows and read_projection fails with 42704.
What is right
The underlying complaint is real — retired storage did leak catalog rows, and factoring the delete into a helper is the correct shape. The problem is where it is called from and what it removes on the way.
|
Pushed I reproduced it on both trees before touching anythingPG 17.10, each statement its own
Your diagnosis is confirmed by the fix working: the cached write state holds the storage id whose rows the retire had just deleted, the second INSERT reuses it, and it flushes into a storage the relation no longer reads. The primary-key collision against the surviving old rows was the only thing making that safe, and deleting them removed it. The retire now drops the cached write state in the same branch. Forget rather than flush — the rows that state buffers are exactly the rows the rewrite is discarding. Note the third row of that table. The sibling path is patched too (ask 3)
Two committed arms, and one thing I did deliberatelyThe transaction arm asserts the exit status and the surviving row range together, in one check. Split apart, a commit that leaves nothing passes the status half, and a lost The second arm is a rewrite that must not lose anything — Removal proofRevert both calls, keep the arms: the transaction arm goes red with Mutation asserted applied by source md5 (
Still open, not addressed here
I stopped here rather than carry on because the data-loss fix is worth reviewing on its own, and asks 4 to 6 will change what the suite measures — better to agree the product behaviour first. Happy to take them next, or leave them if you would rather split them out. |
Independent check at
|
jdatcmd
left a comment
There was a problem hiding this comment.
Not approving yet, and the reason is not a defect.
The adversarial pass found nothing blocking that survived refutation. Four findings were
raised against this PR and all four were killed by skeptics reading the real code — including
the alarming one ("TRUNCATE silently destroys every declared projection"), which collapses
because projection rows are keyed by storage id and pgcolumnar_relation_set_new_filelocator
mints a new id on every rewrite on main too, so the scenario yields the identical 42704
unpatched. I checked that refutation rather than taking it, and it holds.
What stops the approval is your own note on a499059: "Six of the nine asks are not [fixed]".
Those nine are mine. I am not going to sign off while six of them stand — either they get
addressed, or I withdraw them explicitly, and I have not read them closely enough yet to
withdraw them. Tell me which of the six you think are wrong and I will rule on those first;
that is faster than doing all six.
Two things I did verify independently, so they are not in dispute:
- The blocking data-loss defect is fixed.
BEGIN; INSERT; TRUNCATE; INSERT; COMMITcommits and
keeps the right rows. - Merge order against #872 is not mechanical. I merged both onto
mainfor real: they
conflict insrc/columnar_tableam.c, in the object-access hook. This PR's side has
pgcolumnar_delete_storage_tree(storageId); PgColumnarDeleteOptions(objectId);where #872
replaces the region with an explicit projection loop plus a relid-keyed cleanup gated on the
options table resolving. Whichever lands first, the other needs a real resolution and a
rebuild, not a hunk pick. Every other conflicting pair on this board isCHANGELOG.mdonly
(10 of 28 pairs conflict; this is the sole content conflict).
Reviewed adversarially at the head shown below: every finding raised against this PR was
handed to three independent skeptics with different lenses (is the code really like that;
can the named mutation really leave the test green; is it merge-blocking at all), each told
to refute and to default to refuted when uncertain. A finding is reported here only if it
survived at least two of those three.
No blocking finding survived refutation
Raised and killed (4)
Recorded so nobody re-litigates them:
TRUNCATE silently destroys every declared projection; the fix deletes the projection rows and never re-records them— refuted.test/truncate_cleanup.sh:64 asserts the destruction as correct: fixing the product turns this arm red— refuted.Four of the thirteen arms cannot go red for the defect they name— refuted.The pending delete-vector buffer still names the retired storage id, so the leak the PR closes reopens through DELETE, and the nontransactional path can hide committed rows— refuted.
Co-authored-by: Cursor <cursoragent@cursor.com>
Requesting-changes was right: as it stood this PR turned a loud failure into
silent loss of committed data. Reproduced on both trees before changing
anything, PG 17.10, each statement its own -c so TRUNCATE does not take core's
in-place same-transaction path:
BEGIN; INSERT 11..20; TRUNCATE t; INSERT 21..30; COMMIT;
main 53224e4 rc=1 1..10 n=10 ERROR, rolls back
this PR before rc=0 n=0 COMMITS, table EMPTY
The cached write state holds the storage id whose catalog rows the retire had
just deleted. The second INSERT reuses that state and flushes into a storage the
relation no longer reads; the relation reads the new one and finds it empty.
Before the retire existed, the old rows survived and the stale flush collided
with them on the primary key, so the transaction ERRORed. That collision was the
only thing making this safe, and deleting the rows removed it.
So the retire now drops the cached write state in the same branch. Forget rather
than flush: the rows that state buffers are exactly the rows the rewrite is
discarding.
this PR with the fix rc=0 21..30 n=10 commits, keeps the new rows
Note what that third line says. main is not the standard being restored here.
This transaction is ordinary SQL and it never worked on main either; it now
does.
The second TRUNCATE path is patched too. ExecuteTruncateGuts calls
heap_truncate_one_rel, and so pgcolumnar_relation_nontransactional_truncate,
when the relation got its filelocator in the current subtransaction, and that
path never reaches relation_set_new_filelocator. Same hazard, reached the other
way.
Two arms added to test/truncate_cleanup.sh. The transaction arm asserts the exit
status and the surviving row RANGE together, because split apart a commit that
leaves nothing passes the status half and a lost TRUNCATE (1..10) passes a bare
count of ten. The second arm is a rewrite that must not lose anything --
ALTER COLUMN TYPE over 5000 rows -- because the retire runs from
relation_set_new_filelocator, which every rewrite calls, and drop_cleanup
rewrites nothing.
Removal proof: revert both calls, keep the arms, and the transaction arm goes
red with `got [rc=0 -1..-1 n=0]`, which is the silent loss itself. Mutation
asserted applied by source md5 (1661f6c0 -> 031ab0e1), object md5
(3f7b8511 -> 4782e5ec) and call-site count (2 -> 0). NOT by grepping the symbol
out of the binary: it is defined in columnar_write_state.c and present in both,
so that check would have passed on the mutant.
truncate_cleanup 13/13, drop_cleanup 8/8, projections 64/64 on PG 17.10.
Six of the review's asks are NOT addressed here and are still open: re-recording
projection declarations after the rewrite deletes them, the three arms that
cannot go red for the defect they name, the header's prose counts, and the bare
literal 2 in the guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDbfRym2V1sYFmMZ5gnsQL
Five of jdatcmd's six open asks. The sixth (ask 2) is answered in the PR thread with a measurement rather than a commit, because it is not a defect this branch introduces. ASK 4 -- the headline unbounded-leak arm passed on unmodified main. Confirmed by running the suite as submitted against main 381c765: "ten create-insert-truncate-drop cycles leave nothing" is not among the six arms that redden there. Two reasons, both real. All four statements went through one psql_run, so the server saw one implicit transaction, and core routes TRUNCATE of a relation created in the current subtransaction through table_relation_nontransactional_truncate -- an in-place path this PR's code never sees. And the DROP inside each cycle removed the storage that cycle had just created, so nothing could accumulate even on the leaking path. The table is now created once outside the loop and each statement gets its own psql_run. On main the arm reaches 15/0/15/32/64/32/0 against a 5/0/5/12/24/12/0 baseline. ASK 5 -- the two DROP arms could not redden for anything DROP does. Each ran straight after a check that had already asserted snapshot == base, against a table TRUNCATE had just emptied, so DROP was asked to remove nothing and the comparison was base against base. Proved by mutation rather than by reading: gut the DROP-path cleanup at src/columnar_tableam.c (pgcolumnar_delete_storage_ tree(storageId) -> a no-op, src md5 dfb2c39900de -> d7422c0f8d27) and leave the TRUNCATE-path retire intact, so DROP is the only thing broken: suite as submitted 13 passed + 0 failed PASSED this suite 15 passed + 2 failed both DROP arms red The table is loaded before each DROP now, with a premise arm asserting it is loaded, so the DROP has work to do. ASK 6 -- "TRUNCATE takes the projection's retired storage with it" compared the whole snapshot against a $base captured before the projection existed, so it required pgcolumnar.projection to be EMPTY. It does redden on main today, but it would also redden if a rewrite were corrected to re-record the projection under the new storage id, which is the ask's point. It now counts the rows that name a storage id the table no longer has -- which is the leak itself -- and a second arm holds that the declaration survives, so the projection stays rebuildable. On main: got [2] want [0]. ASK 8 -- the header's prose counts are replaced by the numbers this suite's own fixture produces on unpatched main, including the sharper half the prose missed: after ten cycles DROP TABLE removes nothing at all, because DROP deletes rows for the relation's CURRENT storage id and after a TRUNCATE that id has not been written to yet. Ten orphaned storages outlive the table. ASK 9 -- smgrnblocks(...) >= 2 encoded the block layout as a bare literal. COLUMNAR_METAPAGE_BLOCKNO and COLUMNAR_EMPTY_BLOCKNO move from private #defines in columnar_storage.c to columnar_storage.h, which already declares that file's interface, and COLUMNAR_INITIALIZED_NBLOCKS is derived from them rather than written as 2. Adding a block now moves the guard with the writer. Green on pg18a at this head: truncate_cleanup 17/17, drop_cleanup 8/8, projections 64/64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQbQUiMSpGWembJV1jxob
a499059 to
056d393
Compare
Five of the six asks closed at
|
main 381c765 |
#867 | |
|---|---|---|
pgcolumnar.projection rows before TRUNCATE |
2 | 2 |
| after TRUNCATE | 2 | 0 |
pgcolumnar.projection_declaration |
1 | 1 |
read_projection after TRUNCATE |
42704 |
42704 |
| after a re-INSERT | 2 | 0 |
read_projection after that |
42704 |
42704 |
Two things follow. The declaration survives on both, so "TRUNCATE destroys every declared projection" is not what happens — the declaration is intact and the projection is rebuildable. And read_projection gives the identical 42704 on main, so this PR introduces nothing: your skeptics' reason was right, set_new_filelocator mints a new storage id on every rewrite on main too. main's two surviving rows are the leak this PR closes.
What is real is pre-existing and belongs in its own issue: no rewrite path re-records projections under the new storage id, so read_projection is dead after any TRUNCATE on both trees, and columnar_vacuum.c:1295-1317 / :1476-1497 show the shape a fix would take. I will file it unless you would rather.
Asks 4 and 5 — both right, and I proved each by running rather than reading
Ask 4. I ran the suite as submitted against main 381c765. "ten create-insert-truncate-drop cycles leave nothing" is not among the six arms that redden there — it passes on main, exactly as you said. Two causes, both real: one psql_run for all four statements, so the server saw one implicit transaction and core routed TRUNCATE through table_relation_nontransactional_truncate; and the DROP inside each cycle removed the storage that cycle had just created, so nothing could accumulate even on the leaking path. The table is now created once outside the loop and each statement gets its own psql_run. On main the arm reaches 15/0/15/32/64/32/0 against a 5/0/5/12/24/12/0 baseline.
Ask 5. Proved by mutation. Gut the DROP-path cleanup (pgcolumnar_delete_storage_tree(storageId) → no-op, src/columnar_tableam.c md5 dfb2c39900de → d7422c0f8d27), leaving the TRUNCATE retire intact so DROP is the only thing broken:
suite as submitted 13 passed + 0 failed PASSED
this suite 15 passed + 2 failed both DROP arms red
Each DROP now runs against a loaded table, with a premise arm asserting it is loaded.
Ask 6 — adopted, not refuted. The old arm does redden on main. But you are right that it pins the wrong thing: $base was captured before the projection existed, so it requires pgcolumnar.projection to be empty, and a rewrite corrected to re-record the projection would redden it. It now counts rows naming a storage id the table no longer has — which is the leak itself — plus an arm that the declaration survives. On main: got [2] want [0].
Ask 8. The header carries the measured numbers now, including the half the prose missed: after ten cycles DROP TABLE removes nothing at all, because DROP deletes rows for the relation's current storage id and after a TRUNCATE that id has not been written to yet. Ten orphaned storages outlive the table.
Ask 9. COLUMNAR_METAPAGE_BLOCKNO and COLUMNAR_EMPTY_BLOCKNO move from private #defines in columnar_storage.c to columnar_storage.h, and COLUMNAR_INITIALIZED_NBLOCKS is derived from them. The guard stops saying 2.
Gate
pg18a and pg19a, 13 suites each: 351 passed, 0 failed both majors. truncate_cleanup 13 → 17 arms. Preflight builds clean on pg15a/16a/17a/18a/19a — this is the only branch on the board with a C change.
Rebase onto 381c765 checked by per-file patch md5 over content lines only: clean, no conflicts.
Still yours
#867 and #872 still conflict substantively in the object-access hook. I have deliberately not pre-resolved it: whichever you merge first decides the shape, and a hunk pick either way loses something.
Not approving: same account as the author.
jdatcmd
left a comment
There was a problem hiding this comment.
Approved at 056d393. This clears my CHANGES_REQUESTED.
Ask 2 is WITHDRAWN, and it was wrong rather than merely satisfied. I claimed this PR made
TRUNCATE destroy declared projections and leave read_projection failing with 42704. I
checked it against main before withdrawing, in the source rather than from your run:
pgcolumnar_relation_set_new_filelocatoronmainunconditionally does
PgColumnarNextStorageId()andPgColumnarWriteNewMetapage(...)(src/columnar_tableam.c:783-785).
Every rewrite mints a new storage id,TRUNCATEincluded, onmainexactly as here.- projection lookups key on the current id —
read_projectiondoes
storageId = PgColumnarStorageId(rel); projs = PgColumnarListProjections(storageId);
(src/columnar_projection.c:439-441) and raisesERRCODE_UNDEFINED_OBJECTwhen that list is
empty.
So the 42704 after a TRUNCATE is there on main too, and this PR introduces none of it.
main's two surviving rows are the leak this PR closes, not a working projection. My ask
attributed a pre-existing failure to your change, and my own skeptics reached the same
conclusion independently before your measurement did.
The real defect is pre-existing and should be its own issue: no rewrite path re-records
projections under the new storage id, so read_projection is dead after any TRUNCATE on both
trees — which is the sharper form of what I was reaching for, since some rewrite paths
(columnar_vacuum.c:1295-1317, :1476-1497) do re-record and this one does not. Please file
it; it is yours to describe and it should not hold this PR.
Asks 4, 5, 6, 8 and 9 are addressed, and the ones I care about most are addressed by running
rather than by argument: ask 4 by running the suite as submitted against main and finding
the cycles arm was not among the six that redden there, with both causes named; ask 5 by
gutting the DROP-path cleanup alone and showing the submitted suite stays 13/0 PASSED while the
rewritten one goes 15/2 with both DROP arms red. That second one is the removal proof the suite
did not previously have.
Composition, not the branch. Full PG 17.10 matrix on main 381c765 + #867 + #869:
242 verdicts, 237 PASS, 5 SKIP, 0 FAIL, ALL VERSIONS PASSED. Set difference against the
main control adds truncate_cleanup=PASS and takes nothing away; nothing that passed on the
previous composed tree stopped passing.
Not merging #872 alongside this. The two conflict substantively in the object-access hook
and I re-measured it against current main rather than trusting the earlier matrix: #867+#869
compose clean, #869+#872 collide on CHANGELOG.md only, #867+#872 on
src/columnar_tableam.c. Merging this pair leaves exactly one hand resolution instead of two.
Summary
TRUNCATEinstalls a new relfilenode and storage id. Catalog rows for the retired id were left behind becauseDROPonly deletes the current id.Test coverage
test/truncate_cleanup.shtest/drop_cleanup.sh(unchanged, still passes)Made with Cursor