From 749994f644c512ca4f4a0d152e6546c70fbed9ab Mon Sep 17 00:00:00 2001 From: Haibo Yan Date: Tue, 15 Sep 2026 14:23:26 -0700 Subject: [PATCH] Refresh global temporary table freeze horizons after ON COMMIT DELETE ROWS. relfrozenxid and relminmxid bound the XIDs and MultiXactIds that can appear in a relation's tuples. For a global temporary table these are session-local, held in GtrInfo, and each backend publishes the minimum over all the global temporary tables it is using as PGPROC.tempfrozenxid and tempminmxid, which vac_update_datfrozenxid() folds into datfrozenxid. ON COMMIT DELETE ROWS empties the table at every commit, via heap_truncate() -> heap_truncate_one_rel(), which truncates the main relation, all of its indexes, and its TOAST relation and their indexes. No tuple survives, so the old bounds no longer describe anything. They were nevertheless left alone, so a session kept publishing the horizon of its very first use of the table for as long as it lived, holding back datfrozenxid cluster-wide even though the table was empty at every commit boundary. That is precisely the shape of the intended use case: a long-lived pooled session whose only global temporary table is an ON COMMIT DELETE ROWS scratch table. Such a session also has no reason to run VACUUM, and running it would not help, because there is nothing left to vacuum. An explicit TRUNCATE did release the horizon, because it goes through RelationSetNewRelfilenumber(). So advance the horizon in heap_truncate_one_rel(), for the relation and for its TOAST relation, to the values heapam_relation_set_new_filelocator() gives new storage, which is what explicit TRUNCATE ends up with. The update is non-transactional, matching table_relation_nontransactional_truncate(): if the transaction aborts after the truncation the storage stays empty, so the horizon must stay advanced too. Only GtrInfo is written here; the recomputation of tempfrozenxid and tempminmxid already happens in AtEOXact_GlobalTempRelation(), which runs after PreCommit_on_commit_actions(), so no new publication path is needed. Relations whose table AM does not require per-relation horizons leave them invalid, and indexes never carry them; those are left alone, and the values are never moved backwards. Partitioned tables have no storage and are already skipped. --- src/backend/catalog/heap.c | 69 ++++++++++++++ .../isolation/expected/vacuum-global-temp.out | 92 ++++++++++++++++++- .../isolation/specs/vacuum-global-temp.spec | 53 ++++++++++- 3 files changed, 211 insertions(+), 3 deletions(-) diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c index 93cc874257f..c82ac9229d9 100644 --- a/src/backend/catalog/heap.c +++ b/src/backend/catalog/heap.c @@ -76,6 +76,7 @@ #include "utils/fmgroids.h" #include "utils/inval.h" #include "utils/lsyscache.h" +#include "utils/snapmgr.h" #include "utils/syscache.h" @@ -3715,6 +3716,70 @@ heap_truncate(List *relids) } } +/* + * gtr_reset_freeze_horizon + * + * Advance the session-local freeze horizon of a global temporary relation + * whose storage has just been truncated to empty. + * + * relfrozenxid and relminmxid bound the XIDs and MultiXactIds that can appear + * in a relation's tuples. heap_truncate_one_rel() has just removed every + * tuple from this relation's storage, so the old bounds no longer describe + * anything, and they can be advanced to the values a freshly created empty + * relation would be given. Without this, a session using an ON COMMIT DELETE + * ROWS global temporary table would go on publishing the horizon of its first + * use in tempfrozenxid/tempminmxid for as long as it lived, holding back + * datfrozenxid, even though the table is empty after every commit. + * + * The values are the ones heapam_relation_set_new_filelocator() uses for new + * storage, which is also what an explicit TRUNCATE ends up with by way of + * RelationSetNewRelfilenumber(). + * + * This update is deliberately non-transactional, to match + * table_relation_nontransactional_truncate(): if the transaction aborts after + * the truncation, the storage stays empty, so the horizon must stay advanced + * too. Only GtrInfo is touched here; the recomputation of this backend's + * tempfrozenxid and tempminmxid happens in AtEOXact_GlobalTempRelation(), + * which runs later, on commit. + * + * A table AM that does not need per-relation horizons leaves them invalid, and + * indexes never carry them; those are left alone. The values are also never + * moved backwards. + */ +static void +gtr_reset_freeze_horizon(Relation rel) +{ + GtrInfo *gtr_info; + bool changed = false; + + if (!RELATION_IS_GLOBAL_TEMP(rel)) + return; + + gtr_info = GetGlobalTempRelationInfoForInPlaceUpdate(RelationGetRelid(rel)); + + if (TransactionIdIsValid(gtr_info->relfrozenxid) && + TransactionIdPrecedes(gtr_info->relfrozenxid, RecentXmin)) + { + gtr_info->relfrozenxid = RecentXmin; + changed = true; + } + + if (MultiXactIdIsValid(gtr_info->relminmxid)) + { + MultiXactId oldestMulti = GetOldestMultiXactId(); + + if (MultiXactIdPrecedes(gtr_info->relminmxid, oldestMulti)) + { + gtr_info->relminmxid = oldestMulti; + changed = true; + } + } + + /* Arrange for tempfrozenxid and tempminmxid to be recomputed on commit */ + if (changed) + UpdateTempFrozenXids(); +} + /* * heap_truncate_one_rel * @@ -3751,6 +3816,9 @@ heap_truncate_one_rel(Relation rel) /* If the relation has indexes, truncate the indexes too */ RelationTruncateIndexes(rel, lockmode); + /* The relation is now empty; its freeze horizon can be advanced */ + gtr_reset_freeze_horizon(rel); + /* If there is a toast table, truncate that too */ toastrelid = rel->rd_rel->reltoastrelid; if (OidIsValid(toastrelid)) @@ -3759,6 +3827,7 @@ heap_truncate_one_rel(Relation rel) table_relation_nontransactional_truncate(toastrel); RelationTruncateIndexes(toastrel, lockmode); + gtr_reset_freeze_horizon(toastrel); /* keep the lock... */ table_close(toastrel, NoLock); } diff --git a/src/test/isolation/expected/vacuum-global-temp.out b/src/test/isolation/expected/vacuum-global-temp.out index c8d9c375ff9..636104d4b22 100644 --- a/src/test/isolation/expected/vacuum-global-temp.out +++ b/src/test/isolation/expected/vacuum-global-temp.out @@ -1,6 +1,6 @@ Parsed test spec with 2 sessions -starting permutation: create vacdml1 vac1 vacdml2 vac2 vacdml1 vac1prep vac1 vac1cmp vacdml2 vac2prep vac2 vac2cmp vacdml1 vac1prep vac1 vac1cmp vacdml2 vac2prep vac2 vac2cmp +starting permutation: create vacdml1 vac1 vacdml2 vac2 vacdml1 vac1prep vac1 vac1cmp vacdml2 vac2prep vac2 vac2cmp vacdml1 vac1prep vac1 vac1cmp vacdml2 vac2prep vac2 vac2cmp ocd_use ocd_save vacdml1 vacdml2 vacdml1 vacdml2 ocd_use ocd_check ocd_save vacdml1 vacdml2 ocd_trunc ocd_check step create: CREATE GLOBAL TEMP TABLE vactest (a int); CREATE TABLE saved_xids(local_xid bigint, global_xid bigint, db_xid bigint); @@ -44,6 +44,42 @@ step create: FROM saved_xids, new_xids; END; + -- An ON COMMIT DELETE ROWS table is empty after every commit, so it must not + -- keep pinning the freeze horizon of its first use. Give it a TOAST relation + -- so that horizon is checked too. + CREATE GLOBAL TEMP TABLE ocdtest (a int, b text) ON COMMIT DELETE ROWS; + ALTER TABLE ocdtest ALTER COLUMN b SET STORAGE EXTERNAL; + CREATE TABLE ocd_saved(rel_xid bigint, toast_xid bigint); + + CREATE FUNCTION ocd_save() RETURNS void + BEGIN ATOMIC + DELETE FROM ocd_saved; + INSERT INTO ocd_saved VALUES ( + (SELECT relfrozenxid::text::bigint FROM pg_gtr_info('ocdtest'::regclass)), + (SELECT relfrozenxid::text::bigint FROM pg_gtr_info( + (SELECT reltoastrelid FROM pg_class WHERE oid = 'ocdtest'::regclass))) + ); + END; + + CREATE FUNCTION ocd_check(OUT rel_xid text, OUT toast_xid text, + OUT rows bigint, OUT proc_min_ok boolean) + BEGIN ATOMIC + SELECT cmp_xids((SELECT relfrozenxid::text::bigint + FROM pg_gtr_info('ocdtest'::regclass)), + s.rel_xid), + cmp_xids((SELECT relfrozenxid::text::bigint + FROM pg_gtr_info((SELECT reltoastrelid FROM pg_class + WHERE oid = 'ocdtest'::regclass))), + s.toast_xid), + (SELECT count(*) FROM ocdtest), + -- the value published in PGPROC must match the in-memory minimum + (SELECT a.tempfrozenxid::text::bigint FROM pg_stat_activity a + WHERE a.pid = pg_backend_pid()) = + (SELECT min(t.relfrozenxid::text::bigint) FROM pg_gtrs_in_use() t + WHERE t.relfrozenxid != 0) + FROM ocd_saved s; + END; + step vacdml1: INSERT INTO vactest SELECT * FROM generate_series(1, 10); DELETE FROM vactest WHERE a % 2 = 0; @@ -125,3 +161,57 @@ local_xid|global_xid|db_xid younger |younger |younger (1 row) +step ocd_use: INSERT INTO ocdtest SELECT g, repeat('x', 3000) FROM generate_series(1, 20) g; +step ocd_save: SELECT ocd_save(); +ocd_save +-------- + +(1 row) + +step vacdml1: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + DELETE FROM vactest WHERE a % 2 = 0; + INSERT INTO vactest SELECT a * 2 FROM vactest; + +step vacdml2: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + UPDATE vactest SET a = a * 2 WHERE a % 2 = 1; + +step vacdml1: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + DELETE FROM vactest WHERE a % 2 = 0; + INSERT INTO vactest SELECT a * 2 FROM vactest; + +step vacdml2: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + UPDATE vactest SET a = a * 2 WHERE a % 2 = 1; + +step ocd_use: INSERT INTO ocdtest SELECT g, repeat('x', 3000) FROM generate_series(1, 20) g; +step ocd_check: SELECT * FROM ocd_check(); +rel_xid|toast_xid|rows|proc_min_ok +-------+---------+----+----------- +younger|younger | 0|t +(1 row) + +step ocd_save: SELECT ocd_save(); +ocd_save +-------- + +(1 row) + +step vacdml1: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + DELETE FROM vactest WHERE a % 2 = 0; + INSERT INTO vactest SELECT a * 2 FROM vactest; + +step vacdml2: + INSERT INTO vactest SELECT * FROM generate_series(1, 10); + UPDATE vactest SET a = a * 2 WHERE a % 2 = 1; + +step ocd_trunc: TRUNCATE ocdtest; +step ocd_check: SELECT * FROM ocd_check(); +rel_xid|toast_xid|rows|proc_min_ok +-------+---------+----+----------- +younger|younger | 0|t +(1 row) + diff --git a/src/test/isolation/specs/vacuum-global-temp.spec b/src/test/isolation/specs/vacuum-global-temp.spec index e3de3e773bd..23f7c41738c 100644 --- a/src/test/isolation/specs/vacuum-global-temp.spec +++ b/src/test/isolation/specs/vacuum-global-temp.spec @@ -1,8 +1,8 @@ # Test vacuuming global temporary relations teardown { - DROP FUNCTION save_xids, cmp_xids, check_new_xids; - DROP TABLE vactest, saved_xids; + DROP FUNCTION save_xids, cmp_xids, check_new_xids, ocd_save, ocd_check; + DROP TABLE vactest, saved_xids, ocdtest, ocd_saved; } session s1 @@ -48,6 +48,42 @@ step create { cmp_xids(new_db_xid, db_xid) FROM saved_xids, new_xids; END; + + -- An ON COMMIT DELETE ROWS table is empty after every commit, so it must not + -- keep pinning the freeze horizon of its first use. Give it a TOAST relation + -- so that horizon is checked too. + CREATE GLOBAL TEMP TABLE ocdtest (a int, b text) ON COMMIT DELETE ROWS; + ALTER TABLE ocdtest ALTER COLUMN b SET STORAGE EXTERNAL; + CREATE TABLE ocd_saved(rel_xid bigint, toast_xid bigint); + + CREATE FUNCTION ocd_save() RETURNS void + BEGIN ATOMIC + DELETE FROM ocd_saved; + INSERT INTO ocd_saved VALUES ( + (SELECT relfrozenxid::text::bigint FROM pg_gtr_info('ocdtest'::regclass)), + (SELECT relfrozenxid::text::bigint FROM pg_gtr_info( + (SELECT reltoastrelid FROM pg_class WHERE oid = 'ocdtest'::regclass))) + ); + END; + + CREATE FUNCTION ocd_check(OUT rel_xid text, OUT toast_xid text, + OUT rows bigint, OUT proc_min_ok boolean) + BEGIN ATOMIC + SELECT cmp_xids((SELECT relfrozenxid::text::bigint + FROM pg_gtr_info('ocdtest'::regclass)), + s.rel_xid), + cmp_xids((SELECT relfrozenxid::text::bigint + FROM pg_gtr_info((SELECT reltoastrelid FROM pg_class + WHERE oid = 'ocdtest'::regclass))), + s.toast_xid), + (SELECT count(*) FROM ocdtest), + -- the value published in PGPROC must match the in-memory minimum + (SELECT a.tempfrozenxid::text::bigint FROM pg_stat_activity a + WHERE a.pid = pg_backend_pid()) = + (SELECT min(t.relfrozenxid::text::bigint) FROM pg_gtrs_in_use() t + WHERE t.relfrozenxid != 0) + FROM ocd_saved s; + END; } step vacdml1 { INSERT INTO vactest SELECT * FROM generate_series(1, 10); @@ -57,6 +93,10 @@ step vacdml1 { step vac1prep { SELECT save_xids(); } step vac1 { VACUUM (FREEZE); } step vac1cmp { SELECT * FROM check_new_xids(); } +step ocd_use { INSERT INTO ocdtest SELECT g, repeat('x', 3000) FROM generate_series(1, 20) g; } +step ocd_save { SELECT ocd_save(); } +step ocd_check { SELECT * FROM ocd_check(); } +step ocd_trunc { TRUNCATE ocdtest; } session s2 step vacdml2 { @@ -73,3 +113,12 @@ permutation vacdml2 vac2prep vac2 vac2cmp vacdml1 vac1prep vac1 vac1cmp vacdml2 vac2prep vac2 vac2cmp + # ON COMMIT DELETE ROWS: the table is empty after each step's commit, so its + # horizon must be younger once XIDs have been consumed, not pinned to its + # first use. Consume the XIDs with plain DML rather than VACUUM, so that the + # horizon can only have moved because of the ON COMMIT truncation. An + # explicit TRUNCATE must go on behaving the same way. + ocd_use ocd_save + vacdml1 vacdml2 vacdml1 vacdml2 + ocd_use ocd_check + ocd_save vacdml1 vacdml2 ocd_trunc ocd_check -- 2.54.0